Source code for optiland.nonsequential.components.refractive

"""Refractive component for Non-Sequential Raytracing.

Transmits and reflects (lenses, prisms, windows). Uses detached-sample /
attached-weight Fresnel splitting for differentiable Monte Carlo.

Kramer Harrison, 2026
"""

from __future__ import annotations

from typing import TYPE_CHECKING

import numpy as np

import optiland.backend as be
from optiland.backend.utils import to_numpy
from optiland.nonsequential.components.base import BaseComponent
from optiland.nonsequential.components.coating_support import (
    reject_polarized_coating,
)
from optiland.nonsequential.materials.nsq_material import medium_stack_id
from optiland.nonsequential.ray_bundle import (
    MEDIUM_STACK_EMPTY,
    MEDIUM_STACK_MAX_DEPTH,
    MediumStackOverflowError,
)
from optiland.nonsequential.rng import EventSlot
from optiland.nonsequential.sampling import resolve_reflect_prob

if TYPE_CHECKING:
    from optiland.coatings import BaseCoating
    from optiland.coordinate_system import CoordinateSystem
    from optiland.nonsequential.bsdf.base import BaseBSDF
    from optiland.nonsequential.components.geometry.base import ComponentGeometry
    from optiland.nonsequential.ir.bsdf_ir import BsdfIR
    from optiland.nonsequential.ir.scene_ir import SamplingPolicy
    from optiland.nonsequential.materials.nsq_material import NSQMaterial
    from optiland.nonsequential.ray_bundle import NSQRayBundle
    from optiland.nonsequential.rng import NSQRng


[docs] class RefractiveComponent(BaseComponent): """Refractive optical element (lens, prism, window). At each interface, Fresnel splitting uses the detached-sample / attached-weight scheme: the branch decision (reflect vs transmit) is drawn from a detached probability, while the throughput weight carries the attached reflectance so gradients flow through material parameters. The two materials name the media on either side of the surface, and the component works out which one a ray is leaving by comparing the ray direction against the surface's geometric normal (``n_geom``, fixed per surface point, pointing from ``material_front`` toward ``material_back``) -- never by comparing refractive index values. Crossing direction therefore does not matter: the same surface refracts correctly for a ray on its way in, for a ghost or retro-reflection coming back through, and for the far side of a closed solid modelled as a single geometry, even when the two adjacent media have nearly identical indices (a cemented doublet, oil immersion). Attributes: cs: Coordinate system. geometry: Surface geometry. material_front: Medium on the front (normal-facing) side. material_back: Medium on the back side. bsdf: Optional BSDF for scatter. None = specular. name: Optional label. """ def __init__( self, cs: CoordinateSystem, geometry: ComponentGeometry, material_front: NSQMaterial, material_back: NSQMaterial, bsdf: BaseBSDF | None = None, name: str = "", scatter_fraction: float = 1.0, coating: BaseCoating | None = None, ) -> None: """Initialize RefractiveComponent. Args: cs: Coordinate system. geometry: Surface geometry. material_front: Front-side medium. By contract (see ``ComponentGeometry.ray_intersect``), this is the medium on the side the geometry's *unflipped* normal points away from -- for the analytic geometries, the local -z side. material_back: Back-side medium -- the side the geometry's unflipped normal points toward (local +z, for the analytic geometries). bsdf: Optional BSDF scatter model. name: Optional label. scatter_fraction: Probability that a hit ray is routed through ``bsdf`` rather than refracted. coating: Optional ``optiland.coatings.BaseCoating`` (e.g. ``SimpleCoating``). When set, its ``.reflectance``/ ``.transmittance`` replace the bare Fresnel R/T so NSQ agrees with the sequential engine's coating model. Must be unpolarized -- a ``BaseCoatingPolarized`` instance raises ``NotImplementedError`` immediately, since NSQ rays carry no polarization state. """ reject_polarized_coating(coating, surface_name=name) self.coating = coating super().__init__( cs, geometry, material_front, material_back, bsdf, name, scatter_fraction=scatter_fraction, )
[docs] def interact( self, rays: NSQRayBundle, t: np.ndarray, normals: np.ndarray, hit_mask: np.ndarray, rng: NSQRng, bsdf_ir: BsdfIR, n_geom: np.ndarray, sampling: SamplingPolicy | None = None, forced_branch: str | None = None, ) -> None: """Apply Fresnel refraction/reflection at hit points (in-place). Uses detached-sample / attached-weight Fresnel: the reflect/transmit branch decision is drawn from a detached probability so stochastic choices do not block gradients; the throughput weight multiplier carries the attached reflectance so ∂flux/∂R is non-zero. When ``self.coating`` is set, its R/T replace the bare Fresnel values (still forced to R=1/T=0 under TIR, where no coating can restore a transmitted wave). Args: rays: Ray bundle updated in-place. t: Hit distances [mm], shape (N,). normals: Surface normals in global frame, shape (N, 3). hit_mask: True for rays hitting this component, shape (N,). rng: Keyed PCG32 RNG (used for detached sampling only). Draws are keyed by this ray's own id and its bounce count as of this interaction, so they are independent of batch_size, compaction, and every other ray in the bundle. bsdf_ir: This surface's lowered BSDF descriptor. Whether the scatter branch below runs at all is decided from ``bsdf_ir.kind != "none"`` (verified by the caller to match ``self.bsdf``), not from ``self.bsdf is not None``. n_geom: Geometric surface normal in global frame, shape (N, 3), fixed per surface point and pointing from ``material_front`` toward ``material_back``. Used, not ``rays.n_current``, to determine which material a ray is entering. sampling: The scene's rare-path sampling policy. Resolves the reflect-branch sampling probability -- see :func:`optiland.nonsequential.sampling.resolve_reflect_prob`. ``None`` defaults to ``reflect_prob="fresnel"`` (today's behaviour). Ignored when ``forced_branch`` is set. forced_branch: ``"reflect"`` or ``"transmit"`` to deterministically force the branch (weight = R or T exactly, no importance division) instead of drawing it stochastically. Used only by the NumPy forward engine's bounded-splitting orchestration to build both children of a split ray. """ # Every RNG draw in this call is keyed to the ray's identity as of # this specific interaction event -- captured before any of the # in-place mutations below (including the bounce increment) change # rays.bounce out from under us. ray_id_key = to_numpy(rays.ray_id) bounce_key = to_numpy(rays.bounce) # Missed rays carry t = inf; zero it for the differentiable position # update so the masked-out be.where branch cannot inject a # 0 * inf = NaN into the backward pass. t = be.where(hit_mask, t, be.zeros_like(t)) # Advance hit rays to intersection point rays.x = be.where(hit_mask, rays.x + t * rays.L, rays.x) rays.y = be.where(hit_mask, rays.y + t * rays.M, rays.y) rays.z = be.where(hit_mask, rays.z + t * rays.N, rays.z) dirs = be.stack([rays.L, rays.M, rays.N], axis=1) wl = rays.wavelength # µm # Determine n1 and n2 for each ray (based on side of the surface) dot = (dirs * normals).sum(axis=1) # signed cos_theta cos_theta_i = be.abs(dot) # Evaluate the front/back indices at each wavelength -- attached # (differentiable). n_front = self.material_front.n(wl) n_back = self.material_back.n(wl) # Extinction coefficients: tracked the same way as n1/n2 below # so rays.k_current always reflects the medium a ray is currently # travelling through, for Beer-Lambert attenuation on its next hop. k_front = self.material_front.k(wl) k_back = self.material_back.k(wl) # n_geom is fixed per surface point and points from material_front # toward material_back (D-1; see ComponentGeometry.ray_intersect), # independent of which side the ray approaches from. This replaces # the old index-proximity heuristic # (`abs(n1 - n2_back) < abs(n1 - n2_front)` against rays.n_current), # which silently mis-resolved whenever the two adjacent media had # similar indices (a cemented doublet, oil immersion) or the ray # took an unexpected path (a ghost re-entering a solid). Comparing # ray direction against n_geom is direction-agnostic *and* # index-value-agnostic: correct for a ray on its way in, a # retro-reflection, or the far side of a closed solid. dot_geom = (dirs * n_geom).sum(axis=1) entering_back = dot_geom > 0.0 n1 = be.where(entering_back, n_front, n_back) n2 = be.where(entering_back, n_back, n_front) k1 = be.where(entering_back, k_front, k_back) k2 = be.where(entering_back, k_back, k_front) # Fresnel reflectance (unpolarized, attached) n_ratio = n1 / (n2 + 1e-30) sin2_t = n_ratio**2 * (1.0 - cos_theta_i**2) tir = sin2_t > 1.0 # Epsilon-clamp the radicand (not 0): sqrt's infinite derivative at 0 # combined with be.where yields a 0 * inf = NaN gradient at the TIR # boundary. Forward value changes by <= 1e-6. cos_theta_t = be.where( tir, be.zeros_like(sin2_t), be.maximum(1.0 - sin2_t, 1e-12) ** 0.5, ) rs = (n1 * cos_theta_i - n2 * cos_theta_t) / ( n1 * cos_theta_i + n2 * cos_theta_t + 1e-30 ) rp = (n2 * cos_theta_i - n1 * cos_theta_t) / ( n2 * cos_theta_i + n1 * cos_theta_t + 1e-30 ) R_fresnel = be.where(tir, be.ones_like(rs), 0.5 * (rs**2 + rp**2)) # A coating overrides the bare Fresnel R/T with its own (possibly # wavelength-independent, possibly lossy: R + T < 1) values -- except # under TIR, where there is no real transmitted wave regardless of # what the coating claims, so reflection stays forced to R=1, T=0. if self.coating is not None: R_used = be.ones_like(R_fresnel) * float(self.coating.reflectance) T_used = be.ones_like(R_fresnel) * float(self.coating.transmittance) else: R_used = R_fresnel T_used = 1.0 - R_fresnel R_used = be.where(tir, be.ones_like(R_used), R_used) T_used = be.where(tir, be.zeros_like(T_used), T_used) # --- Detached-sample / attached-weight --- R_np = to_numpy(R_used).astype(np.float64) if forced_branch is not None: # Bounded-splitting orchestration (PR11, NumPy forward engine # only): the branch is fixed, not drawn, and the weight is the # exact deterministic R or T -- no importance division, since # there is no probability being compensated for. TIR rays are # unaffected: T_used is already forced to 0 there, so a forced # "transmit" branch on a TIR ray correctly carries zero flux # rather than raising or fabricating a wave that cannot exist. do_reflect_np = np.full_like(R_np, forced_branch == "reflect", dtype=bool) do_reflect = be.array(do_reflect_np) weight = be.where(do_reflect, R_used, T_used) else: # Importance-biased branch probability: generalises # the plain-Fresnel estimator (p == R) to any detached # probability p, dividing by p rather than R_det so the # estimator stays unbiased for any p in (0, 1) -- only the # variance changes. reflect_prob="fresnel" reproduces the # original weight formula exactly. r_det = be.array(R_np) p_be = resolve_reflect_prob(sampling, r_det) if sampling else r_det p_np = np.clip(to_numpy(p_be).astype(np.float64), 1e-12, 1.0 - 1e-12) u = rng.uniform(ray_id_key, bounce_key, EventSlot.FRESNEL_BRANCH) do_reflect_np = (u < p_np) | to_numpy(tir).astype(bool) do_reflect = be.array(do_reflect_np) # Throughput weight: forward value is 1.0 in expectation; carries # gradients through R/T. Generalizes the plain-Fresnel # weight_transmit = (1-R)/(1-R_det) to allow T != 1-R (coating # absorption) *and* p != R (importance biasing): the branch is # still a single reflect-vs-transmit draw with P(reflect) = p, # so E[weight] = R on the reflect branch and T on the transmit # branch regardless of p -- exact flux conservation in # expectation, with the shortfall R+T<1 taken up by the # deterministic T weight rather than a separate absorption draw. # For TIR rays weight stays 1.0 (full reflection is deterministic). p_det = be.array(p_np) # detached copy used as denominator weight_reflect = R_used / (p_det + 1e-30) weight_transmit = T_used / (1.0 - p_det + 1e-30) weight = be.where(do_reflect, weight_reflect, weight_transmit) # TIR: weight is exactly 1 weight = be.where(tir, be.ones_like(weight), weight) # Apply weight to flux for hit rays rays.flux = rays.flux * be.where(hit_mask, weight, be.ones_like(weight)) # Compute reflected direction: d - 2*(d.n)*n raw_dot = (dirs * normals).sum(axis=1, keepdims=True) reflected = dirs - 2.0 * raw_dot * normals norms_r = (reflected * reflected).sum(axis=1, keepdims=True) ** 0.5 reflected = reflected / (norms_r + 1e-30) # Compute refracted direction (Snell's law, vector form) n_facing = be.where(raw_dot < 0, normals, -normals) cos_i_pos = be.abs(raw_dot) n_ratio_col = n_ratio[:, None] cos_t_col = cos_theta_t[:, None] refracted = ( n_ratio_col * dirs + (n_ratio_col * cos_i_pos - cos_t_col) * n_facing ) norms_t = (refracted * refracted).sum(axis=1, keepdims=True) ** 0.5 refracted = refracted / (norms_t + 1e-30) # Select direction based on branch decision do_reflect_col = do_reflect[:, None] new_d = be.where(do_reflect_col, reflected, refracted) # Apply only to hit rays hit_col = hit_mask[:, None] rays.L = be.where(hit_col[:, 0], new_d[:, 0], rays.L) rays.M = be.where(hit_col[:, 0], new_d[:, 1], rays.M) rays.N = be.where(hit_col[:, 0], new_d[:, 2], rays.N) # Update n_current/k_current: stays medium 1 on reflect, becomes # medium 2 on refract. rays.n_current = be.where( hit_mask, be.where(do_reflect, n1, n2), rays.n_current ) rays.k_current = be.where( hit_mask, be.where(do_reflect, k1, k2), rays.k_current ) # D1: medium stack push/pop -- a diagnostic cross-check, never fed # back into n1/n2 above. Direction is decided by medium identity, # not entering_back (a Lens's two faces share one +n_geom # convention but opposite interior sides). Reaching ambient (id 0) # always unwinds the whole stack, since abutting media (e.g. a # cemented doublet) push sequentially without true nesting; a pop # at depth 0 is counted as a leak. Reaching a non-ambient medium # that matches one level below the top is a true nesting exit # (pop); anything else pushes. transmit_np = to_numpy(hit_mask).astype(bool) & ~to_numpy(do_reflect).astype( bool ) if transmit_np.any(): entering_back_np = to_numpy(entering_back).astype(bool) front_id = medium_stack_id(self.material_front) back_id = medium_stack_id(self.material_back) mat2_np = np.where(entering_back_np, back_id, front_id) rows = np.where(transmit_np)[0] mat2_rows = mat2_np[rows] ambient_mask = mat2_rows == 0 if ambient_mask.any(): amb_rows = rows[ambient_mask] underflow_mask = rays.medium_depth[amb_rows] == 0 if underflow_mask.any(): rays.medium_stack_underflows[amb_rows[underflow_mask]] += 1 rays.medium_stack[amb_rows, :] = MEDIUM_STACK_EMPTY rays.medium_depth[amb_rows] = 0 non_ambient_mask = ~ambient_mask if non_ambient_mask.any(): na_rows = rows[non_ambient_mask] na_mat2 = mat2_rows[non_ambient_mask] na_depth = rays.medium_depth[na_rows] below = np.zeros_like(na_mat2) # depth 0 or 1 -> "below" is ambient two_or_more = na_depth >= 2 if two_or_more.any(): below[two_or_more] = rays.medium_stack[ na_rows[two_or_more], na_depth[two_or_more] - 2 ] pop_mask = (na_depth >= 1) & (na_mat2 == below) push_mask = ~pop_mask if pop_mask.any(): pr = na_rows[pop_mask] rays.medium_depth[pr] -= 1 rays.medium_stack[pr, rays.medium_depth[pr]] = MEDIUM_STACK_EMPTY if push_mask.any(): pr = na_rows[push_mask] if (rays.medium_depth[pr] >= MEDIUM_STACK_MAX_DEPTH).any(): raise MediumStackOverflowError( f"Medium stack exceeded MEDIUM_STACK_MAX_DEPTH=" f"{MEDIUM_STACK_MAX_DEPTH} at surface " f"{self.name or type(self).__name__!r}. This " "indicates either pathologically deep volume " "nesting or a geometry defect that pushes " "without popping." ) rays.medium_stack[pr, rays.medium_depth[pr]] = na_mat2[push_mask] rays.medium_depth[pr] += 1 # Update bounce count rays.bounce = be.where(hit_mask, rays.bounce + 1, rays.bounce) # Apply BSDF scatter if present (compute for all rays, use where to select) if bsdf_ir.kind != "none": # Compute BSDF for all N rays; where-select only hit rays bsdf_dirs, bsdf_weights, bsdf_transmitted = self.bsdf.sample( rays.num_rays, be.stack([rays.L, rays.M, rays.N], axis=1), normals, rays.wavelength, rng, ray_id_key, bounce_key, ) # Route only a scatter_fraction of the hit rays through the BSDF; # the rest keep the refracted direction computed above. The # branch is drawn from a detached probability, matching the # Fresnel split -- and, like the Fresnel split, carries a # compensating attached weight so d(flux)/d(scatter_fraction) is # correct rather than silently zero. Epsilon-clamped denominator # for the same reason as the Fresnel branch: scatter_fraction=1 # (or 0) exactly would otherwise divide by zero for the ~1e-6 # fraction of draws the clamp itself puts on the "wrong" side. sf_det = float(np.clip(to_numpy(self.scatter_fraction), 1e-6, 1.0 - 1e-6)) u_scatter = rng.uniform(ray_id_key, bounce_key, EventSlot.SCATTER_BRANCH) scatters_np = to_numpy(hit_mask).astype(bool) & (u_scatter < sf_det) scatters = be.array(scatters_np) sf = self.scatter_fraction weight_scatter_branch = sf / sf_det weight_nonscatter_branch = (1.0 - sf) / (1.0 - sf_det) sf_gate = be.where( scatters, weight_scatter_branch, weight_nonscatter_branch ) rays.flux = rays.flux * be.where(hit_mask, sf_gate, be.ones_like(sf_gate)) scatter_col = scatters[:, None] cur_dirs = be.stack([rays.L, rays.M, rays.N], axis=1) new_dirs = be.where(scatter_col, bsdf_dirs, cur_dirs) rays.L = new_dirs[:, 0] rays.M = new_dirs[:, 1] rays.N = new_dirs[:, 2] bsdf_gate = be.where(scatters, bsdf_weights, be.ones_like(bsdf_weights)) rays.flux = rays.flux * bsdf_gate # D-4: a scattered ray's medium is decided by its own lobe's # reflect/transmit side, not by the Fresnel branch draw above -- # that draw only describes what happens to a ray that does NOT # enter the BSDF lobe. Re-resolves n_current/k_current for # exactly the scattered rays. bsdf_in_medium2 = be.array(bsdf_transmitted) rays.n_current = be.where( scatters, be.where(bsdf_in_medium2, n2, n1), rays.n_current ) rays.k_current = be.where( scatters, be.where(bsdf_in_medium2, k2, k1), rays.k_current )
# Note: the medium stack above is not re-synced for scattered # rays -- a transmissive BSDF lobe can cross the boundary # opposite to the deterministic Fresnel branch's push/pop, so # scatter_fraction > 0 on a volume boundary can drift the stack # out of sync with n_current (diagnostic-only; n1/n2 stay # correct either way).