"""Volume -- a closed, outward-oriented solid built from boundary surfaces.
Medium sidedness (which material a ray is entering) is fixed geometrically
per-surface -- see ``RefractiveComponent.interact`` -- and stays the sole
source of truth for n1/n2. ``Volume`` is a separate, independent check: a
compound component's boundary surfaces are supposed to form a genuinely
closed solid, and nothing else checks that. It validates a boundary list at
construction time and raises loudly (``NonWatertightVolumeError``) if the
surfaces do not actually close up or are inconsistently oriented, rather
than letting a silent gap leak flux at trace time.
A ray-level medium stack (``NSQRayBundle.medium_stack``/``medium_depth``,
pushed/popped by ``RefractiveComponent.interact`` on every transmitted ray)
runs alongside this as a runtime cross-check: it does not feed back into
n1/n2 either, but a pop on an empty stack is counted in
``Diagnostics.medium_stack_underflows`` as a likely geometry defect. This
``Volume`` boundary list is not yet wired into that stack via ids (there is
no ``SceneIR``-level ``VolumeIR`` population), so the stack's push/pop
identity currently comes from ``NSQMaterial`` object identity
(``optiland.nonsequential.materials.nsq_material.medium_stack_id``), not
from a volume registry.
Kramer Harrison, 2026
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
import numpy as np
from optiland.nonsequential._utils import as_float
from optiland.nonsequential.components.base import BaseComponent, _get_transform
if TYPE_CHECKING:
from optiland.nonsequential.materials.nsq_material import NSQMaterial
# Default rim-coincidence tolerance [mm]. Proposed in the original spec as a
# starting point; the sag arithmetic in analytic geometries stays well
# within float64 precision at ordinary lens scales, so this is not tuned
# further here.
WATERTIGHT_TOL = 1e-6
_RIM_SAMPLES = 64
_PARITY_DIRECTIONS = 8
_PARITY_SEED = 0
_PARITY_MAX_BOUNCES = 64
_PARITY_EPSILON = 1e-6
[docs]
class NonWatertightVolumeError(Exception):
"""A Volume's boundary surfaces do not form a closed, consistently
outward-oriented solid.
Raised at :class:`Volume` construction, never as a warning: a leak in
the boundary lets rays enter or exit a solid without the medium stack
(or, in this revamp, the per-surface geometric sidedness check)
noticing, which is exactly the class of silent-wrong-answer failure
this validation exists to prevent.
"""
def _rectangle_perimeter(half_width: float, half_height: float, n: int) -> np.ndarray:
"""Sample ``n`` points roughly evenly around a rectangle's perimeter.
Args:
half_width: Half-width along local x [mm].
half_height: Half-height along local y [mm].
n: Number of points to sample.
Returns:
(n, 3) array of local-frame points at z=0.
"""
perim = 4.0 * (half_width + half_height)
if perim <= 0.0:
return np.zeros((n, 3))
s = (np.arange(n) / n) * perim
x = np.zeros(n)
y = np.zeros(n)
# Walk the perimeter starting at (+hw, -hh), going counter-clockwise.
edges = [
(2 * half_width, (1.0, 0.0), (-half_width, -half_height)),
(2 * half_height, (0.0, 1.0), (half_width, -half_height)),
(2 * half_width, (-1.0, 0.0), (half_width, half_height)),
(2 * half_height, (0.0, -1.0), (-half_width, half_height)),
]
remaining = s.copy()
start = 0.0
for length, (dx, dy), (ox, oy) in edges:
on_edge = (remaining >= start) & (remaining < start + length)
local_s = remaining[on_edge] - start
x[on_edge] = ox + dx * local_s
y[on_edge] = oy + dy * local_s
start += length
return np.stack([x, y, np.zeros(n)], axis=1)
def _rim_points(
component: BaseComponent, n_samples: int = _RIM_SAMPLES
) -> np.ndarray | None:
"""Sample points along a component's aperture rim, in global coordinates.
Supports the analytic geometries the compound builders actually use
(conic, finite plane, annulus, frustum). Geometries with no finite open
edge (an infinite plane, a full sphere, a mesh) return ``None`` -- there
is nothing for a neighbouring surface to meet, so watertightness
contributes nothing to check for them.
Args:
component: The boundary surface to sample.
n_samples: Points per rim loop.
Returns:
(n_samples * num_loops, 3) global-frame points, or ``None``.
"""
from optiland.nonsequential.components.geometry.analytic.annulus import ( # noqa: PLC0415
AnnularPlaneGeometry,
)
from optiland.nonsequential.components.geometry.analytic.conic import ( # noqa: PLC0415
ConicGeometry,
)
from optiland.nonsequential.components.geometry.analytic.frustum import ( # noqa: PLC0415
CylindricalFrustumGeometry,
)
from optiland.nonsequential.components.geometry.analytic.plane import ( # noqa: PLC0415
FinitePlaneGeometry,
)
from optiland.nonsequential.components.lens import _sag_at_rim # noqa: PLC0415
geom = component.geometry
theta = np.linspace(0.0, 2.0 * np.pi, n_samples, endpoint=False)
loops: list[np.ndarray] = []
if isinstance(geom, ConicGeometry):
r = as_float(geom.aperture_radius)
z = _sag_at_rim(as_float(geom.radius), as_float(geom.conic), r)
loops.append(
np.stack(
[r * np.cos(theta), r * np.sin(theta), np.full(n_samples, z)], axis=1
)
)
elif isinstance(geom, FinitePlaneGeometry):
if geom.aperture_radius is not None:
r = as_float(geom.aperture_radius)
loops.append(
np.stack(
[r * np.cos(theta), r * np.sin(theta), np.zeros(n_samples)], axis=1
)
)
else:
hw = as_float(geom.width) / 2.0
hh = as_float(geom.height) / 2.0
loops.append(_rectangle_perimeter(hw, hh, n_samples))
elif isinstance(geom, AnnularPlaneGeometry):
ri = as_float(geom.inner_radius)
ro = as_float(geom.outer_radius)
z = as_float(geom.z_offset)
loops.append(
np.stack(
[ri * np.cos(theta), ri * np.sin(theta), np.full(n_samples, z)], axis=1
)
)
loops.append(
np.stack(
[ro * np.cos(theta), ro * np.sin(theta), np.full(n_samples, z)], axis=1
)
)
elif isinstance(geom, CylindricalFrustumGeometry):
rf, zf = as_float(geom.r_front), as_float(geom.z_front)
rb, zb = as_float(geom.r_back), as_float(geom.z_back)
loops.append(
np.stack(
[rf * np.cos(theta), rf * np.sin(theta), np.full(n_samples, zf)], axis=1
)
)
loops.append(
np.stack(
[rb * np.cos(theta), rb * np.sin(theta), np.full(n_samples, zb)], axis=1
)
)
else:
# Infinite plane, sphere, mesh: no finite rim supported yet.
return None
local_pts = np.concatenate(loops, axis=0)
translation, rotation = _get_transform(component.cs)
return local_pts @ rotation.T + translation
def _check_watertight(
boundary: list[BaseComponent], tol: float = WATERTIGHT_TOL
) -> np.ndarray | None:
"""Verify every boundary surface's rim is met by a neighbour's rim.
Args:
boundary: The volume's boundary surfaces.
tol: Maximum allowed gap [mm].
Returns:
All sampled rim points (for reuse as a centroid estimate), or
``None`` if no surface in ``boundary`` has a finite rim.
Raises:
NonWatertightVolumeError: If any rim point is farther than ``tol``
from every other surface's rim.
"""
rims = [(comp, pts) for comp in boundary if (pts := _rim_points(comp)) is not None]
if not rims:
return None
if len(rims) == 1:
return rims[0][1]
for i, (comp_i, pts_i) in enumerate(rims):
other_pts = np.concatenate(
[p for j, (_, p) in enumerate(rims) if j != i], axis=0
)
# (n_i, n_other) pairwise distances -- rim samples are small (a few
# hundred points across a handful of surfaces), so this is cheap.
diff = pts_i[:, None, :] - other_pts[None, :, :]
dists = np.sqrt((diff**2).sum(axis=2)).min(axis=1)
worst = float(dists.max())
if worst > tol:
raise NonWatertightVolumeError(
f"Volume boundary is not watertight: surface "
f"'{comp_i.name or type(comp_i).__name__}' has a rim point "
f"{worst:.3g} mm from the nearest point on any other boundary "
f"surface (tolerance {tol:.1e} mm). Check that neighbouring "
f"surfaces' aperture radii and rim geometry agree."
)
return np.concatenate([p for _, p in rims], axis=0)
class _DetachedProxy:
"""A minimal (cs, geometry) pair usable with ``BaseComponent.intersect``.
Not a real component -- just enough duck-typed surface for
``intersect()`` (which only reads ``self.cs``/``self.geometry``) to
work against a fully detached, plain-float geometry clone.
"""
def __init__(self, cs: object, geometry: object) -> None:
self.cs = cs
self.geometry = geometry
intersect = BaseComponent.intersect
def _detached_cs(cs: object) -> object:
"""Return a plain-float clone of a ``CoordinateSystem``.
A differentiable scene may give x/y/z/rx/ry/rz live ``torch.Tensor``
values (position/tilt are not currently differentiable NSQ parameters,
but the ``CoordinateSystem`` type itself does not forbid it). Even a
numpy-backend computation cannot touch a tensor that requires grad
without detaching it first -- ``be.cos(rx)`` fails exactly like any
other numpy ufunc would. Recurses through ``reference_cs`` chains, the
same nesting :mod:`optiland.nonsequential.serialization` already
detaches for JSON export.
Args:
cs: A live ``CoordinateSystem``.
Returns:
A new ``CoordinateSystem`` with every field a plain float.
"""
from optiland.coordinate_system import CoordinateSystem # noqa: PLC0415
return CoordinateSystem(
x=as_float(cs.x),
y=as_float(cs.y),
z=as_float(cs.z),
rx=as_float(cs.rx),
ry=as_float(cs.ry),
rz=as_float(cs.rz),
reference_cs=_detached_cs(cs.reference_cs) if cs.reference_cs else None,
)
def _detached_geometry(geometry: object) -> object:
"""Return a plain-float clone of ``geometry`` for construction-time checks.
The watertightness/ray-parity checks never need gradients (they run
once, at construction, on discrete pass/fail geometry) but a
differentiable scene may attach a ``torch.Tensor`` radius/conic/etc to
the live geometry. Cloning with :func:`as_float` keeps that live
component's tensor untouched while giving this check plain numpy
arithmetic to work with, regardless of which backend is currently
active.
Args:
geometry: A live ``ComponentGeometry`` instance.
Returns:
A new instance of the same class with every numeric parameter
detached to a plain float, or ``geometry`` itself if its class is
not one of the parametrized analytic geometries (nothing to
detach).
"""
from optiland.nonsequential.components.geometry.analytic.annulus import ( # noqa: PLC0415
AnnularPlaneGeometry,
)
from optiland.nonsequential.components.geometry.analytic.conic import ( # noqa: PLC0415
ConicGeometry,
)
from optiland.nonsequential.components.geometry.analytic.frustum import ( # noqa: PLC0415
CylindricalFrustumGeometry,
)
from optiland.nonsequential.components.geometry.analytic.plane import ( # noqa: PLC0415
FinitePlaneGeometry,
)
from optiland.nonsequential.components.geometry.analytic.sphere import ( # noqa: PLC0415
SphereGeometry,
)
if isinstance(geometry, ConicGeometry):
return ConicGeometry(
as_float(geometry.radius),
as_float(geometry.conic),
as_float(geometry.aperture_radius),
)
if isinstance(geometry, FinitePlaneGeometry):
ap = geometry.aperture_radius
return FinitePlaneGeometry(
as_float(geometry.width),
as_float(geometry.height),
as_float(ap) if ap is not None else None,
)
if isinstance(geometry, AnnularPlaneGeometry):
return AnnularPlaneGeometry(
as_float(geometry.inner_radius),
as_float(geometry.outer_radius),
as_float(geometry.z_offset),
)
if isinstance(geometry, CylindricalFrustumGeometry):
return CylindricalFrustumGeometry(
as_float(geometry.r_front),
as_float(geometry.r_back),
as_float(geometry.z_front),
as_float(geometry.z_back),
)
if isinstance(geometry, SphereGeometry):
ap = geometry.aperture_radius
return SphereGeometry(
as_float(geometry.radius), as_float(ap) if ap is not None else None
)
return geometry
def _count_crossings(
boundary: list[BaseComponent],
origin: np.ndarray,
direction: np.ndarray,
max_bounces: int = _PARITY_MAX_BOUNCES,
) -> int:
"""Count how many times a ray crosses the boundary before escaping.
Reuses each component's own ``intersect()`` -- no separate "all hits
along a ray" geometry API is needed: the ray is walked hit-by-hit,
nudged past each crossing by a small epsilon, and re-intersected against
every boundary surface, up to ``max_bounces`` (a bound, not an
unbounded loop).
Args:
boundary: The volume's boundary surfaces.
origin: Ray start point, shape (3,).
direction: Unit ray direction, shape (3,).
max_bounces: Maximum crossings to count before giving up.
Returns:
Number of boundary crossings before the ray escapes to infinity.
"""
from optiland.nonsequential.ray_bundle import NSQRayBundle # noqa: PLC0415
proxies = [
_DetachedProxy(_detached_cs(comp.cs), _detached_geometry(comp.geometry))
for comp in boundary
]
o = origin.astype(np.float64).copy()
d = direction.astype(np.float64).copy()
count = 0
for _ in range(max_bounces):
t_min = np.inf
for comp in proxies:
rays = NSQRayBundle(
x=np.array([o[0]]),
y=np.array([o[1]]),
z=np.array([o[2]]),
L=np.array([d[0]]),
M=np.array([d[1]]),
N=np.array([d[2]]),
flux=np.array([1.0]),
wavelength=np.array([0.55]),
n_current=np.array([1.0]),
bounce=np.array([0], dtype=np.int32),
alive=np.array([True]),
ray_id=np.array([0], dtype=np.int64),
)
t, _normals, hit, _n_geom = comp.intersect(rays)
t0 = float(t[0])
if bool(hit[0]) and t0 < t_min:
t_min = t0
if not np.isfinite(t_min):
break
o = o + (t_min + _PARITY_EPSILON) * d
count += 1
return count
def _check_normals_outward(
boundary: list[BaseComponent],
centroid: np.ndarray,
num_directions: int = _PARITY_DIRECTIONS,
seed: int = _PARITY_SEED,
) -> None:
"""Ray-parity check: every direction from ``centroid`` must exit an odd
number of times.
A point inside a closed, consistently outward-oriented solid crosses
its boundary an odd number of times along any ray to infinity; an even
count means either a gap (the ray slipped through unnoticed) or a
surface with an inconsistent orientation.
Args:
boundary: The volume's boundary surfaces.
centroid: An interior point estimate, shape (3,).
num_directions: Number of random directions to test.
seed: RNG seed, for a deterministic (reproducible) check.
Raises:
NonWatertightVolumeError: If any tested direction crosses an even
number of times.
"""
rng = np.random.default_rng(seed)
dirs = rng.normal(size=(num_directions, 3))
dirs /= np.linalg.norm(dirs, axis=1, keepdims=True)
for d in dirs:
count = _count_crossings(boundary, centroid, d)
if count == 0 or count % 2 == 0:
raise NonWatertightVolumeError(
f"Volume boundary failed the inside/outside ray-parity check: "
f"a ray cast from the estimated interior point "
f"{centroid.tolist()} in direction {d.tolist()} crossed the "
f"boundary {count} times (expected an odd, nonzero count for "
f"a point inside a closed, consistently outward-oriented "
f"solid). This usually means a gap in the boundary, a "
f"surface with an unexpectedly flipped normal, or that the "
f"estimated interior point is not actually inside the solid."
)
[docs]
@dataclass
class Volume:
"""A closed, outward-oriented solid built from boundary surfaces.
Validated at construction: every boundary surface's rim must be met by
a neighbour's rim (watertightness), and the boundary must enclose its
own estimated interior point consistently from every direction
(orientation). Both checks raise :class:`NonWatertightVolumeError`
rather than warning -- a leaky or misoriented boundary produces
silently wrong flux accounting, exactly the failure class this
validation exists to catch at construction time instead of at trace
time.
Attributes:
name: Human-readable label.
boundary: Closed, outward-oriented list of boundary surfaces.
interior: The medium inside this volume.
"""
name: str
boundary: list[BaseComponent]
interior: NSQMaterial
_skip_validation: bool = field(default=False, repr=False, compare=False)
def __post_init__(self) -> None:
if self._skip_validation:
return
if not self.boundary:
raise NonWatertightVolumeError(
f"Volume '{self.name}' has no boundary surfaces."
)
rim_points = _check_watertight(self.boundary)
if rim_points is not None and len(rim_points) > 0:
centroid = rim_points.mean(axis=0)
else:
# No surface exposed a finite rim (e.g. a single closed sphere):
# fall back to the mean of each surface's own coordinate origin.
centroid = np.mean([_get_transform(c.cs)[0] for c in self.boundary], axis=0)
# This check calls component.intersect(), which dispatches through
# optiland.backend (be.*). The check is purely discrete geometry --
# never differentiated -- so it always runs on the numpy backend,
# regardless of which backend is active for the surrounding scene
# (e.g. a Lens built while be.set_backend("torch") is active for a
# gradient trace).
import optiland.backend as be # noqa: PLC0415
previous_backend = be.get_backend()
try:
be.set_backend("numpy")
_check_normals_outward(self.boundary, centroid)
finally:
be.set_backend(previous_backend)
[docs]
@staticmethod
def union(
*parts: Volume | list[BaseComponent] | BaseComponent,
) -> list[BaseComponent]:
"""Concatenate already-disjoint boundary surfaces into one list.
This is the CSG operation this revamp implements: gluing separately
constructed, non-overlapping boundary pieces together (the stated
use cases -- a lens with a flat, a light pipe with a chamfer) are
boundary concatenation, not boolean surface evaluation. The result
is not itself validated; pass it to :class:`Volume` to check it.
Args:
*parts: Any mix of ``Volume`` instances, lists of components, or
single components.
Returns:
The concatenated boundary list, in argument order.
"""
boundary: list[BaseComponent] = []
for part in parts:
if isinstance(part, Volume):
boundary.extend(part.boundary)
elif isinstance(part, list):
boundary.extend(part)
else:
boundary.append(part)
return boundary
[docs]
@staticmethod
def intersection(*parts: object) -> None:
"""Not implemented: true CSG intersection needs a boolean surface
evaluator.
Raises:
NotImplementedError: Always. Construct the intersected geometry
directly with analytic primitives, or use :meth:`union` for
concatenating already-disjoint boundary surfaces.
"""
raise NotImplementedError(
"Volume.intersection() requires a true boolean surface evaluator, "
"which this revamp does not implement (D16, measured and specced "
"only). Use Volume.union() for concatenating already-disjoint "
"boundary surfaces, or construct the intersected geometry "
"directly with analytic primitives."
)
[docs]
@staticmethod
def difference(*parts: object) -> None:
"""Not implemented: true CSG difference needs a boolean surface
evaluator.
Raises:
NotImplementedError: Always. See :meth:`intersection`.
"""
raise NotImplementedError(
"Volume.difference() requires a true boolean surface evaluator, "
"which this revamp does not implement (D16, measured and specced "
"only). Use Volume.union() for concatenating already-disjoint "
"boundary surfaces, or construct the differenced geometry "
"directly with analytic primitives."
)