Source code for optiland.nonsequential.components.geometry.mesh.mesh_geometry

"""Mesh geometry for Non-Sequential Raytracing.

Triangulated surface backed by trimesh. Uses trimesh's built-in BVH for
CPU-side ray intersection.

Kramer Harrison, 2026
"""

from __future__ import annotations

import numpy as np

from optiland.nonsequential.components.geometry.base import AABB, AnalyticGeometry


[docs] class MeshGeometry(AnalyticGeometry): """Triangulated surface backed by a trimesh.Trimesh object. Intersection uses trimesh's ray caster (pyembree if installed, else the pure-Python fallback). GPU transfer is required on each intersection step for CuPy arrays; analytic geometry is preferred for GPU performance. Attributes: mesh: The underlying trimesh.Trimesh object. """ def __init__(self, mesh: object) -> None: # trimesh.Trimesh """Initialize MeshGeometry. Args: mesh: A trimesh.Trimesh instance defining the surface. Raises: ImportError: If trimesh is not installed. """ try: import trimesh # noqa: F401 # type: ignore[import] except ImportError as e: raise ImportError( "trimesh is required for MeshGeometry. " "Install with: pip install trimesh" ) from e self.mesh = mesh
[docs] def ray_intersect( self, origins: np.ndarray, directions: np.ndarray ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: """Intersect rays with the mesh using trimesh BVH. Arrays are converted to NumPy for trimesh, then results are converted back to the original array type (CuPy if needed). 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 is trimesh's raw ``face_normals`` value, i.e. the ``material_back`` side by contract (see :meth:`ComponentGeometry.ray_intersect`) is whichever side the mesh's face winding points away from -- consistently outward for a properly wound (CCW, right-hand rule) closed mesh. """ xp = _get_xp(origins) # Bring to CPU for trimesh o_np = _to_numpy(origins) d_np = _to_numpy(directions) N = o_np.shape[0] # trimesh ray casting locations, ray_indices, triangle_indices = self.mesh.ray.intersects_location( ray_origins=o_np, ray_directions=d_np, multiple_hits=False, ) t_out = np.full(N, np.inf, dtype=np.float64) normals_out = np.zeros((N, 3), dtype=np.float64) n_geom_out = np.zeros((N, 3), dtype=np.float64) if len(ray_indices) > 0: # Compute t for each hit hit_vecs = locations - o_np[ray_indices] # t = dot(hit_vec, direction) / |direction|^2 ~ dot for unit vectors t_vals = (hit_vecs * d_np[ray_indices]).sum(axis=1) # Keep nearest positive hit per ray order = np.argsort(ray_indices) for idx, ri in enumerate(ray_indices[order]): tv = t_vals[order[idx]] if tv > 1e-9 and tv < t_out[ri]: t_out[ri] = tv tri_idx = triangle_indices[order[idx]] face_normal = self.mesh.face_normals[tri_idx] n_geom_out[ri] = face_normal # Flip to face incoming ray if np.dot(d_np[ri], face_normal) > 0: face_normal = -face_normal normals_out[ri] = face_normal hit_mask_np = t_out < np.inf if xp is not np: t_out = xp.array(t_out) normals_out = xp.array(normals_out) hit_mask_np = xp.array(hit_mask_np) n_geom_out = xp.array(n_geom_out) return t_out, normals_out, hit_mask_np, n_geom_out
[docs] def bounding_box(self, transform: tuple[np.ndarray, np.ndarray]) -> AABB: """Return AABB for the mesh 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) verts_local = np.array(self.mesh.vertices, dtype=float) verts_global = verts_local @ R.T + t_vec return AABB(verts_global.min(axis=0), verts_global.max(axis=0))
def _to_numpy(arr: np.ndarray) -> np.ndarray: try: import cupy # type: ignore[import] if isinstance(arr, cupy.ndarray): return cupy.asnumpy(arr) except ImportError: pass return arr def _get_xp(arr: np.ndarray): try: import cupy # type: ignore[import] if isinstance(arr, cupy.ndarray): return cupy except ImportError: pass return np