# ============================================================================
# pyvale: the python validation engine
# License: MIT
# Copyright (C) 2026 Sceptical Rabbit (Lloyd Fletcher)
# ============================================================================
"""Feebee ray tracer scene definitions and renderer scaffold.
Feebee is pyvale's future ray tracing backend - these are stubs/placeholders
that will not be implemented until Feebee is merged.
"""
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
import numpy as np
from .capabilities import RenderCapabilities
from .errors import ValidationIssue
from .mesh import EElemType, Mesh3D
from .renderer3d import IRenderer3D
from .result import RenderResult
from .scene import Scene3D
from .verifyinput import raise_if_issues, verify_scene_3d
[docs]
class EFeebeeMaterialType(Enum):
"""Light transport models supplied by Feebee.
``UNLIT`` returns the shader colour without lighting. ``DIFFUSE``,
``SPECULAR``, and ``REFRACTIVE`` use Feebee's ray tracing material paths.
"""
DIFFUSE = "diffuse"
SPECULAR = "specular"
REFRACTIVE = "refractive"
UNLIT = "unlit"
[docs]
class EFeebeeShading(Enum):
"""Normal interpolation methods supported by Feebee.
``FLAT`` uses geometric normals. ``BLENDED`` uses the element specific
normal interpolation used by the original ray tracer. ``ANGLE_AVERAGED``
uses angle averaged node normals for every supported surface topology.
"""
FLAT = "flat"
BLENDED = "blended"
ANGLE_AVERAGED = "angle_averaged"
[docs]
class EFeebeeTextureSampler(Enum):
"""Texture filters planned for Feebee's texture shader."""
NEAREST_NEIGHBOUR = "nearest_neighbour"
LANCZOS_2 = "lanczos_2"
LANCZOS_3 = "lanczos_3"
CATMULL_ROM = "catmull_rom"
MITCHELL_NETRAVALI = "mitchell_netravali"
BSPLINE = "bspline"
QUINTIC_SPLINE = "quintic_spline"
[docs]
@dataclass(slots=True)
class FeebeeMaterial:
"""Material properties for one Feebee mesh shader.
Parameters
----------
material_type : EFeebeeMaterialType, optional
Ray transport model for the mesh. Defaults to
``EFeebeeMaterialType.DIFFUSE``.
colour : np.ndarray, optional
RGB multiplier or refractive tint array with shape ``(3,)`` and dtype
``float64``. Values are linear and non negative; values exceeding 1.0
are permitted. Defaults to ``np.ones(3)``.
refractive_index : float or None, optional
Material refractive index. Required for refractive materials.
priority : int, optional
Nesting priority used to resolve overlapping refractive volumes.
Defaults to 0.
is_shell : bool, optional
Whether the mesh represents a shell rather than a closed solid.
Defaults to ``False``.
thickness : float, optional
Physical shell thickness. Used only when ``is_shell`` is true.
Defaults to 1.0.
"""
material_type: EFeebeeMaterialType = EFeebeeMaterialType.DIFFUSE
colour: np.ndarray = field(
default_factory=lambda: np.ones(3, dtype=np.float64),
)
refractive_index: float | None = None
priority: int = 0
is_shell: bool = False
thickness: float = 1.0
def __post_init__(self) -> None:
"""Store the immutable colour vector as a float array."""
self.colour = np.ascontiguousarray(self.colour, dtype=np.float64)
[docs]
@dataclass(slots=True)
class FeebeeColourShader:
"""Face colour shader with a Feebee material.
Parameters
----------
colours : np.ndarray
RGB face colours array with shape
``(num_frames, num_elements, 3)`` and dtype ``float64``. A single
frame can be used for a static mesh.
material : FeebeeMaterial, optional
Light transport properties applied to the face colours.
"""
colours: np.ndarray
material: FeebeeMaterial = field(default_factory=FeebeeMaterial)
def __post_init__(self) -> None:
"""Store contiguous double precision face colour data."""
self.colours = np.ascontiguousarray(self.colours, dtype=np.float64)
[docs]
@dataclass(slots=True)
class FeebeeTextureShader:
"""Texture shader with nodal UV coordinates and a Feebee material.
Parameters
----------
uvs : np.ndarray
Nodal UV coordinate array with shape ``(num_nodes, 2)`` and dtype
``float64`` in the normalized range ``[0.0, 1.0]``.
texture : np.ndarray
Greyscale or RGB texture image array with shape ``(height, width)``
or ``(height, width, num_channels)``.
material : FeebeeMaterial, optional
Light transport properties applied to sampled texture values.
"""
uvs: np.ndarray
texture: np.ndarray
material: FeebeeMaterial = field(default_factory=FeebeeMaterial)
def __post_init__(self) -> None:
"""Store contiguous texture coordinate and image arrays."""
self.uvs = np.ascontiguousarray(self.uvs)
self.texture = np.ascontiguousarray(self.texture)
[docs]
@dataclass(slots=True)
class FeebeeConfig:
"""Global options for a Feebee render request.
Parameters
----------
background_colour : np.ndarray, optional
Linear RGB scene background array with shape ``(3,)`` and dtype
``float64``. Defaults to ``(0.7, 0.7, 0.7)``.
scene_refractive_index : float, optional
Refractive index of the medium that fills the scene. Defaults to
1.0003.
antialiasing_samples : int, optional
Number of samples evaluated for every pixel. Defaults to 1.
shading : EFeebeeShading, optional
Surface normal interpolation method. Defaults to
``EFeebeeShading.FLAT``.
texture_sampler : EFeebeeTextureSampler, optional
Filter used by texture shaders. Defaults to
``EFeebeeTextureSampler.NEAREST_NEIGHBOUR``.
max_depth : int or None, optional
Maximum secondary ray depth. ``None`` requests Feebee's automatic
depth selection.
min_refractive_depth : int or None, optional
Minimum deterministic depth before refractive paths may terminate.
output_dir : pathlib.Path or None, optional
Directory in which Feebee will write rendered image files.
"""
background_colour: np.ndarray = field(
default_factory=lambda: np.full(3, 0.7, dtype=np.float64),
)
scene_refractive_index: float = 1.0003
antialiasing_samples: int = 1
shading: EFeebeeShading = EFeebeeShading.FLAT
texture_sampler: EFeebeeTextureSampler = (
EFeebeeTextureSampler.NEAREST_NEIGHBOUR
)
max_depth: int | None = None
min_refractive_depth: int | None = None
output_dir: Path | None = None
def __post_init__(self) -> None:
"""Store background colour and output directory in normal form."""
self.background_colour = np.ascontiguousarray(
self.background_colour,
dtype=np.float64,
)
if self.output_dir is not None:
self.output_dir = Path(self.output_dir)
[docs]
class Feebee(IRenderer3D):
"""Prepare pyvale scenes for the forthcoming Feebee ray tracer.
The class already validates Feebee specific shader, material, and scene
options before any costly scene expansion occurs. The compiled rendering
backend has not yet been migrated, so :meth:`render` currently raises
:class:`NotImplementedError` only after successful validation.
Parameters
----------
config : FeebeeConfig, optional
Global options for the Feebee render request.
"""
capabilities = RenderCapabilities(
element_types=frozenset(EElemType),
supports_lights=False,
supports_camera_distortion=False,
supports_psf=False,
)
[docs]
def __init__(self, config: FeebeeConfig | None = None) -> None:
"""Create a Feebee renderer with global render options."""
self.config = FeebeeConfig() if config is None else config
def _render(self, scene: Scene3D) -> RenderResult:
"""Reject rendering until the compiled Feebee backend is migrated.
Parameters
----------
scene : Scene3D
Previously validated Feebee scene.
Raises
------
NotImplementedError
Always, until the Feebee C++ dispatch layer is integrated.
"""
raise NotImplementedError(
"Feebee's compiled rendering backend has not been migrated yet.",
)
def _verify_config(config: FeebeeConfig) -> tuple[ValidationIssue, ...]:
"""Return cheap validation issues for global Feebee options."""
if not isinstance(config, FeebeeConfig):
return (
ValidationIssue("config", "TYPE", "Expected render.FeebeeConfig."),
)
issues: list[ValidationIssue] = []
if (
config.background_colour.shape != (3,)
or not np.isfinite(config.background_colour).all()
or np.any(config.background_colour < 0.0)
):
issues.append(
ValidationIssue(
"config.background_colour",
"VALUE",
"Expected three finite, non-negative RGB values.",
),
)
if not _is_positive_finite(config.scene_refractive_index):
issues.append(
ValidationIssue(
"config.scene_refractive_index",
"VALUE",
"Expected a positive finite refractive index.",
),
)
if config.antialiasing_samples <= 0:
issues.append(
ValidationIssue(
"config.antialiasing_samples",
"VALUE",
"Expected a positive sample count.",
),
)
if not isinstance(config.shading, EFeebeeShading):
issues.append(
ValidationIssue(
"config.shading", "TYPE", "Expected EFeebeeShading."
),
)
if not isinstance(config.texture_sampler, EFeebeeTextureSampler):
issues.append(
ValidationIssue(
"config.texture_sampler",
"TYPE",
"Expected EFeebeeTextureSampler.",
),
)
issues.extend(_verify_depths(config.max_depth, config.min_refractive_depth))
return tuple(issues)
def _verify_depths(
max_depth: int | None,
min_refractive_depth: int | None,
) -> tuple[ValidationIssue, ...]:
"""Validate ray depth options used by the original ray tracer."""
issues: list[ValidationIssue] = []
for path, value, minimum in (
("config.max_depth", max_depth, 1),
("config.min_refractive_depth", min_refractive_depth, 0),
):
if value is not None and (
not isinstance(value, int) or value < minimum
):
issues.append(
ValidationIssue(
path, "VALUE", f"Expected an integer >= {minimum}."
),
)
if (
isinstance(max_depth, int)
and isinstance(min_refractive_depth, int)
and max_depth < min_refractive_depth
):
issues.append(
ValidationIssue(
"config.max_depth",
"VALUE",
"Maximum depth must be at least the refractive depth.",
),
)
return tuple(issues)
def _verify_mesh(mesh: Mesh3D, path: str) -> tuple[ValidationIssue, ...]:
"""Return Feebee specific validation issues for one mesh."""
issues: list[ValidationIssue] = []
nodes_per_element = {
EElemType.TRI3: 3,
EElemType.TRI6: 6,
EElemType.QUAD4: 4,
EElemType.QUAD8: 8,
EElemType.QUAD9: 9,
}
expected_nodes = nodes_per_element.get(mesh.element_type)
if expected_nodes is None:
issues.append(
ValidationIssue(
path + ".element_type",
"UNSUPPORTED",
"Unsupported Feebee element type.",
),
)
elif (
mesh.connectivity.ndim == 2
and mesh.connectivity.shape[1] != expected_nodes
):
issues.append(
ValidationIssue(
path + ".connectivity",
"SHAPE",
f"Expected {expected_nodes} nodes per "
f"{mesh.element_type.value} element.",
),
)
shader = mesh.shader
if not isinstance(shader, (FeebeeColourShader, FeebeeTextureShader)):
issues.append(
ValidationIssue(
path + ".shader",
"OWNERSHIP",
"Expected a render.feebee shader.",
),
)
return tuple(issues)
issues.extend(_verify_material(shader.material, path + ".shader.material"))
if isinstance(shader, FeebeeColourShader):
expected_shape = (None, mesh.connectivity.shape[0], 3)
if (
shader.colours.ndim != 3
or shader.colours.shape[1:] != expected_shape[1:]
or not _is_finite_array(shader.colours)
or np.any(shader.colours < 0.0)
):
issues.append(
ValidationIssue(
path + ".shader.colours",
"SHAPE",
"Expected finite non-negative shape (frames, elements, 3).",
),
)
if (
mesh.displacements is not None
and shader.colours.ndim == 3
and shader.colours.shape[0] not in (1, mesh.displacements.shape[0])
):
issues.append(
ValidationIssue(
path + ".shader.colours",
"FRAME_COUNT",
"Expected one colour frame or one per displacement frame.",
),
)
else:
if shader.uvs.shape != (mesh.coords.shape[0], 2):
issues.append(
ValidationIssue(
path + ".shader.uvs",
"SHAPE",
"Expected shape (nodes, 2).",
),
)
elif not np.isfinite(shader.uvs).all():
issues.append(
ValidationIssue(
path + ".shader.uvs",
"FINITE",
"Texture coordinates must be finite.",
),
)
if shader.texture.ndim not in (2, 3) or not _is_finite_array(
shader.texture
):
issues.append(
ValidationIssue(
path + ".shader.texture",
"SHAPE",
"Expected a finite greyscale or RGB texture image.",
),
)
return tuple(issues)
def _verify_material(
material: FeebeeMaterial,
path: str,
) -> tuple[ValidationIssue, ...]:
"""Return validation issues for Feebee material properties."""
issues: list[ValidationIssue] = []
if not isinstance(material.material_type, EFeebeeMaterialType):
issues.append(
ValidationIssue(
path + ".material_type",
"TYPE",
"Expected EFeebeeMaterialType.",
),
)
if (
material.colour.shape != (3,)
or not _is_finite_array(material.colour)
or np.any(material.colour < 0.0)
):
issues.append(
ValidationIssue(
path + ".colour",
"VALUE",
"Expected three finite, non-negative RGB values.",
),
)
if material.material_type is EFeebeeMaterialType.REFRACTIVE and (
material.refractive_index is None
or not _is_positive_finite(material.refractive_index)
):
issues.append(
ValidationIssue(
path + ".refractive_index",
"VALUE",
"Refractive materials require a positive finite index.",
),
)
if not isinstance(material.priority, int):
issues.append(
ValidationIssue(path + ".priority", "TYPE", "Expected an integer."),
)
if material.is_shell and (not _is_positive_finite(material.thickness)):
issues.append(
ValidationIssue(
path + ".thickness",
"VALUE",
"Shell materials require a positive finite thickness.",
),
)
return tuple(issues)
def _is_finite_array(values: np.ndarray) -> bool:
"""Return whether an array can represent only finite numeric values."""
try:
return bool(np.isfinite(values).all())
except TypeError:
return False
def _is_positive_finite(value: float | np.number) -> bool:
"""Return whether a scalar is finite and strictly positive."""
try:
return bool(np.isfinite(value) and value > 0.0)
except TypeError:
return False
def _mesh_frame_count(mesh: Mesh3D) -> int | None:
"""Return the animation frame count represented by one valid mesh."""
if mesh.displacements is not None:
return mesh.displacements.shape[0]
if (
isinstance(mesh.shader, FeebeeColourShader)
and mesh.shader.colours.ndim == 3
):
return mesh.shader.colours.shape[0]
return None
__all__ = [
"EFeebeeMaterialType",
"EFeebeeShading",
"EFeebeeTextureSampler",
"Feebee",
"FeebeeColourShader",
"FeebeeConfig",
"FeebeeMaterial",
"FeebeeTextureShader",
]