Source code for optiland.nonsequential.bsdf.tabulated

"""Tabulated BSDF for Non-Sequential Raytracing.

Loads scatter data from a CSV or Zemax scatter file and interpolates.

Kramer Harrison, 2026
"""

from __future__ import annotations

from pathlib import Path
from typing import TYPE_CHECKING

import numpy as np
from scipy.interpolate import RegularGridInterpolator

import optiland.backend as be
from optiland.backend.utils import to_numpy
from optiland.nonsequential.bsdf.base import BaseBSDF
from optiland.nonsequential.rng import EventSlot

if TYPE_CHECKING:
    from optiland.nonsequential.rng import NSQRng


[docs] class TabulatedBSDF(BaseBSDF): """BSDF loaded from tabulated data (CSV or Zemax scatter file). The file must contain columns: theta_i [deg], theta_s [deg], bsdf_value. The BSDF is assumed azimuthally symmetric (phi-independent). Attributes: path: Path to the scatter data file. transmissive_fraction: Probability in [0, 1] that a given scatter event samples the transmissive hemisphere (the far side of the surface) instead of the reflective one. Defaults to 0.0: a purely reflective scatter, identical to this class's behaviour before D-5. The tabulated data itself is treated as hemisphere-relative (``theta_s`` measured from whichever normal the draw lands on), not as a combined BRDF+BTDF table. """ def __init__(self, path: str | Path, transmissive_fraction: float = 0.0) -> None: """Load tabulated BSDF from file. Args: path: Path to CSV file with columns [theta_i, theta_s, bsdf]. transmissive_fraction: Probability in [0, 1] that a scatter event lands in the transmissive hemisphere rather than the reflective one. """ self.path = Path(path) self.transmissive_fraction = float(transmissive_fraction) self._load(self.path) def _load(self, path: Path) -> None: """Parse and build an interpolator from the data file. Args: path: Path to the CSV data file. """ data = np.loadtxt(path, delimiter=",", comments="#") theta_i = np.unique(data[:, 0]) theta_s = np.unique(data[:, 1]) bsdf_grid = data[:, 2].reshape(len(theta_i), len(theta_s)) self._interp = RegularGridInterpolator( (theta_i, theta_s), bsdf_grid, method="linear", bounds_error=False, fill_value=0.0, ) self._theta_i_vals = theta_i self._theta_s_vals = theta_s
[docs] def sample( self, num_rays: int, incident_dirs: np.ndarray, normals: np.ndarray, wavelengths: np.ndarray, rng: NSQRng, ray_id: np.ndarray, bounce: np.ndarray, ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """Sample scattered directions from the tabulated BSDF. Uses importance sampling via Lambertian hemisphere + BSDF weighting. A per-ray draw against :attr:`transmissive_fraction` picks whether that hemisphere is centred on ``normals`` (reflective) or ``-normals`` (transmissive); ``theta_i``/``theta_s`` are measured from whichever normal the ray's draw landed on. Sampling is detached (keyed PCG32). Args: num_rays: Number of rays. incident_dirs: Incident directions, shape (N, 3). normals: Surface normals, shape (N, 3). wavelengths: Wavelengths [µm], shape (N,). rng: Keyed PCG32 RNG. ray_id: Per-ray identifiers, shape (N,). bounce: Per-ray bounce/step index, shape (N,). Returns: (scattered_dirs, flux_weights, transmitted). """ n_np = np.asarray(to_numpy(normals), dtype=np.float64) d_np = np.asarray(to_numpy(incident_dirs), dtype=np.float64) if self.transmissive_fraction > 0.0: u_lobe = rng.uniform(ray_id, bounce, EventSlot.BSDF_LOBE_BRANCH) transmitted_np = u_lobe < self.transmissive_fraction hemisphere_np = np.where(transmitted_np[:, None], -n_np, n_np) else: transmitted_np = np.zeros(n_np.shape[0], dtype=bool) hemisphere_np = n_np # Compute angle of incidence relative to the chosen hemisphere. cos_i = np.clip((-d_np * hemisphere_np).sum(axis=1), 0.0, 1.0) theta_i = np.degrees(np.arccos(cos_i)) from optiland.nonsequential.bsdf.lambertian import ( # noqa: PLC0415 _orthonormal_basis, ) r1 = rng.uniform(ray_id, bounce, EventSlot.BSDF_U1) r2 = rng.uniform(ray_id, bounce, EventSlot.BSDF_U2) phi = 2.0 * np.pi * r1 cos_theta = np.sqrt(r2) sin_theta = np.sqrt(1.0 - r2) theta_s = np.degrees(np.arccos(cos_theta)) lx = sin_theta * np.cos(phi) ly = sin_theta * np.sin(phi) lz = cos_theta t_vec, b_vec = _orthonormal_basis(hemisphere_np) scattered = ( lx[:, None] * t_vec + ly[:, None] * b_vec + lz[:, None] * hemisphere_np ) norms = (scattered * scattered).sum(axis=1, keepdims=True) ** 0.5 scattered = scattered / norms # Evaluate BSDF at (theta_i, theta_s) pairs query = np.column_stack([theta_i, theta_s]) bsdf_vals = self._interp(query) flux_weights = np.clip(np.pi * bsdf_vals, 0.0, 1.0) return ( be.array(scattered.astype(np.float64)), be.array(flux_weights), be.array(transmitted_np), )
[docs] def reflectance( self, incident_dirs: np.ndarray, normals: np.ndarray, wavelengths: np.ndarray, ) -> np.ndarray: """Approximate total hemispherical reflectance from tabulated data. Args: incident_dirs: Incident directions, shape (N, 3). normals: Surface normals, shape (N, 3). wavelengths: Wavelengths [µm], shape (N,). Returns: Reflectance values, shape (N,). """ d_np = np.asarray(to_numpy(incident_dirs), dtype=np.float64) n_np = np.asarray(to_numpy(normals), dtype=np.float64) cos_i = np.clip((-d_np * n_np).sum(axis=1), 0.0, 1.0) theta_i = np.degrees(np.arccos(cos_i)) theta_s_mid = np.mean(self._theta_s_vals) query = np.column_stack([theta_i, np.full_like(theta_i, theta_s_mid)]) bsdf_vals = self._interp(query) refl = np.clip(np.pi * bsdf_vals, 0.0, 1.0) return be.array(refl.astype(np.float64))