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

"""Annular plane geometry for Non-Sequential Raytracing.

AnnularPlaneGeometry -- a flat ring (annulus) at a fixed axial offset, with
inner and outer radii.  Used as the rim surface of a lens whose front and
back apertures differ.

All 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 AnnularPlaneGeometry(AnalyticGeometry): """Flat annular ring at ``z = z_offset`` in the local frame. The annulus extends from ``inner_radius`` to ``outer_radius`` in the radial direction. Rays that hit the z = z_offset plane inside the annular band register a hit; rays outside or on the inner hole do not. Attributes: inner_radius: Inner radius of the annulus [mm]. outer_radius: Outer radius of the annulus [mm]. z_offset: Axial position of the plane in local frame [mm]. """ def __init__( self, inner_radius: float, outer_radius: float, z_offset: float = 0.0, ) -> None: """Initialize AnnularPlaneGeometry. Args: inner_radius: Inner (hole) radius [mm]. outer_radius: Outer (rim) radius [mm]. z_offset: Axial z-position of the plane [mm]. """ self.inner_radius = as_param(inner_radius) self.outer_radius = as_param(outer_radius) self.z_offset = as_param(z_offset)
[docs] def ray_intersect( self, origins: np.ndarray, directions: np.ndarray ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: """Intersect rays with the annular plane. Uses the standard plane intersection: t = (z_offset - oz) / dz, then checks that the hit radius is within [inner_radius, outer_radius]. 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) all in local frame. n_geom is the fixed local +z (the ``material_back`` side by contract; see :meth:`ComponentGeometry.ray_intersect`). """ N = origins.shape[0] oz = origins[:, 2] dz = directions[:, 2] eps = 1e-9 inf_arr = be.ones(N) * be.inf # Avoid division by zero for rays parallel to the plane t = be.where( be.abs(dz) > eps, (self.z_offset - oz) / (dz + 1e-30), inf_arr, ) # Compute hit position; inf t values naturally fail the r^2 check hx = origins[:, 0] + t * directions[:, 0] hy = origins[:, 1] + t * directions[:, 1] r2 = hx * hx + hy * hy hit_mask = ( (t > eps) & (r2 >= self.inner_radius**2) & (r2 <= self.outer_radius**2) ) t_out = be.where(hit_mask, t, inf_arr) n_geom = be.stack([be.zeros(N), be.zeros(N), be.ones(N)], axis=1) # Normal is (0, 0, +/-1) -- flip to face incoming ray nz_sign = be.where(dz > 0, -1.0, 1.0) normals = be.stack([be.zeros(N), be.zeros(N), nz_sign * be.ones(N)], axis=1) return t_out, normals, hit_mask, n_geom
[docs] def bounding_box(self, transform: tuple[np.ndarray, np.ndarray]) -> AABB: """Return AABB of the annulus 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.outer_radius) z = as_float(self.z_offset) corners_local = np.array( [ [-r, -r, z], [-r, r, z], [r, -r, z], [r, r, z], ], dtype=float, ) corners_global = corners_local @ R.T + t_vec return AABB(corners_global.min(axis=0), corners_global.max(axis=0))