use std::f64::consts::PI;
use tracing::trace;
use super::trace::{OpticalSurface, TraceHit, TraceRay, trace_sequential, trace_surface};
use crate::error::Result;
#[derive(Debug, Clone, Copy, PartialEq)]
#[non_exhaustive]
pub enum TraceEvent {
Refraction {
reflectance: f64,
surface_idx: usize,
},
Reflection {
surface_idx: usize,
},
Escaped,
Terminated,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TraceSegment {
pub origin: [f64; 3],
pub direction: [f64; 3],
pub n: f64,
pub path_length: f64,
pub optical_path: f64,
pub energy: f64,
pub depth: u32,
pub event: TraceEvent,
}
#[derive(Debug, Clone, Copy)]
pub struct TraceConfig {
pub max_depth: u32,
pub min_energy: f64,
}
impl Default for TraceConfig {
fn default() -> Self {
Self {
max_depth: 8,
min_energy: 0.001,
}
}
}
#[derive(Debug, Clone)]
pub struct TraceTree {
pub segments: Vec<TraceSegment>,
pub interactions: usize,
}
#[must_use]
pub fn trace_recursive(
ray: &TraceRay,
surfaces: &[OpticalSurface],
config: &TraceConfig,
) -> TraceTree {
trace!(
max_depth = config.max_depth,
min_energy = config.min_energy,
num_surfaces = surfaces.len(),
"trace_recursive"
);
let mut segments = Vec::new();
let mut interactions = 0;
trace_recursive_inner(
ray,
surfaces,
config,
1.0,
0,
&mut segments,
&mut interactions,
);
TraceTree {
segments,
interactions,
}
}
fn trace_recursive_inner(
ray: &TraceRay,
surfaces: &[OpticalSurface],
config: &TraceConfig,
energy: f64,
depth: u32,
segments: &mut Vec<TraceSegment>,
interactions: &mut usize,
) {
if depth >= config.max_depth || energy < config.min_energy {
segments.push(TraceSegment {
origin: ray.position,
direction: ray.direction,
n: ray.n,
path_length: 0.0,
optical_path: 0.0,
energy,
depth,
event: TraceEvent::Terminated,
});
return;
}
let mut best_hit: Option<(usize, TraceHit)> = None;
for (i, surface) in surfaces.iter().enumerate() {
if let Ok(hit) = trace_surface(ray, surface) {
let dist = vec3_dist(ray.position, hit.hit_point);
match &best_hit {
Some((_, prev)) => {
if dist < vec3_dist(ray.position, prev.hit_point) {
best_hit = Some((i, hit));
}
}
None => best_hit = Some((i, hit)),
}
}
}
let Some((surface_idx, hit)) = best_hit else {
segments.push(TraceSegment {
origin: ray.position,
direction: ray.direction,
n: ray.n,
path_length: 0.0,
optical_path: 0.0,
energy,
depth,
event: TraceEvent::Escaped,
});
return;
};
*interactions += 1;
let path_len = vec3_dist(ray.position, hit.hit_point);
let optical_path = ray.n * path_len;
let reflectance = hit.reflectance;
let transmittance = 1.0 - reflectance;
segments.push(TraceSegment {
origin: ray.position,
direction: ray.direction,
n: ray.n,
path_length: path_len,
optical_path,
energy: energy * transmittance,
depth,
event: TraceEvent::Refraction {
reflectance,
surface_idx,
},
});
let refracted_energy = energy * transmittance;
if refracted_energy >= config.min_energy {
trace_recursive_inner(
&hit.ray_after,
surfaces,
config,
refracted_energy,
depth + 1,
segments,
interactions,
);
}
let reflected_energy = energy * reflectance;
if reflected_energy >= config.min_energy {
let reflected_dir = reflect_3d(ray.direction, hit.normal);
let reflected_ray = TraceRay {
position: hit.hit_point,
direction: reflected_dir,
n: ray.n, };
segments.push(TraceSegment {
origin: hit.hit_point,
direction: reflected_dir,
n: ray.n,
path_length: 0.0,
optical_path: 0.0,
energy: reflected_energy,
depth,
event: TraceEvent::Reflection { surface_idx },
});
trace_recursive_inner(
&reflected_ray,
surfaces,
config,
reflected_energy,
depth + 1,
segments,
interactions,
);
}
}
#[inline]
fn reflect_3d(direction: [f64; 3], normal: [f64; 3]) -> [f64; 3] {
let dot = direction[0] * normal[0] + direction[1] * normal[1] + direction[2] * normal[2];
[
direction[0] - 2.0 * dot * normal[0],
direction[1] - 2.0 * dot * normal[1],
direction[2] - 2.0 * dot * normal[2],
]
}
#[inline]
fn vec3_dist(a: [f64; 3], b: [f64; 3]) -> f64 {
let dx = b[0] - a[0];
let dy = b[1] - a[1];
let dz = b[2] - a[2];
(dx * dx + dy * dy + dz * dz).sqrt()
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FanRay {
pub ray: TraceRay,
pub aperture_height: f64,
}
#[must_use]
pub fn ray_fan_meridional(
aperture_radius: f64,
num_rays: usize,
field_angle: f64,
start_z: f64,
) -> Vec<FanRay> {
trace!(aperture_radius, num_rays, field_angle, "ray_fan_meridional");
let mut rays = Vec::with_capacity(num_rays);
let (sin_f, cos_f) = field_angle.sin_cos();
for i in 0..num_rays {
let t = if num_rays == 1 {
0.0
} else {
-1.0 + 2.0 * (i as f64) / (num_rays as f64 - 1.0)
};
let y = t * aperture_radius;
rays.push(FanRay {
ray: TraceRay {
position: [0.0, y, start_z],
direction: [0.0, sin_f, cos_f],
n: 1.0,
},
aperture_height: t,
});
}
rays
}
#[must_use]
pub fn ray_fan_sagittal(
aperture_radius: f64,
num_rays: usize,
field_angle: f64,
start_z: f64,
) -> Vec<FanRay> {
trace!(aperture_radius, num_rays, field_angle, "ray_fan_sagittal");
let mut rays = Vec::with_capacity(num_rays);
let (sin_f, cos_f) = field_angle.sin_cos();
for i in 0..num_rays {
let t = if num_rays == 1 {
0.0
} else {
-1.0 + 2.0 * (i as f64) / (num_rays as f64 - 1.0)
};
let x = t * aperture_radius;
rays.push(FanRay {
ray: TraceRay {
position: [x, 0.0, start_z],
direction: [sin_f, 0.0, cos_f],
n: 1.0,
},
aperture_height: t,
});
}
rays
}
#[must_use]
pub fn ray_bundle(
aperture_radius: f64,
num_rings: usize,
num_arms: usize,
field_angle: f64,
start_z: f64,
) -> Vec<FanRay> {
trace!(
aperture_radius,
num_rings, num_arms, field_angle, "ray_bundle"
);
let total = num_rings * num_arms + 1;
let mut rays = Vec::with_capacity(total);
let (sin_f, cos_f) = field_angle.sin_cos();
rays.push(FanRay {
ray: TraceRay {
position: [0.0, 0.0, start_z],
direction: [0.0, sin_f, cos_f],
n: 1.0,
},
aperture_height: 0.0,
});
for ring in 1..=num_rings {
let r = aperture_radius * (ring as f64) / (num_rings as f64);
let t = r / aperture_radius; for arm in 0..num_arms {
let phi = 2.0 * PI * (arm as f64) / (num_arms as f64);
let (sin_p, cos_p) = phi.sin_cos();
let x = r * cos_p;
let y = r * sin_p;
rays.push(FanRay {
ray: TraceRay {
position: [x, y, start_z],
direction: [0.0, sin_f, cos_f],
n: 1.0,
},
aperture_height: t,
});
}
}
rays
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SpotPoint {
pub x: f64,
pub y: f64,
pub optical_path: f64,
pub aperture_height: f64,
}
#[must_use]
pub fn spot_diagram(
surfaces: &[OpticalSurface],
aperture_radius: f64,
num_rings: usize,
num_arms: usize,
field_angle: f64,
start_z: f64,
image_z: f64,
) -> Vec<SpotPoint> {
trace!(num_rings, num_arms, field_angle, image_z, "spot_diagram");
let bundle = ray_bundle(aperture_radius, num_rings, num_arms, field_angle, start_z);
let mut spots = Vec::with_capacity(bundle.len());
for fan_ray in &bundle {
if let Ok(hits) = trace_sequential(&fan_ray.ray, surfaces) {
let final_ray = if let Some(last) = hits.last() {
last.ray_after
} else {
fan_ray.ray
};
let dz = final_ray.direction[2];
if dz.abs() < 1e-15 {
continue; }
let t = (image_z - final_ray.position[2]) / dz;
if t < 0.0 {
continue; }
let img_x = final_ray.position[0] + t * final_ray.direction[0];
let img_y = final_ray.position[1] + t * final_ray.direction[1];
let mut opl = 0.0;
let mut prev_pos = fan_ray.ray.position;
let mut prev_n = fan_ray.ray.n;
for hit in &hits {
let seg_len = vec3_dist(prev_pos, hit.hit_point);
opl += prev_n * seg_len;
prev_pos = hit.hit_point;
prev_n = hit.ray_after.n;
}
let final_len = vec3_dist(prev_pos, [img_x, img_y, image_z]);
opl += prev_n * final_len;
spots.push(SpotPoint {
x: img_x,
y: img_y,
optical_path: opl,
aperture_height: fan_ray.aperture_height,
});
}
}
spots
}
#[must_use]
pub fn spot_rms_radius(spots: &[SpotPoint]) -> f64 {
if spots.is_empty() {
return 0.0;
}
let n = spots.len() as f64;
let cx: f64 = spots.iter().map(|s| s.x).sum::<f64>() / n;
let cy: f64 = spots.iter().map(|s| s.y).sum::<f64>() / n;
let sum_r2: f64 = spots
.iter()
.map(|s| (s.x - cx) * (s.x - cx) + (s.y - cy) * (s.y - cy))
.sum();
(sum_r2 / n).sqrt()
}
#[must_use = "returns the computed optical path length"]
pub fn optical_path_length(
ray: &TraceRay,
surfaces: &[OpticalSurface],
image_z: f64,
) -> Result<f64> {
let hits = trace_sequential(ray, surfaces)?;
let mut opl = 0.0;
let mut prev_pos = ray.position;
let mut prev_n = ray.n;
for hit in &hits {
opl += prev_n * vec3_dist(prev_pos, hit.hit_point);
prev_pos = hit.hit_point;
prev_n = hit.ray_after.n;
}
let final_ray = if let Some(last) = hits.last() {
last.ray_after
} else {
*ray
};
let dz = final_ray.direction[2];
if dz.abs() > 1e-15 {
let t = (image_z - final_ray.position[2]) / dz;
if t > 0.0 {
let end = [
final_ray.position[0] + t * final_ray.direction[0],
final_ray.position[1] + t * final_ray.direction[1],
image_z,
];
opl += prev_n * vec3_dist(prev_pos, end);
}
}
Ok(opl)
}
#[must_use = "returns the computed OPD"]
pub fn optical_path_difference(
ray: &TraceRay,
chief_ray: &TraceRay,
surfaces: &[OpticalSurface],
image_z: f64,
) -> Result<f64> {
let opl_ray = optical_path_length(ray, surfaces, image_z)?;
let opl_chief = optical_path_length(chief_ray, surfaces, image_z)?;
Ok(opl_ray - opl_chief)
}
#[must_use]
pub fn opd_fan(fan: &[FanRay], surfaces: &[OpticalSurface], image_z: f64) -> Vec<(f64, f64)> {
trace!(num_rays = fan.len(), image_z, "opd_fan");
let chief = fan.iter().min_by(|a, b| {
a.aperture_height
.abs()
.partial_cmp(&b.aperture_height.abs())
.unwrap_or(std::cmp::Ordering::Equal)
});
let Some(chief) = chief else {
return Vec::new();
};
let Ok(opl_chief) = optical_path_length(&chief.ray, surfaces, image_z) else {
return Vec::new();
};
let mut result = Vec::with_capacity(fan.len());
for fan_ray in fan {
if let Ok(opl) = optical_path_length(&fan_ray.ray, surfaces, image_z) {
result.push((fan_ray.aperture_height, opl - opl_chief));
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ray::trace::{OpticalSurface, SurfaceShape};
const EPS: f64 = 1e-6;
fn biconvex_lens() -> Vec<OpticalSurface> {
vec![
OpticalSurface {
shape: SurfaceShape::Sphere { radius: 100.0 },
z_position: 0.0,
n_after: 1.5,
aperture_radius: 25.0,
},
OpticalSurface {
shape: SurfaceShape::Sphere { radius: -100.0 },
z_position: 5.0,
n_after: 1.0,
aperture_radius: 25.0,
},
]
}
fn on_axis_ray(z: f64) -> TraceRay {
TraceRay {
position: [0.0, 0.0, z],
direction: [0.0, 0.0, 1.0],
n: 1.0,
}
}
fn off_axis_ray(y: f64, z: f64) -> TraceRay {
TraceRay {
position: [0.0, y, z],
direction: [0.0, 0.0, 1.0],
n: 1.0,
}
}
#[test]
fn test_recursive_empty_scene() {
let ray = on_axis_ray(-50.0);
let tree = trace_recursive(&ray, &[], &TraceConfig::default());
assert_eq!(tree.segments.len(), 1);
assert_eq!(tree.interactions, 0);
assert!(matches!(tree.segments[0].event, TraceEvent::Escaped));
}
#[test]
fn test_recursive_single_surface() {
let surfaces = [OpticalSurface {
shape: SurfaceShape::Plane,
z_position: 10.0,
n_after: 1.5,
aperture_radius: 25.0,
}];
let ray = on_axis_ray(0.0);
let tree = trace_recursive(&ray, &surfaces, &TraceConfig::default());
assert!(tree.interactions >= 1);
assert!(tree.segments.len() >= 2); }
#[test]
fn test_recursive_energy_conservation() {
let surfaces = biconvex_lens();
let ray = on_axis_ray(-50.0);
let tree = trace_recursive(&ray, &surfaces, &TraceConfig::default());
let total_energy: f64 = tree
.segments
.iter()
.filter(|s| matches!(s.event, TraceEvent::Escaped | TraceEvent::Terminated))
.map(|s| s.energy)
.sum();
assert!(
total_energy <= 1.0 + EPS,
"Energy should be conserved: {total_energy}"
);
}
#[test]
fn test_recursive_max_depth() {
let surfaces = biconvex_lens();
let ray = on_axis_ray(-50.0);
let config = TraceConfig {
max_depth: 1,
min_energy: 0.0,
};
let tree = trace_recursive(&ray, &surfaces, &config);
for seg in &tree.segments {
assert!(seg.depth <= 1, "Depth should not exceed max_depth");
}
}
#[test]
fn test_recursive_min_energy() {
let surfaces = biconvex_lens();
let ray = on_axis_ray(-50.0);
let config = TraceConfig {
max_depth: 100,
min_energy: 0.5,
};
let tree = trace_recursive(&ray, &surfaces, &config);
assert!(tree.segments.len() < 20);
}
#[test]
fn test_recursive_generates_reflections() {
let surfaces = biconvex_lens();
let ray = off_axis_ray(5.0, -50.0);
let config = TraceConfig {
max_depth: 4,
min_energy: 0.001,
};
let tree = trace_recursive(&ray, &surfaces, &config);
let has_reflection = tree
.segments
.iter()
.any(|s| matches!(s.event, TraceEvent::Reflection { .. }));
assert!(
has_reflection,
"Should have at least one reflection segment"
);
}
#[test]
fn test_recursive_optical_path_positive() {
let surfaces = biconvex_lens();
let ray = on_axis_ray(-50.0);
let tree = trace_recursive(&ray, &surfaces, &TraceConfig::default());
for seg in &tree.segments {
assert!(seg.optical_path >= 0.0, "OPL should be non-negative");
assert!(seg.path_length >= 0.0, "Path length should be non-negative");
}
}
#[test]
fn test_meridional_fan_count() {
let fan = ray_fan_meridional(10.0, 11, 0.0, -50.0);
assert_eq!(fan.len(), 11);
}
#[test]
fn test_meridional_fan_symmetric() {
let fan = ray_fan_meridional(10.0, 11, 0.0, -50.0);
let first = fan.first().unwrap();
let last = fan.last().unwrap();
assert!((first.ray.position[1] + last.ray.position[1]).abs() < EPS);
assert!((first.aperture_height + last.aperture_height).abs() < EPS);
}
#[test]
fn test_meridional_fan_center_ray() {
let fan = ray_fan_meridional(10.0, 11, 0.0, -50.0);
let center = &fan[5];
assert!(
center.ray.position[1].abs() < EPS,
"Center ray should be on axis"
);
assert!(center.aperture_height.abs() < EPS);
}
#[test]
fn test_meridional_fan_aperture_range() {
let fan = ray_fan_meridional(10.0, 21, 0.0, -50.0);
for ray in &fan {
assert!(
ray.ray.position[1].abs() <= 10.0 + EPS,
"Ray outside aperture"
);
}
}
#[test]
fn test_sagittal_fan_count() {
let fan = ray_fan_sagittal(10.0, 11, 0.0, -50.0);
assert_eq!(fan.len(), 11);
}
#[test]
fn test_sagittal_fan_in_x_plane() {
let fan = ray_fan_sagittal(10.0, 11, 0.0, -50.0);
for ray in &fan {
assert!(
ray.ray.position[1].abs() < EPS,
"Sagittal fan should be in x-z plane"
);
}
}
#[test]
fn test_ray_bundle_count() {
let bundle = ray_bundle(10.0, 3, 6, 0.0, -50.0);
assert_eq!(bundle.len(), 3 * 6 + 1); }
#[test]
fn test_ray_bundle_chief_at_center() {
let bundle = ray_bundle(10.0, 3, 6, 0.0, -50.0);
let chief = &bundle[0];
assert!(chief.ray.position[0].abs() < EPS);
assert!(chief.ray.position[1].abs() < EPS);
assert!(chief.aperture_height.abs() < EPS);
}
#[test]
fn test_ray_bundle_within_aperture() {
let bundle = ray_bundle(10.0, 3, 8, 0.0, -50.0);
for ray in &bundle {
let r2 = ray.ray.position[0] * ray.ray.position[0]
+ ray.ray.position[1] * ray.ray.position[1];
assert!(r2 <= 10.0 * 10.0 + EPS, "Ray outside aperture: r²={r2}");
}
}
#[test]
fn test_ray_fan_with_field_angle() {
let fan = ray_fan_meridional(10.0, 5, 0.05, -50.0);
for ray in &fan {
assert!(ray.ray.direction[1].abs() > 0.01);
}
}
#[test]
fn test_spot_diagram_on_axis() {
let surfaces = biconvex_lens();
let spots = spot_diagram(&surfaces, 5.0, 2, 6, 0.0, -50.0, 200.0);
assert!(!spots.is_empty(), "Should have spot points");
}
#[test]
fn test_spot_diagram_chief_near_axis() {
let surfaces = biconvex_lens();
let spots = spot_diagram(&surfaces, 1.0, 1, 4, 0.0, -50.0, 200.0);
for spot in &spots {
assert!(
spot.x.abs() < 1.0 && spot.y.abs() < 1.0,
"On-axis spots should be near origin: ({}, {})",
spot.x,
spot.y
);
}
}
#[test]
fn test_spot_diagram_optical_path_positive() {
let surfaces = biconvex_lens();
let spots = spot_diagram(&surfaces, 5.0, 2, 6, 0.0, -50.0, 200.0);
for spot in &spots {
assert!(spot.optical_path > 0.0, "OPL should be positive");
}
}
#[test]
fn test_spot_rms_zero_for_single_point() {
let spots = vec![SpotPoint {
x: 1.0,
y: 2.0,
optical_path: 100.0,
aperture_height: 0.0,
}];
assert!(spot_rms_radius(&spots).abs() < EPS);
}
#[test]
fn test_spot_rms_empty() {
assert!(spot_rms_radius(&[]).abs() < EPS);
}
#[test]
fn test_spot_rms_known() {
let spots = vec![
SpotPoint {
x: 1.0,
y: 0.0,
optical_path: 0.0,
aperture_height: 0.0,
},
SpotPoint {
x: -1.0,
y: 0.0,
optical_path: 0.0,
aperture_height: 0.0,
},
SpotPoint {
x: 0.0,
y: 1.0,
optical_path: 0.0,
aperture_height: 0.0,
},
SpotPoint {
x: 0.0,
y: -1.0,
optical_path: 0.0,
aperture_height: 0.0,
},
];
let rms = spot_rms_radius(&spots);
assert!((rms - 1.0).abs() < EPS, "RMS should be 1.0, got {rms}");
}
#[test]
fn test_spot_larger_aperture_larger_spot() {
let surfaces = biconvex_lens();
let small = spot_diagram(&surfaces, 2.0, 2, 6, 0.0, -50.0, 200.0);
let large = spot_diagram(&surfaces, 10.0, 2, 6, 0.0, -50.0, 200.0);
let rms_small = spot_rms_radius(&small);
let rms_large = spot_rms_radius(&large);
assert!(
rms_large >= rms_small,
"Larger aperture should give larger spot: {rms_small} vs {rms_large}"
);
}
#[test]
fn test_opl_positive() {
let surfaces = biconvex_lens();
let ray = on_axis_ray(-50.0);
let opl = optical_path_length(&ray, &surfaces, 200.0).unwrap();
assert!(opl > 0.0, "OPL should be positive");
}
#[test]
fn test_opl_longer_in_glass() {
let surfaces = biconvex_lens();
let ray = on_axis_ray(-50.0);
let opl = optical_path_length(&ray, &surfaces, 200.0).unwrap();
let geometric = 250.0; assert!(
opl > geometric,
"OPL should exceed geometric distance: {opl} vs {geometric}"
);
}
#[test]
fn test_opd_chief_is_zero() {
let surfaces = biconvex_lens();
let chief = on_axis_ray(-50.0);
let opd = optical_path_difference(&chief, &chief, &surfaces, 200.0).unwrap();
assert!(opd.abs() < EPS, "Chief vs chief OPD should be 0");
}
#[test]
fn test_opd_off_axis_nonzero() {
let surfaces = biconvex_lens();
let chief = on_axis_ray(-50.0);
let marginal = off_axis_ray(5.0, -50.0);
let opd = optical_path_difference(&marginal, &chief, &surfaces, 200.0).unwrap();
assert!(
opd.abs() > 1e-6,
"Off-axis ray should have nonzero OPD: {opd}"
);
}
#[test]
fn test_opd_fan_symmetric() {
let surfaces = biconvex_lens();
let fan = ray_fan_meridional(10.0, 11, 0.0, -50.0);
let opd_data = opd_fan(&fan, &surfaces, 200.0);
assert!(!opd_data.is_empty());
for &(h, opd) in &opd_data {
if h.abs() < EPS {
assert!(opd.abs() < EPS, "Chief ray OPD should be ~0");
}
}
}
#[test]
fn test_opd_fan_empty_input() {
let surfaces = biconvex_lens();
let result = opd_fan(&[], &surfaces, 200.0);
assert!(result.is_empty());
}
}