Source code for optiland.nonsequential.ir.interpreter

"""Reference IR interpreter -- NumPy/Torch backends drive their per-bounce
interaction loop from :class:`~optiland.nonsequential.ir.scene_ir.SceneIR`
data instead of iterating live ``scene.surfaces`` and branching on Python
class identity.

Design note: why this still calls into live component objects
----------------------------------------------------------------
A ``PrimitiveIR`` is pure data (translatability checklist, PR3): no
callbacks, no live objects. That is what a non-Python backend (Mitsuba,
OptiX) would need -- it never runs this module and never calls
``BaseComponent.interact()``; it reads ``PrimitiveIR.kind``/``params``/
``bsdf`` and writes its own kernel.

The NumPy/Torch *reference* interpreters are a different concern: they are
Python code, already have the live component objects in hand, and those
objects' ``intersect()``/``interact()``/``BaseBSDF.sample()`` methods are the
validated, gradient-checked implementation of the physics. Re-deriving that
same math a second time as free functions over raw ``PrimitiveIR.params``
would duplicate several hundred lines of numerically delicate code (conic
root selection, Harvey-Shack's cached inverse-CDF table, TIR handling, ...)
for no behavioural difference, at real risk of the two implementations
silently diverging. So the reference interpreters keep delegating the
*math* to the live objects, and this module supplies the part that is
genuinely new in PR4: the *dispatch* is driven by IR data (``PrimitiveIR
.component_kind``, ``PrimitiveIR.bsdf.kind``) rather than by
``isinstance()``/Python class identity, with a hard consistency check
(:func:`assert_component_kind_matches`, :func:`assert_bsdf_matches`) that
fires if ``lower()``'s mapping and a component's actual type ever disagree
-- the drift guard translatability checklist requires.

Kramer Harrison, 2026
"""

from __future__ import annotations

from collections.abc import Callable
from typing import TYPE_CHECKING

import numpy as np

import optiland.backend as be

if TYPE_CHECKING:
    from optiland.nonsequential.components.base import BaseComponent
    from optiland.nonsequential.ir.scene_ir import BsdfIR, PrimitiveIR, SceneIR
    from optiland.nonsequential.ray_bundle import NSQRayBundle
    from optiland.nonsequential.rng import NSQRng

LogHitFn = Callable[["NSQRayBundle", "np.ndarray", str, object], None]
RayIdAllocator = Callable[[int], "np.ndarray"]


def _component_kind_of(component: BaseComponent) -> str:
    """Return the :data:`~.scene_ir.ComponentKind` of a live component.

    Reuses :func:`optiland.nonsequential.ir.lower._component_kind` so there
    is exactly one place that maps a live component type to its IR kind.

    Args:
        component: A scene surface.

    Returns:
        One of ``"refractive"``, ``"reflective"``, ``"absorbing"``.
    """
    from optiland.nonsequential.ir.lower import _component_kind  # noqa: PLC0415

    return _component_kind(component)


def _bsdf_kind_of(bsdf: object | None) -> str:
    """Return the :data:`~.bsdf_ir.BsdfKind` of a live BSDF (or ``None``).

    Reuses :func:`optiland.nonsequential.ir.lower._lower_bsdf` for the same
    reason as :func:`_component_kind_of`.

    Args:
        bsdf: A BSDF instance, or ``None``.

    Returns:
        The BSDF's IR kind string.
    """
    from optiland.nonsequential.ir.lower import _lower_bsdf  # noqa: PLC0415

    return _lower_bsdf(bsdf).kind


[docs] def assert_component_kind_matches( component: BaseComponent, primitive: PrimitiveIR ) -> None: """Raise if a live component's type disagrees with its lowered IR kind. Cheap (no per-ray cost): only compares two short strings. Called once per hit primitive per bounce, never per ray. Args: component: The live component ``primitive`` was lowered from. primitive: The corresponding :class:`PrimitiveIR`. Raises: RuntimeError: If the live component's interaction type no longer matches what ``lower()`` recorded -- a lowering/interpreter drift bug, not a user configuration error. """ actual = _component_kind_of(component) if actual != primitive.component_kind: raise RuntimeError( f"Scene-IR drift on primitive '{primitive.name}': lower() " f"recorded component_kind={primitive.component_kind!r}, but the " f"live component is now a {type(component).__name__} " f"(kind {actual!r}). The interpreter and lower() must agree; " "this indicates a bug, not a scene configuration error." )
[docs] def assert_bsdf_matches(bsdf: object | None, bsdf_ir: BsdfIR) -> None: """Raise if a live BSDF's type disagrees with its lowered ``BsdfIR``. Args: bsdf: The live BSDF ``bsdf_ir`` was lowered from (or ``None``). bsdf_ir: The corresponding :class:`BsdfIR`. Raises: RuntimeError: If the live BSDF's type no longer matches what ``lower()`` recorded. """ actual = _bsdf_kind_of(bsdf) if actual != bsdf_ir.kind: raise RuntimeError( f"Scene-IR drift: lower() recorded BsdfIR.kind={bsdf_ir.kind!r}, " f"but the live BSDF is now {bsdf!r} (kind {actual!r}). The " "interpreter and lower() must agree; this indicates a bug, not " "a scene configuration error." )
[docs] def apply_primitive_interactions( rays: NSQRayBundle, ir: SceneIR, components: list[BaseComponent], t_min: object, hit_normals: object, hit_n_geom: object, comp_idx: np.ndarray, comp_first_np: np.ndarray, rng: NSQRng, log_hit_fn: LogHitFn | None = None, ray_id_allocator: RayIdAllocator | None = None, ) -> NSQRayBundle | None: """Apply each hit primitive's interaction to ``rays``, in-place. This is the shared per-bounce "which surface did each ray hit, and what happens" step -- previously duplicated almost verbatim between ``ArrayBackend.trace()`` and ``TorchBackend.trace()`` (backend-specific only in whether ``t_min``/``hit_normals`` are eager NumPy arrays or attached Torch tensors, which ``optiland.backend`` already abstracts). Dispatch is IR-driven: primitives are visited in ``ir.primitives`` order (not by iterating ``components`` and asking "is this the hit one"), and each hit is checked against its recorded :data:`ComponentKind`/ :class:`BsdfIR` before the live component's ``interact()`` executes the physics (see the module docstring for why the physics itself still lives on the component). Args: rays: Ray bundle to update in-place. ir: The scene's lowered IR (built once per ``trace()`` call). components: ``scene.surfaces``, in the same order ``ir.primitives`` was built from -- ``components[i]`` is the live object ``ir.primitives[i]`` was lowered from. t_min: Per-ray nearest-primitive hit distance, shape (N,). hit_normals: Per-ray nearest-primitive hit normal, shape (N, 3). hit_n_geom: Per-ray nearest-primitive geometric (unflipped) normal, shape (N, 3); see ``ComponentGeometry.ray_intersect``. comp_idx: Per-ray index into ``ir.primitives``/``components`` of the nearest-hit primitive, or -1. NumPy int array. comp_first_np: Per-ray mask: True where a primitive (not a detector) is this ray's nearest hit and should be processed this bounce. NumPy bool array. rng: Keyed PCG32 RNG. log_hit_fn: Optional ``(rays, mask, primitive_name, t_offset)`` callback for path recording, matching each backend's ``_log_hits`` closure. ray_id_allocator: ``(n) -> int64 ndarray`` of ``n`` fresh, previously -unused ray ids. Required to enable bounded splitting (D2, PR11, ``ir.sampling.split_depth > 0``) -- omit (the default) on the Torch backend, which forces ``split_depth=0`` and never spawns rays (fixed tensor shapes are required for the autograd graph). Returns: A new :class:`NSQRayBundle` of transmit-branch children spawned by bounded splitting this bounce, or ``None`` if none were spawned (splitting disabled, no eligible hits, or ``ray_id_allocator`` was not given). The caller is responsible for merging this into the live bundle -- see :meth:`optiland.nonsequential.backends.array_backend.ArrayBackend.trace`. """ spawned_chunks: list[NSQRayBundle] = [] for i, primitive in enumerate(ir.primitives): mask_i_np = comp_first_np & (comp_idx == i) if not mask_i_np.any(): continue component = components[i] assert_component_kind_matches(component, primitive) assert_bsdf_matches(component.bsdf, primitive.bsdf) if log_hit_fn is not None: log_hit_fn(rays, mask_i_np, primitive.name, t_min) split_eligible_np = np.zeros_like(mask_i_np) if ( ray_id_allocator is not None and ir.sampling.split_depth > 0 and primitive.component_kind == "refractive" ): bounce_np = np.asarray(rays.bounce) split_eligible_np = mask_i_np & (bounce_np < ir.sampling.split_depth) split_idx = np.where(split_eligible_np)[0] if split_idx.size == 0: mask_i = be.array(mask_i_np) component.interact( rays, t_min, hit_normals, mask_i, rng, primitive.bsdf, hit_n_geom, sampling=ir.sampling, ) continue # Bounded splitting: a hit ray below split_depth spawns # *both* Fresnel children instead of drawing one stochastically. # 1) Snapshot the pre-interaction state of the splitting subset # (fresh ray ids, so its RNG stream is independent of the sibling # that keeps the original id) before either branch mutates # anything. new_ids = ray_id_allocator(split_idx.size) transmit_snapshot = rays.select(split_idx, ray_id=new_ids) snap_t = np.asarray(t_min)[split_idx] snap_normals = np.asarray(hit_normals)[split_idx] snap_n_geom = np.asarray(hit_n_geom)[split_idx] snap_mask = be.array(np.ones(split_idx.size, dtype=bool)) # 2) Non-splitting remainder of this primitive's hits (if any): # normal single-branch draw. remainder_np = mask_i_np & ~split_eligible_np if remainder_np.any(): component.interact( rays, t_min, hit_normals, be.array(remainder_np), rng, primitive.bsdf, hit_n_geom, sampling=ir.sampling, ) # 3) Reflect child: force the branch in place on the original rays. component.interact( rays, t_min, hit_normals, be.array(split_eligible_np), rng, primitive.bsdf, hit_n_geom, sampling=ir.sampling, forced_branch="reflect", ) # 4) Transmit child: force the other branch on the snapshot, which # becomes a newly spawned ray in the live bundle. component.interact( transmit_snapshot, snap_t, snap_normals, snap_mask, rng, primitive.bsdf, snap_n_geom, sampling=ir.sampling, forced_branch="transmit", ) spawned_chunks.append(transmit_snapshot) if not spawned_chunks: return None from optiland.nonsequential.ray_bundle import NSQRayBundle # noqa: PLC0415 return NSQRayBundle.concat(spawned_chunks)