Source code for optiland.nonsequential.components.geometry.analytic.conic

"""Conic geometry for Non-Sequential Raytracing.

ConicGeometry (paraboloid, ellipsoid, hyperboloid) and the ParaboloidGeometry
convenience subclass. All operations in LOCAL coordinates.

The conic surface is defined as:
    z = r^2 / (R * (1 + sqrt(1 - (1+K) * r^2 / R^2)))
where r^2 = x^2 + y^2, R is the radius of curvature, and K is the conic constant.

Special cases:
    K = 0  -> sphere
    K = -1 -> paraboloid
    K < -1 -> hyperboloid
    -1 < K < 0 -> prolate ellipsoid
    K > 0  -> oblate ellipsoid

Kramer Harrison, 2026
"""

from __future__ import annotations

import math

import numpy as np

import optiland.backend as be
from optiland.nonsequential._utils import as_float, as_param
from optiland.nonsequential.components.geometry.base import AABB, AnalyticGeometry

# Denominators below this magnitude are treated as degenerate rather than
# divided by, and the affected root is discarded.
_TINY = 1e-30


[docs] class ConicGeometry(AnalyticGeometry): """Conic section surface (z = f(r)) centred on the local z-axis. The surface vertex is at the local origin. The aperture is circular with radius aperture_radius. Attributes: radius: Radius of curvature at the vertex [mm]. Positive = centre of curvature on +z side. conic: Conic constant K. aperture_radius: Semi-aperture radius [mm]. """ def __init__(self, radius: float, conic: float, aperture_radius: float) -> None: """Initialize ConicGeometry. Args: radius: Vertex radius of curvature [mm]. conic: Conic constant K. aperture_radius: Aperture semi-diameter [mm]. """ self.radius = as_param(radius) self.conic = as_param(conic) self.aperture_radius = as_param(aperture_radius) def _curvature(self): """Return the vertex curvature c = 1 / radius. A radius of 0 or infinity both denote a flat surface (``r2=0.0`` is the plano convention used by :class:`LensConfig`), and both map to c = 0. A degenerate radius is returned as a detached float: there is no meaningful derivative at the flat limit, and evaluating ``1 / R`` there would emit an inf that backpropagates as NaN. Returns: Curvature [1/mm], as a tensor when ``radius`` is a tensor. """ r_val = as_float(self.radius) if r_val == 0.0 or not math.isfinite(r_val): return 0.0 return 1.0 / self.radius def _sag(self, x: np.ndarray, y: np.ndarray) -> np.ndarray: """Compute conic surface sag z(x, y). Args: x: x-coordinates, shape (N,). y: y-coordinates, shape (N,). Returns: Sag values z, shape (N,). """ r2 = x**2 + y**2 c = self._curvature() K = self.conic # Curvature form. The radius form (dividing by R * R) evaluates to # inf * 0 for a flat surface (radius = inf); the forward value is # right but the backward pass returns NaN for every scene parameter. # With c = 1/R a flat surface is simply c = 0. under_root = 1.0 - (1.0 + K) * c**2 * r2 # Clamp to a small positive epsilon (not 0): sqrt has an infinite # derivative at 0, which would poison gradients for rays at the conic # edge. The forward value changes by <= 1e-6 (sqrt(1e-12)). safe_root = be.maximum(under_root, 1e-12) return c * r2 / (1.0 + safe_root**0.5) def _normal_local(self, x: np.ndarray, y: np.ndarray) -> np.ndarray: """Compute outward surface normals in local frame at (x, y, z(x,y)). The gradient of f(x,y,z) = z - sag(x,y) is (df/dx, df/dy, 1). Args: x: Hit x-coordinates, shape (N,). y: Hit y-coordinates, shape (N,). Returns: Outward normals (not yet normalized), shape (N, 3). """ r2 = x**2 + y**2 c = self._curvature() K = self.conic # Epsilon-clamped (not 0) so the sqrt derivative stays finite at the edge. under_root = be.maximum(1.0 - (1.0 + K) * c**2 * r2, 1e-12) sqrt_term = under_root**0.5 # For a conic, dz/dr = c * r / sqrt(1 - (1+K) c^2 r^2), so # dz/dx = c * x / S and dz/dy = c * y / S. Written in curvature form # this stays finite for a flat surface (c = 0), where the radius form # produces an inf/inf that returns NaN gradients. gx = -c * x / sqrt_term gy = -c * y / sqrt_term gz = be.ones_like(x) norms = be.stack([gx, gy, gz], axis=1) return norms def _root_valid( self, t: np.ndarray, solvable: np.ndarray, origins: np.ndarray, directions: np.ndarray, eps: float, ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """Test one quadratic root for a physical hit on the sag sheet. Args: t: Candidate distances along the ray, shape (N,). solvable: Lanes where this root came from a well-posed division. origins: Ray origins in local frame, shape (N, 3) [mm]. directions: Ray directions in local frame, shape (N, 3). eps: Minimum accepted distance [mm]. Returns: (valid, px, py): hit flag and the transverse hit coordinates. """ px = origins[:, 0] + t * directions[:, 0] py = origins[:, 1] + t * directions[:, 1] pz = origins[:, 2] + t * directions[:, 2] in_aperture = (px**2 + py**2) <= self.aperture_radius**2 # On the surface, sqrt(1 - (1+K) c^2 r^2) = 1 - (1+K) c z, so the sheet # the sag function describes (positive root) is 1 - (1+K) c z >= 0. on_sag_sheet = (1.0 - (1.0 + self.conic) * self._curvature() * pz) >= 0.0 valid = solvable & be.isfinite(t) & (t > eps) & in_aperture & on_sag_sheet return valid, px, py
[docs] def ray_intersect( self, origins: np.ndarray, directions: np.ndarray ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: """Intersect rays with the conic surface. Solved in closed form: the sag form in the module docstring is algebraically the quadric c * (x^2 + y^2) + (1 + K) * c * z^2 - 2 * z = 0, so substituting p(t) = o + t*d gives a quadratic in t. The same equation covers the flat limit (c = 0), where it becomes linear. The quadric is the whole conic, including points the sag function does not describe (the far side of an ellipsoid, the second branch of a hyperboloid), which :meth:`_root_valid` rejects. Args: origins: Ray origins in local frame, shape (N, 3) [mm]. directions: Ray directions in local frame, shape (N, 3). Returns: (t, normals, hit_mask, n_geom). n_geom points toward local +z (the ``material_back`` side by contract; see :meth:`ComponentGeometry.ray_intersect`). """ ox, oy, oz = origins[:, 0], origins[:, 1], origins[:, 2] dx, dy, dz = directions[:, 0], directions[:, 1], directions[:, 2] c = self._curvature() kp = 1.0 + self.conic # Coefficients of a*t^2 + b*t + c_0 = 0. a = c * (dx**2 + dy**2 + kp * dz**2) b = 2.0 * (c * (ox * dx + oy * dy + kp * oz * dz) - dz) c_0 = c * (ox**2 + oy**2 + kp * oz**2) - 2.0 * oz disc = b**2 - 4.0 * a * c_0 disc_ok = disc >= 0.0 # Clamp the radicand to a small positive epsilon (not 0): sqrt has an # infinite derivative at 0, which combined with be.where yields a # 0 * inf = NaN in the backward pass even though the forward is masked. sqrt_disc = be.where( disc_ok, be.maximum(disc, 1e-12) ** 0.5, be.zeros_like(disc) ) # Numerically stable roots (Numerical Recipes' "citardauque" form): the # textbook (-b +/- sqrt(disc)) / (2a) cancels catastrophically near the # paraboloid limit and for near-axial rays, where "a" is tiny. This form # also reduces continuously to the linear solution as a -> 0. sign_b = be.where(b >= 0.0, 1.0, -1.0) q = -0.5 * (b + sign_b * sqrt_disc) # Guard both denominators in place: masking a division by ~0 after the # fact still leaves an inf in the graph, which backpropagates as NaN. a_ok = be.abs(a) > _TINY q_ok = be.abs(q) > _TINY t1 = q / be.where(a_ok, a, be.ones_like(a)) t2 = c_0 / be.where(q_ok, q, be.ones_like(q)) eps = 1e-9 args = (origins, directions, eps) valid1, px1, py1 = self._root_valid(t1, disc_ok & a_ok, *args) valid2, px2, py2 = self._root_valid(t2, disc_ok & q_ok, *args) # Nearest valid root along the ray. pick1 = valid1 & (~valid2 | (t1 <= t2)) pick2 = valid2 & ~pick1 hit_mask = pick1 | pick2 inf_arr = be.ones_like(t1) * be.inf t = be.where(pick1, t1, be.where(pick2, t2, be.zeros_like(t1))) t_out = be.where(hit_mask, t, inf_arr) # Normals from the finite hit points, not t_out: its inf lanes would # poison every lane's gradient through be.where. px = be.where(pick1, px1, be.where(pick2, px2, be.zeros_like(px1))) py = be.where(pick1, py1, be.where(pick2, py2, be.zeros_like(py1))) n_raw = self._normal_local(px, py) n_len = (n_raw * n_raw).sum(axis=1, keepdims=True) ** 0.5 n_geom = n_raw / (n_len + 1e-30) # Flip to face incoming ray dot = (directions * n_geom).sum(axis=1, keepdims=True) normals = be.where(dot > 0, -n_geom, n_geom) return t_out, normals, hit_mask, n_geom
[docs] def bounding_box(self, transform: tuple[np.ndarray, np.ndarray]) -> AABB: """Return AABB in global coordinates. Args: transform: (translation, rotation_matrix). Returns: AABB in global frame. """ t_vec = np.array(transform[0], dtype=float) R = np.array(transform[1], dtype=float) r = as_float(self.aperture_radius) # Max sag (at edge of aperture). Computed from detached floats: the # AABB is NumPy-only bookkeeping and never carries gradients, and # mixing a torch parameter with NumPy arrays is disallowed. c = as_float(self._curvature()) K = as_float(self.conic) r2 = r * r under_root = max(1.0 - (1.0 + K) * c * c * r2, 1e-12) sag_edge = c * r2 / (1.0 + np.sqrt(under_root)) z_max = max(0.0, sag_edge) z_min = min(0.0, sag_edge) corners_local = np.array( [ [-r, -r, z_min], [-r, r, z_min], [r, -r, z_min], [r, r, z_min], [-r, -r, z_max], [-r, r, z_max], [r, -r, z_max], [r, r, z_max], ], dtype=float, ) corners_global = corners_local @ R.T + t_vec return AABB(corners_global.min(axis=0), corners_global.max(axis=0))
[docs] class ParaboloidGeometry(ConicGeometry): """Convenience subclass for paraboloid (conic constant K = -1). Attributes: radius: Radius of curvature [mm]. aperture_radius: Semi-aperture [mm]. """ def __init__(self, radius: float, aperture_radius: float) -> None: """Initialize ParaboloidGeometry. Args: radius: Vertex radius of curvature [mm]. aperture_radius: Aperture semi-diameter [mm]. """ super().__init__(radius=radius, conic=-1.0, aperture_radius=aperture_radius)