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

"""Sphere geometry for Non-Sequential Raytracing.

Full sphere or spherical cap. Operations in LOCAL coordinates.

Kramer Harrison, 2026
"""

from __future__ import annotations

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


[docs] class SphereGeometry(AnalyticGeometry): """Full sphere centred at the local origin. The sphere equation: x^2 + y^2 + z^2 = radius^2. Attributes: radius: Sphere radius [mm]. aperture_radius: Optional aperture limit [mm]. Only the part of the sphere within this transverse radius is considered. """ def __init__(self, radius: float, aperture_radius: float | None = None) -> None: """Initialize SphereGeometry. Args: radius: Sphere radius [mm]. aperture_radius: Optional transverse aperture limit [mm]. Points outside this radius are not considered valid hits. """ self.radius = as_param(radius) self.aperture_radius = ( as_param(aperture_radius) if aperture_radius is not None else None )
[docs] def ray_intersect( self, origins: np.ndarray, directions: np.ndarray ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: """Intersect rays with the sphere. Uses the analytic quadratic solution. Returns the nearest positive hit. Args: origins: Ray origins in local frame, shape (N, 3) [mm]. directions: Ray directions in local frame, shape (N, 3), unit. Returns: (t, normals, hit_mask, n_geom). n_geom points *inward*, toward the sphere centre -- the ``material_back`` side by contract (see :meth:`ComponentGeometry.ray_intersect`): a standalone sphere used as a refractive surface (e.g. a ball lens) should be built with the exterior medium as ``material_front`` and the interior as ``material_back``, matching the convention every compound builder (``Lens``, ``Doublet``) already uses. """ ox, oy, oz = origins[:, 0], origins[:, 1], origins[:, 2] dx, dy, dz = directions[:, 0], directions[:, 1], directions[:, 2] # Quadratic: |o + t*d|^2 = R^2 b = 2.0 * (ox * dx + oy * dy + oz * dz) c = ox**2 + oy**2 + oz**2 - self.radius**2 discriminant = b**2 - 4.0 * c disc_ok = discriminant >= 0.0 # Clamp the radicand to a small positive epsilon (not 0). sqrt has an # infinite derivative at 0; combined with be.where this produces a # 0 * inf = NaN in the backward pass even though the forward is masked. sqrt_disc = be.where( disc_ok, be.maximum(discriminant, 1e-12) ** 0.5, be.zeros_like(discriminant) ) inf_arr = be.ones_like(discriminant) * be.inf t1 = be.where(disc_ok, (-b - sqrt_disc) / 2.0, inf_arr) t2 = be.where(disc_ok, (-b + sqrt_disc) / 2.0, inf_arr) # Choose nearest positive t eps = 1e-9 use_t1 = disc_ok & (t1 > eps) use_t2 = disc_ok & (~use_t1) & (t2 > eps) t = be.where(use_t1, t1, be.where(use_t2, t2, inf_arr)) # Compute hit position and normal hx = ox + t * dx hy = oy + t * dy hz = oz + t * dz # n_geom: inward, toward the sphere centre, fixed regardless of ray # direction (see the material_back contract in the docstring above). nx = be.where(t < be.inf, hx / self.radius, be.zeros_like(hx)) ny = be.where(t < be.inf, hy / self.radius, be.zeros_like(hy)) nz = be.where(t < be.inf, hz / self.radius, be.zeros_like(hz)) n_geom = be.stack([-nx, -ny, -nz], axis=1) # Flip to face incoming ray dot = dx * nx + dy * ny + dz * nz flip = be.where(dot > 0, -1.0, 1.0) normals = be.stack([nx * flip, ny * flip, nz * flip], axis=1) hit_mask = t < be.inf # Aperture check if self.aperture_radius is not None: r_transverse = (hx**2 + hy**2) ** 0.5 in_aperture = r_transverse <= self.aperture_radius hit_mask = hit_mask & in_aperture t = be.where(hit_mask, t, inf_arr) return t, normals, hit_mask, n_geom
[docs] def bounding_box(self, transform: tuple[np.ndarray, np.ndarray]) -> AABB: """Return AABB for the sphere in global coordinates. Args: transform: (translation, rotation_matrix). Returns: AABB in global frame. """ t = np.array(transform[0], dtype=float) r = as_float(self.radius) return AABB(t - r, t + r)