diff --git a/crates/cherry-rs/benches/convexplano_lens.rs b/crates/cherry-rs/benches/convexplano_lens.rs index 88ff0dcf..e5df1ac3 100644 --- a/crates/cherry-rs/benches/convexplano_lens.rs +++ b/crates/cherry-rs/benches/convexplano_lens.rs @@ -27,14 +27,16 @@ fn benchmark(c: &mut Criterion) { let n_air: Rc = n!(1.0); let n_nbk7: Rc = n!(1.515); let model = sequential_model(n_air, n_nbk7, &WAVELENGTHS); - let paraxial_view = ParaxialView::new(&model, &FIELD_SPECS, false).unwrap(); + let field_specs_by_path = [FIELD_SPECS.to_vec()]; + let aperture_specs_by_path = [APERTURE_SPEC]; + let paraxial_view = ParaxialView::new(&model, &field_specs_by_path, false).unwrap(); let mut group = c.benchmark_group("3D ray trace, convexplano lens"); group.bench_function("ray_trace_3d_view", |b| { b.iter(|| { ray_trace_3d_view( - black_box(&APERTURE_SPEC), - black_box(&FIELD_SPECS), + black_box(&aperture_specs_by_path), + black_box(&field_specs_by_path), black_box(&model), black_box(¶xial_view), black_box(SamplingConfig { diff --git a/crates/cherry-rs/benches/f_theta_scan_lens.rs b/crates/cherry-rs/benches/f_theta_scan_lens.rs index f6dd27f0..c17ac228 100644 --- a/crates/cherry-rs/benches/f_theta_scan_lens.rs +++ b/crates/cherry-rs/benches/f_theta_scan_lens.rs @@ -11,18 +11,19 @@ const APERTURE_SPEC: ApertureSpec = ApertureSpec::EntrancePupil { semi_diameter: fn benchmark(c: &mut Criterion) { let model = sequential_model(n!(1.0), n!(1.84666), &WAVELENGTHS); - let field_specs = vec![FieldSpec::Angle { + let field_specs_by_path = [vec![FieldSpec::Angle { chi: 20.0, phi: 90.0, - }]; - let paraxial_view = ParaxialView::new(&model, &field_specs, false).unwrap(); + }]]; + let aperture_specs_by_path = [APERTURE_SPEC]; + let paraxial_view = ParaxialView::new(&model, &field_specs_by_path, false).unwrap(); let mut group = c.benchmark_group("3D ray trace, f-theta scan lens"); group.bench_function("ray_trace_3d_view, 20 deg off-axis", |b| { b.iter(|| { ray_trace_3d_view( - black_box(&APERTURE_SPEC), - black_box(&field_specs), + black_box(&aperture_specs_by_path), + black_box(&field_specs_by_path), black_box(&model), black_box(¶xial_view), black_box(SamplingConfig { diff --git a/crates/cherry-rs/src/core/sequential_model/builder.rs b/crates/cherry-rs/src/core/sequential_model/builder.rs index 3fe4df05..99a36397 100644 --- a/crates/cherry-rs/src/core/sequential_model/builder.rs +++ b/crates/cherry-rs/src/core/sequential_model/builder.rs @@ -1080,16 +1080,13 @@ mod tests { .model; // Path 1's gap was solved to place its image at the paraxial focus. - let pv = ParaxialView::new( - &model, - &[FieldSpec::Angle { - chi: 0.0, - phi: 90.0, - }], - false, - ) - .unwrap(); - let sub1 = pv.get_for_path(1, 0, 0).unwrap(); + let field_specs = vec![FieldSpec::Angle { + chi: 0.0, + phi: 90.0, + }]; + let pv = ParaxialView::new(&model, &[field_specs.clone(), field_specs], false).unwrap(); + let tangential_vec_id = pv.tangential_vec_id_for_phi(1, std::f64::consts::FRAC_PI_2); + let sub1 = pv.get_for_path(1, 0, tangential_vec_id).unwrap(); assert_abs_diff_eq!( sub1.marginal_ray().rays_at_surface(2)[0].height, 0.0, diff --git a/crates/cherry-rs/src/core/sequential_model/solves/fno.rs b/crates/cherry-rs/src/core/sequential_model/solves/fno.rs index b99ebf5c..2a02d30c 100644 --- a/crates/cherry-rs/src/core/sequential_model/solves/fno.rs +++ b/crates/cherry-rs/src/core/sequential_model/solves/fno.rs @@ -322,7 +322,7 @@ mod tests { .build() .expect("build should succeed") .model; - let pv = ParaxialView::new(&model, &field_specs(), false).unwrap(); + let pv = ParaxialView::new(&model, &[field_specs()], false).unwrap(); (model, pv) } @@ -437,7 +437,7 @@ mod tests { .expect("build should succeed") .model; - let pv = ParaxialView::new(&model, &field_specs(), false).unwrap(); + let pv = ParaxialView::new(&model, &[field_specs()], false).unwrap(); let sub = pv.get(0, 0).unwrap(); // F/# constraint satisfied. @@ -548,10 +548,11 @@ mod tests { chi: 0.0, phi: 90.0, }]; - let pv = ParaxialView::new(&model, &field_specs, false).unwrap(); + let pv = ParaxialView::new(&model, &[field_specs.clone(), field_specs], false).unwrap(); // Path 1 (the evaluation path): F/# matches the target directly. - let sub1 = pv.get_for_path(1, 0, 0).unwrap(); + let tangential_vec_id_1 = pv.tangential_vec_id_for_phi(1, std::f64::consts::FRAC_PI_2); + let sub1 = pv.get_for_path(1, 0, tangential_vec_id_1).unwrap(); assert_abs_diff_eq!(sub1.paraxial_fno().abs(), target, epsilon = 1e-3); // Path 0 sees the *same physical surface* — same store index, same diff --git a/crates/cherry-rs/src/core/sequential_model/solves/marginal_ray.rs b/crates/cherry-rs/src/core/sequential_model/solves/marginal_ray.rs index d7b49c97..970e0a46 100644 --- a/crates/cherry-rs/src/core/sequential_model/solves/marginal_ray.rs +++ b/crates/cherry-rs/src/core/sequential_model/solves/marginal_ray.rs @@ -218,7 +218,7 @@ mod tests { .expect("build should succeed") .model; - let pv = ParaxialView::new(&model, &field_specs(), false).unwrap(); + let pv = ParaxialView::new(&model, &[field_specs()], false).unwrap(); let sub = pv.get(0, 0).unwrap(); let marginal_at_image = sub.marginal_ray().rays_at_surface(3)[0].height; @@ -238,7 +238,7 @@ mod tests { .expect("build should succeed") .model; - let pv = ParaxialView::new(&model, &field_specs(), false).unwrap(); + let pv = ParaxialView::new(&model, &[field_specs()], false).unwrap(); let sub = pv.get(0, 0).unwrap(); let marginal_at_image = sub.marginal_ray().rays_at_surface(3)[0].height; @@ -372,7 +372,7 @@ mod tests { .expect("build should succeed") .model; - let pv = ParaxialView::new(&model, &field_specs(), false).unwrap(); + let pv = ParaxialView::new(&model, &[field_specs()], false).unwrap(); let sub = pv.get(0, 0).unwrap(); let marginal_at_image = sub.marginal_ray().rays_at_surface(3)[0].height; assert_abs_diff_eq!(marginal_at_image, 0.0, epsilon = 1e-4); diff --git a/crates/cherry-rs/src/gui/compute.rs b/crates/cherry-rs/src/gui/compute.rs index e6b6f4d8..a089f732 100644 --- a/crates/cherry-rs/src/gui/compute.rs +++ b/crates/cherry-rs/src/gui/compute.rs @@ -150,7 +150,7 @@ fn run_compute( let surfaces = build_surface_descs(&seq); let fields = build_field_descs(&parsed.fields); - let pv = match ParaxialView::new(&seq, &parsed.fields, false) { + let pv = match ParaxialView::new(&seq, std::slice::from_ref(&parsed.fields), false) { Ok(p) => p, Err(e) => { return ResultPackage { @@ -179,7 +179,13 @@ fn run_compute( n_fan_rays: req.specs.n_fan_rays as usize, full_pupil_spacing, }; - let trace = match ray_trace_3d_view(&parsed.aperture, &parsed.fields, &seq, &pv, config) { + let trace = match ray_trace_3d_view( + &[parsed.aperture], + std::slice::from_ref(&parsed.fields), + &seq, + &pv, + config, + ) { Ok(t) => Some(t), Err(e) => { log::warn!("Ray trace failed: {e}"); diff --git a/crates/cherry-rs/src/gui/convert.rs b/crates/cherry-rs/src/gui/convert.rs index e42bc9e1..4206ed69 100644 --- a/crates/cherry-rs/src/gui/convert.rs +++ b/crates/cherry-rs/src/gui/convert.rs @@ -316,13 +316,15 @@ fn apply_group_transforms( for group in lens_groups { // Collect the full surface index list for this group from the component map. + // `nominal` is always single-path (built via from_surface_specs), so there is + // exactly one PathComponent per component and no path filtering is needed. let mut all_surfs: Vec = Vec::new(); for &first_surf in &group.component_first_surfs { - if let Some(comp) = components + if let Some(pc) = components .iter() - .find(|c| component_first_idx(c) == first_surf) + .find(|pc| component_first_idx(&pc.component) == first_surf) { - match comp { + match &pc.component { Component::Element { surf_idxs } => all_surfs.extend(surf_idxs), Component::Iris { stop_idx } => all_surfs.push(*stop_idx), Component::Mirror { surf_idx } => all_surfs.push(*surf_idx), diff --git a/crates/cherry-rs/src/gui/panels/lens_overlay.rs b/crates/cherry-rs/src/gui/panels/lens_overlay.rs index 92c354d5..6072f7d1 100644 --- a/crates/cherry-rs/src/gui/panels/lens_overlay.rs +++ b/crates/cherry-rs/src/gui/panels/lens_overlay.rs @@ -7,7 +7,7 @@ use crate::{ model::{LensGroupSpec, SystemSpecs}, result_package::ResultPackage, }, - views::components::Component, + views::components::{Component, PathComponent}, }; /// Floating panel that groups auto-detected optical components and exposes @@ -58,9 +58,12 @@ fn default_group_name(c: &Component) -> String { /// discarded groups and `n_new` is the number of newly added groups. fn validate_and_sync( lens_groups: &mut Vec, - components: &[Component], + components: &[PathComponent], ) -> (Vec, usize) { - let known: HashSet = components.iter().map(component_first_idx).collect(); + let known: HashSet = components + .iter() + .map(|pc| component_first_idx(&pc.component)) + .collect(); let mut stale_names = Vec::new(); let valid: Vec = lens_groups @@ -84,10 +87,10 @@ fn validate_and_sync( .collect(); let mut n_new = 0usize; - for comp in components { - let first = component_first_idx(comp); + for pc in components { + let first = component_first_idx(&pc.component); if !covered.contains(&first) { - let mut g = LensGroupSpec::new(default_group_name(comp)); + let mut g = LensGroupSpec::new(default_group_name(&pc.component)); g.component_first_surfs = vec![first]; lens_groups.push(g); n_new += 1; @@ -174,7 +177,7 @@ impl LensOverlayPanel { let components = &result.components; let comp_lookup: std::collections::HashMap = components .iter() - .map(|c| (component_first_idx(c), c)) + .map(|pc| (component_first_idx(&pc.component), &pc.component)) .collect(); let n_groups = specs.lens_groups.len(); @@ -459,6 +462,16 @@ mod tests { Component::Mirror { surf_idx: idx } } + fn as_path_components(components: Vec) -> Vec { + components + .into_iter() + .map(|component| PathComponent { + path_id: 0, + component, + }) + .collect() + } + fn make_group(name: &str, first_surfs: Vec) -> LensGroupSpec { let mut g = LensGroupSpec::new(name); g.component_first_surfs = first_surfs; @@ -468,7 +481,7 @@ mod tests { #[test] fn validate_adds_default_groups_when_empty() { let mut groups: Vec = Vec::new(); - let components = vec![make_element(vec![1, 2]), make_mirror(3)]; + let components = as_path_components(vec![make_element(vec![1, 2]), make_mirror(3)]); let (stale, n_new) = validate_and_sync(&mut groups, &components); assert!(stale.is_empty()); assert_eq!(n_new, 2); @@ -480,7 +493,7 @@ mod tests { #[test] fn validate_discards_stale_group() { let mut groups = vec![make_group("OldGroup", vec![5])]; - let components = vec![make_element(vec![1, 2])]; + let components = as_path_components(vec![make_element(vec![1, 2])]); let (stale, n_new) = validate_and_sync(&mut groups, &components); assert_eq!(stale, vec!["OldGroup"]); assert_eq!(n_new, 1); @@ -491,7 +504,7 @@ mod tests { #[test] fn validate_keeps_valid_groups_and_adds_new_component() { let mut groups = vec![make_group("MyLens", vec![1])]; - let components = vec![make_element(vec![1, 2]), make_mirror(3)]; + let components = as_path_components(vec![make_element(vec![1, 2]), make_mirror(3)]); let (stale, n_new) = validate_and_sync(&mut groups, &components); assert!(stale.is_empty()); assert_eq!(n_new, 1); @@ -503,7 +516,7 @@ mod tests { #[test] fn validate_groups_sorted_by_first_surf() { let mut groups: Vec = Vec::new(); - let components = vec![make_mirror(5), make_element(vec![1, 2])]; + let components = as_path_components(vec![make_mirror(5), make_element(vec![1, 2])]); let (_, _) = validate_and_sync(&mut groups, &components); assert_eq!(groups[0].component_first_surfs[0], 1); assert_eq!(groups[1].component_first_surfs[0], 5); diff --git a/crates/cherry-rs/src/gui/result_package.rs b/crates/cherry-rs/src/gui/result_package.rs index 4109dbda..bbc1c309 100644 --- a/crates/cherry-rs/src/gui/result_package.rs +++ b/crates/cherry-rs/src/gui/result_package.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use crate::{ CrossSectionView, FieldSpec, ParaxialView, TraceResultsCollection, core::math::{linalg::mat3x3::Mat3x3, vec3::Vec3}, - views::components::Component, + views::components::PathComponent, }; /// Post-solve parameter values keyed by their index in the surfaces table. @@ -47,7 +47,7 @@ pub struct ResultPackage { pub error: Option, pub solved_values: SolvedValues, /// Auto-detected optical components from the sequential model. - pub components: Vec, + pub components: Vec, } impl ResultPackage { diff --git a/crates/cherry-rs/src/gui/windows/paraxial.rs b/crates/cherry-rs/src/gui/windows/paraxial.rs index 2e6eb11b..34e173b6 100644 --- a/crates/cherry-rs/src/gui/windows/paraxial.rs +++ b/crates/cherry-rs/src/gui/windows/paraxial.rs @@ -61,7 +61,11 @@ fn render_paraxial_content(ui: &mut egui::Ui, r: &ResultPackage) { if r.wavelengths.len() > 1 { let pac = pv.primary_axial_color(); for &v_idx in &v_indices { - if let Some(&color) = pac.get(v_idx) { + if let Some(color) = pac + .iter() + .find(|ac| ac.path_id == 0 && ac.tangential_vec_id == v_idx) + .map(|ac| ac.color) + { let phi_suffix = if n_v > 1 { let phi_deg = pv.phi_deg(v_idx); format!(" (\u{03c6} = {phi_deg:.0}\u{00b0})") @@ -291,7 +295,8 @@ mod tests { None, ) .expect("model"); - let pv = ParaxialView::new(&seq, &parsed.fields, false).expect("paraxial"); + let pv = + ParaxialView::new(&seq, std::slice::from_ref(&parsed.fields), false).expect("paraxial"); let wls = seq.wavelengths().to_vec(); ResultPackage { id: 1, diff --git a/crates/cherry-rs/src/gui/windows/ray_fan.rs b/crates/cherry-rs/src/gui/windows/ray_fan.rs index 4ff10a72..2bdfbda3 100644 --- a/crates/cherry-rs/src/gui/windows/ray_fan.rs +++ b/crates/cherry-rs/src/gui/windows/ray_fan.rs @@ -332,7 +332,7 @@ fn chief_ray_image_pos( let Some(pv) = &r.paraxial else { return (None, false); }; - let tangential_vec_id = pv.tangential_vec_id_for_phi(phi); + let tangential_vec_id = pv.tangential_vec_id_for_phi(0, phi); let Some(sv) = pv.get(wl_id, tangential_vec_id) else { return (None, false); }; @@ -518,12 +518,20 @@ mod tests { None, ) .expect("model"); - let pv = ParaxialView::new(&seq, &parsed.fields, false).expect("paraxial"); + let pv = + ParaxialView::new(&seq, std::slice::from_ref(&parsed.fields), false).expect("paraxial"); let config = SamplingConfig { n_fan_rays: 11, full_pupil_spacing: 0.1, }; - let trace = ray_trace_3d_view(&parsed.aperture, &parsed.fields, &seq, &pv, config).ok(); + let trace = ray_trace_3d_view( + &[parsed.aperture], + std::slice::from_ref(&parsed.fields), + &seq, + &pv, + config, + ) + .ok(); let wls = seq.wavelengths().to_vec(); // Build surface descs manually (mirrors compute.rs logic). diff --git a/crates/cherry-rs/src/gui/windows/spot_diagram.rs b/crates/cherry-rs/src/gui/windows/spot_diagram.rs index b752f6e3..89aa8b55 100644 --- a/crates/cherry-rs/src/gui/windows/spot_diagram.rs +++ b/crates/cherry-rs/src/gui/windows/spot_diagram.rs @@ -393,7 +393,8 @@ mod tests { None, ) .expect("model"); - let pv = ParaxialView::new(&seq, &parsed.fields, false).expect("paraxial"); + let pv = + ParaxialView::new(&seq, std::slice::from_ref(&parsed.fields), false).expect("paraxial"); let result = ResultPackage { id: 1, @@ -447,10 +448,11 @@ mod tests { None, ) .expect("model"); - let pv = ParaxialView::new(&seq, &parsed.fields, false).expect("paraxial"); + let pv = + ParaxialView::new(&seq, std::slice::from_ref(&parsed.fields), false).expect("paraxial"); let trace = ray_trace_3d_view( - &parsed.aperture, - &parsed.fields, + &[parsed.aperture], + std::slice::from_ref(&parsed.fields), &seq, &pv, crate::views::ray_trace_3d::SamplingConfig { diff --git a/crates/cherry-rs/src/lib.rs b/crates/cherry-rs/src/lib.rs index ed30eb25..6d12b3b6 100644 --- a/crates/cherry-rs/src/lib.rs +++ b/crates/cherry-rs/src/lib.rs @@ -103,8 +103,9 @@ //! FieldSpec::Angle { chi: 5.0, phi: 90.0 }, //! ]; //! -//! // Compute the paraxial view of the system. -//! let paraxial_view = ParaxialView::new(&sequential_model, &field_specs, false).unwrap(); +//! // Compute the paraxial view of the system. The model has a single path, +//! // so field_specs_by_path is a length-1 list. +//! let paraxial_view = ParaxialView::new(&sequential_model, &[field_specs.clone()], false).unwrap(); //! //! // Compute the effective focal length of the lens for each submodel. //! for sub_view in paraxial_view.iter() { @@ -119,7 +120,7 @@ //! // Compute a 3D ray trace of the system, sampling the pupil with a square //! // grid with a spacing of 0.1 in normalized pupil coordinates. //! let results_collection = ray_trace_3d_view( -//! &aperture_spec, &field_specs, +//! &[aperture_spec], &[field_specs.clone()], //! &sequential_model, //! ¶xial_view, //! SamplingConfig { n_fan_rays: 9, full_pupil_spacing: 0.1 }, @@ -164,13 +165,13 @@ pub use specs::{ surfaces::{BeamSplitterPathKind, BoundaryKind, Mask, PlacementSpec, SurfaceSpec}, }; pub use views::{ - components::{Component, components_view}, + components::{Component, PathComponent, components_view}, cross_section::{ Bounds2D, CrossSectionView, DrawElement, FlatPlaneKind, PlaneGeometry, cross_section_view, }, paraxial::{ - ImagePlane, ParaxialRay, ParaxialRayBundle, ParaxialSubView, ParaxialSubViewDescription, - ParaxialView, ParaxialViewDescription, Pupil, + AxialColor, ImagePlane, ParaxialRay, ParaxialRayBundle, ParaxialSubView, + ParaxialSubViewDescription, ParaxialView, ParaxialViewDescription, Pupil, }, ray_trace_3d::{ RayBundle, SamplingConfig, TraceResults, TraceResultsCollection, ray_trace_3d_view, diff --git a/crates/cherry-rs/src/views/components/mod.rs b/crates/cherry-rs/src/views/components/mod.rs index 57848291..c3b2424e 100644 --- a/crates/cherry-rs/src/views/components/mod.rs +++ b/crates/cherry-rs/src/views/components/mod.rs @@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize}; use crate::{ BoundaryKind, RefractiveIndexSpec, SequentialModel, SequentialSubModel, SurfaceKind, - core::{Float, refractive_index::RefractiveIndex}, + core::{Float, refractive_index::RefractiveIndex, sequential_model::Gap, surfaces::Surface}, }; const TOL: Float = 1e-6; @@ -45,6 +45,19 @@ pub enum Component { }, } +/// A `Component` tagged with the path that it was computed for. +/// +/// `Component`'s own fields always carry **store indices**, not path-local +/// step indices — a component built from a surface shared by multiple paths +/// therefore compares equal (same field values) across the `PathComponent`s +/// that reference it, differing only in `path_id`. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct PathComponent { + pub path_id: usize, + pub component: Component, +} + /// Determine the components of an optical system. /// /// Components are the basic building blocks of an optical system. They are @@ -53,80 +66,130 @@ pub enum Component { /// /// Components serve to group surfaces together into individual lenses. /// +/// This computes every path's own components in a single call — a View's +/// scope matches its `SequentialModel`'s scope. A component built from a +/// surface shared by multiple paths is **not** deduplicated: it is computed +/// independently within each path's own pass and appears once per traversing +/// path. +/// /// # Arguments /// * `sequential_model` - The sequential model of the optical system. /// * `background` - The refractive index of the background medium. pub fn components_view( sequential_model: &SequentialModel, background: Rc, -) -> Result> { +) -> Result> { let surfaces = sequential_model.surfaces(); - let n_surfs = surfaces.len(); - - let wavelength = sequential_model - .wavelengths() - .first() - .copied() - .unwrap_or(0.5876); + let mut all_components = Vec::new(); + + for path_id in 0..sequential_model.path_count() { + let wavelength = sequential_model + .wavelengths_for_path(path_id) + .first() + .copied() + .unwrap_or(0.5876); + let background_ri = RefractiveIndex::try_from_spec(background.as_ref(), wavelength)?; + + let surface_indices = sequential_model.path_surface_indices(path_id); + let n_steps = surface_indices.len(); + let submodel = sequential_model + .submodels_for_path(path_id) + .first() + .ok_or_else(|| anyhow!("path {path_id} has no submodels"))?; + let gaps = submodel.gaps(); + + if n_steps < 3 { + // Only object and image plane exist on this path; no real + // components. A disclosed per-path behavior change from the old + // whole-model check: a shorter path is skipped, but a longer + // path on the same model still produces its own components. + continue; + } - let background_ri = RefractiveIndex::try_from_spec(background.as_ref(), wavelength)?; + let path_components = components_for_path(surfaces, surface_indices, gaps, background_ri)?; + all_components.extend( + path_components + .into_iter() + .map(|component| PathComponent { path_id, component }), + ); + } - let sequential_sub_model = sequential_model - .submodel(0) - .ok_or(anyhow!("No submodel found for wavelength index 0."))?; - let gaps = sequential_sub_model.gaps(); + Ok(all_components) +} - if n_surfs < 3 { - // Only object and image plane exist; no real components. - return Ok(vec![]); - } +/// Determine the components of a single path. +/// +/// Iterates in **step space** (position within `surface_indices`, which is +/// also how `gaps` is ordered for this path) and resolves to a **store +/// index** (`surface_indices[step]`) only when reading `surfaces[..]` or +/// constructing a `Component`. +fn components_for_path( + surfaces: &[Box], + surface_indices: &[usize], + gaps: &[Gap], + background_ri: RefractiveIndex, +) -> Result> { + let n_steps = surface_indices.len(); - // Collect non-element components (mirrors, irises) and track which surfaces - // are already claimed so we can detect unpaired surfaces later. + // Collect non-element components (mirrors, irises) and track which steps + // are already claimed so we can detect unpaired surfaces later. Claimed + // steps are tracked in step space; store indices are only resolved when + // constructing a Component. let mut non_elements: Vec = Vec::new(); let mut claimed: HashSet = HashSet::new(); - for (i, surface) in surfaces.iter().enumerate().skip(1).take(n_surfs - 2) { + for (step, &store_idx) in surface_indices.iter().enumerate().skip(1).take(n_steps - 2) { + let surface = &surfaces[store_idx]; let kind = surface.surface_kind(); if matches!(surface.boundary_kind(), BoundaryKind::Reflecting) { - non_elements.push(Component::Mirror { surf_idx: i }); - claimed.insert(i); + non_elements.push(Component::Mirror { + surf_idx: store_idx, + }); + claimed.insert(step); } else if kind == SurfaceKind::Iris { - non_elements.push(Component::Iris { stop_idx: i }); - claimed.insert(i); + non_elements.push(Component::Iris { + stop_idx: store_idx, + }); + claimed.insert(step); } else if kind == SurfaceKind::ThinLens { - non_elements.push(Component::ThinLens { surf_idx: i }); - claimed.insert(i); + non_elements.push(Component::ThinLens { + surf_idx: store_idx, + }); + claimed.insert(step); } } // Pass 1: for every non-background gap, find the nearest non-probe surface // on each side and emit a candidate length-2 element. Probes inside a glass - // run are skipped so they don't split an element. + // run are skipped so they don't split an element. Candidates are stored in + // step space throughout passes 1-2; resolved to store indices only when + // Elements are constructed below. let mut candidates: Vec> = Vec::new(); - 'gap_loop: for (gap_idx, gap) in gaps.iter().enumerate().skip(1).take(n_surfs - 2) { + 'gap_loop: for (gap_step, gap) in gaps.iter().enumerate().skip(1).take(n_steps - 2) { if same_medium(gap.refractive_index, background_ri) { continue; // background gap — not inside glass } - // The front surface is the surface at index gap_idx (left side of the + // The front surface is the surface at step gap_step (left side of the // gap), walking backwards past any probes to find a real boundary. - let mut front = gap_idx; - while front > 0 && surfaces[front].surface_kind() == SurfaceKind::Probe { + let mut front = gap_step; + while front > 0 && surfaces[surface_indices[front]].surface_kind() == SurfaceKind::Probe { front -= 1; } - // The back surface is gap_idx+1, walking forwards past any probes. - let mut back = gap_idx + 1; - while back < n_surfs && surfaces[back].surface_kind() == SurfaceKind::Probe { + // The back surface is gap_step+1, walking forwards past any probes. + let mut back = gap_step + 1; + while back < n_steps && surfaces[surface_indices[back]].surface_kind() == SurfaceKind::Probe + { back += 1; } // Skip if probe-walk escaped the model bounds or landed on object/image. if front == 0 - || back >= n_surfs - || back == n_surfs - 1 && surfaces[back].surface_kind() == SurfaceKind::Image + || back >= n_steps + || back == n_steps - 1 + && surfaces[surface_indices[back]].surface_kind() == SurfaceKind::Image { continue 'gap_loop; } @@ -169,39 +232,47 @@ pub fn components_view( } } - // Convert candidates to Element components, tracking which surfaces are now - // part of an element so unpaired surfaces can be detected. + // Convert candidates to Element components, tracking which steps are now + // part of an element so unpaired surfaces can be detected. Store indices + // are resolved here, when the Component is actually constructed. let mut elements: Vec = Vec::new(); - for mut surfs in candidates { - surfs.sort_unstable(); - for &s in &surfs { + for mut steps in candidates { + steps.sort_unstable(); + for &s in &steps { claimed.insert(s); } - elements.push(Component::Element { surf_idxs: surfs }); + let surf_idxs = steps.iter().map(|&step| surface_indices[step]).collect(); + elements.push(Component::Element { surf_idxs }); } // Detect unpaired surfaces: refracting surfaces that border at least one // non-background gap but were not merged into any element. This includes // the surface just before Image and surfaces adjacent to an iris or mirror // in a non-background medium (e.g. an iris submerged in glass). - for i in 1..(n_surfs - 1) { - if claimed.contains(&i) { + for step in 1..(n_steps - 1) { + if claimed.contains(&step) { continue; } - let kind = surfaces[i].surface_kind(); + let store_idx = surface_indices[step]; + let kind = surfaces[store_idx].surface_kind(); if kind == SurfaceKind::Object || kind == SurfaceKind::Image || kind == SurfaceKind::Probe || kind == SurfaceKind::Iris - || matches!(surfaces[i].boundary_kind(), BoundaryKind::Reflecting) + || matches!( + surfaces[store_idx].boundary_kind(), + BoundaryKind::Reflecting + ) { continue; } - let borders_non_background = !same_medium(gaps[i - 1].refractive_index, background_ri) - || !same_medium(gaps[i].refractive_index, background_ri); + let borders_non_background = !same_medium(gaps[step - 1].refractive_index, background_ri) + || !same_medium(gaps[step].refractive_index, background_ri); if borders_non_background { - non_elements.push(Component::UnpairedSurface { surf_idx: i }); - claimed.insert(i); + non_elements.push(Component::UnpairedSurface { + surf_idx: store_idx, + }); + claimed.insert(step); } } @@ -499,11 +570,24 @@ mod tests { // SequentialModel::from_surface_specs(&gaps, &surfaces, &wavelengths, // None).unwrap() } + /// Test helper: strips the `path_id` tag since every model built here is + /// single-path, where the distinction is invisible. + fn components_only( + model: &SequentialModel, + background: Rc, + ) -> Vec { + components_view(model, background) + .unwrap() + .into_iter() + .map(|pc| pc.component) + .collect() + } + #[test] fn test_concave_mirror() { let sequential_model = concave_mirror::sequential_model(n!(1.0), &[0.5876]); - let components = components_view(&sequential_model, n!(1.0)).unwrap(); + let components = components_only(&sequential_model, n!(1.0)); assert_eq!(components.len(), 1); assert!(components.contains(&Component::Mirror { surf_idx: 1 })); @@ -513,7 +597,7 @@ mod tests { fn test_new_no_components() { let sequential_model = empty_system(); - let components = components_view(&sequential_model, n!(1.0)).unwrap(); + let components = components_only(&sequential_model, n!(1.0)); assert_eq!(components.len(), 0); } @@ -524,7 +608,7 @@ mod tests { let nbk7 = n!(1.515); let wavelengths: [Float; 1] = [0.5876]; let sequential_model = convexplano_lens::sequential_model(air, nbk7, &wavelengths); - let components = components_view(&sequential_model, n!(1.0)).unwrap(); + let components = components_only(&sequential_model, n!(1.0)); assert_eq!(components.len(), 1); assert!(components.contains(&Component::Element { @@ -570,7 +654,7 @@ mod tests { #[test] fn test_thin_lens_is_standalone_component() { let sequential_model = thin_lens_singlet(); - let components = components_view(&sequential_model, n!(1.0)).unwrap(); + let components = components_only(&sequential_model, n!(1.0)); assert_eq!(components.len(), 1); assert!(components.contains(&Component::ThinLens { surf_idx: 1 })); @@ -582,7 +666,7 @@ mod tests { // is claimed — it must be emitted as an UnpairedSurface. let sequential_model = silly_single_surface_and_stop(); - let components = components_view(&sequential_model, n!(1.0)).unwrap(); + let components = components_only(&sequential_model, n!(1.0)); assert_eq!(components.len(), 2); assert!(components.contains(&Component::Iris { stop_idx: 2 })); @@ -594,7 +678,7 @@ mod tests { // This is not a useful system but a good test. let sequential_model = silly_unpaired_surface(); - let components = components_view(&sequential_model, n!(1.0)).unwrap(); + let components = components_only(&sequential_model, n!(1.0)); assert_eq!(components.len(), 2); assert!(components.contains(&Component::Element { @@ -607,7 +691,7 @@ mod tests { fn test_wollaston_landscape_lens() { let sequential_model = wollaston_landscape_lens(); - let components = components_view(&sequential_model, n!(1.0)).unwrap(); + let components = components_only(&sequential_model, n!(1.0)); assert_eq!(components.len(), 2); assert!(components.contains(&Component::Iris { stop_idx: 1 })); // Hard stop @@ -662,7 +746,7 @@ mod tests { fn test_mirror_before_probe() { // Regression: mirror must appear even when a probe sits between it and Image. let model = mirror_then_probe(); - let components = components_view(&model, n!(1.0)).unwrap(); + let components = components_only(&model, n!(1.0)); assert_eq!(components.len(), 1); assert!(components.contains(&Component::Mirror { surf_idx: 1 })); } @@ -676,7 +760,7 @@ mod tests { let air = n!(1.00029); let glass = n!(1.847); let model = f_theta_scan_lens::sequential_model(air.clone(), glass, &[0.5876]); - let components = components_view(&model, air).unwrap(); + let components = components_only(&model, air); assert_eq!(components.len(), 4); // 1 stop + 3 elements assert!(components.contains(&Component::Iris { stop_idx: 1 })); assert!(components.contains(&Component::Element { @@ -758,7 +842,7 @@ mod tests { // A cemented doublet (BK7 + SF2) must be detected as one element spanning // all three bounding surfaces [1, 2, 3], not two separate elements. let model = cemented_doublet(); - let components = components_view(&model, n!(1.0)).unwrap(); + let components = components_only(&model, n!(1.0)); assert_eq!(components.len(), 1); assert_eq!( components[0], @@ -831,7 +915,7 @@ mod tests { // A probe inside the glass of a singlet must not split it into two elements. // Expected: one Element with surf_idxs [1, 3], skipping the probe at [2]. let model = singlet_with_probe(); - let components = components_view(&model, n!(1.0)).unwrap(); + let components = components_only(&model, n!(1.0)); assert_eq!(components.len(), 1); assert_eq!( components[0], @@ -903,11 +987,75 @@ mod tests { // Regression: when an iris sits between two non-background surfaces, // both surrounding refracting surfaces must appear as UnpairedSurface. let model = iris_in_glass(); - let components = components_view(&model, n!(1.0)).unwrap(); + let components = components_only(&model, n!(1.0)); assert_eq!(components.len(), 3); assert!(components.contains(&Component::Iris { stop_idx: 2 })); assert!(components.contains(&Component::UnpairedSurface { surf_idx: 1 })); assert!(components.contains(&Component::UnpairedSurface { surf_idx: 3 })); } + + /// AT-5: regression test for the pre-fix bug where `components_view` + /// indexed the global, store-wide surface list against path 0's own + /// path-relative gap sequence. `wf_epi_microscope`'s emission path + /// introduces surfaces (fold mirror, tube lens, Image) that path 0 never + /// visits, and its `path_surface_indices` are not store-order (`[5, 3, 2, + /// 6, 7, 8]`) — exactly the shape that made the old implementation + /// misindex. `components_view` must succeed and return the correct + /// element groupings for both paths. + #[test] + fn at5_components_view_does_not_misindex_path_exclusive_surfaces() { + use crate::examples::wf_epi_microscope::sequential_model; + + let model = sequential_model(n!(1.0), n!(1.5), &[0.488], &[0.520]); + assert_eq!(model.path_surface_indices(1), &[5, 3, 2, 6, 7, 8]); + + let components = components_view(&model, n!(1.0)).unwrap(); + + // Path 0 (excitation): tube lens (store 1) and objective (store 3), + // both ThinLens components at their own store index. + assert!(components.contains(&PathComponent { + path_id: 0, + component: Component::ThinLens { surf_idx: 1 }, + })); + assert!(components.contains(&PathComponent { + path_id: 0, + component: Component::ThinLens { surf_idx: 3 }, + })); + + // Path 1 (emission): fold mirror (store 6) and tube lens (store 7) — + // both path-1-exclusive surfaces, at step positions 3 and 4, whose + // store indices diverge from their step indices. A store/step + // conflation bug would misidentify these (or panic on an + // out-of-bounds gap index). + assert!(components.contains(&PathComponent { + path_id: 1, + component: Component::Mirror { surf_idx: 6 }, + })); + assert!(components.contains(&PathComponent { + path_id: 1, + component: Component::ThinLens { surf_idx: 7 }, + })); + } + + /// AT-6: a component built from a surface shared by multiple paths (the + /// objective, store index 3) must appear once per traversing path — not + /// deduplicated — computed independently within each path's own pass. + #[test] + fn at6_components_view_tags_shared_surface_once_per_path() { + use crate::examples::wf_epi_microscope::sequential_model; + + let model = sequential_model(n!(1.0), n!(1.5), &[0.488], &[0.520]); + let components = components_view(&model, n!(1.0)).unwrap(); + + let objective = Component::ThinLens { surf_idx: 3 }; + assert!(components.contains(&PathComponent { + path_id: 0, + component: objective.clone(), + })); + assert!(components.contains(&PathComponent { + path_id: 1, + component: objective, + })); + } } diff --git a/crates/cherry-rs/src/views/cross_section.rs b/crates/cherry-rs/src/views/cross_section.rs index e5da619b..0cd50b3f 100644 --- a/crates/cherry-rs/src/views/cross_section.rs +++ b/crates/cherry-rs/src/views/cross_section.rs @@ -6,7 +6,10 @@ use crate::{ Float, math::vec3::Vec3, sequential_model::surface_placement::SurfacePlacement, surfaces::Surface, }, - views::{components::Component, ray_trace_3d::RayBundle}, + views::{ + components::{Component, PathComponent}, + ray_trace_3d::RayBundle, + }, }; /// Identifies a global transverse coordinate axis for cross-section projection. @@ -153,7 +156,7 @@ pub enum FlatPlaneKind { pub fn cross_section_view( model: &SequentialModel, cross_section_rays: Option<&[(usize, usize, RayBundle)]>, - components: &[Component], + components: &[PathComponent], ) -> CrossSectionView { let wavelengths = model.wavelengths().to_vec(); let placements = model.placements(); @@ -200,7 +203,7 @@ fn build_plane_geometry( model: &SequentialModel, cross_section_rays: Option<&[(usize, usize, RayBundle)]>, axis: GlobalAxis, - components: &[Component], + components: &[PathComponent], ) -> PlaneGeometry { let surfaces = model.surfaces(); let placements = model.placements(); @@ -210,7 +213,8 @@ fn build_plane_geometry( // Add lens groups and stops. Components are already sorted by first surface // index. - for comp in components { + for pc in components { + let comp = &pc.component; match comp { Component::Element { surf_idxs } => { let i = surf_idxs.first().copied().unwrap_or(0); @@ -674,7 +678,7 @@ mod tests { SequentialModel::from_surface_specs(&gaps, &surfs, &[0.5876], None).expect("build model") } - fn empty_components() -> Vec { + fn empty_components() -> Vec { Vec::new() } @@ -787,7 +791,7 @@ mod tests { let aperture = ApertureSpec::EntrancePupil { semi_diameter: 12.5, }; - let pv = ParaxialView::new(&model, &fields, false).unwrap(); + let pv = ParaxialView::new(&model, std::slice::from_ref(&fields), false).unwrap(); let rays = trace_ray_bundle( &aperture, &fields, @@ -826,7 +830,7 @@ mod tests { let aperture = ApertureSpec::EntrancePupil { semi_diameter: 12.5, }; - let pv = ParaxialView::new(&model, &fields, false).unwrap(); + let pv = ParaxialView::new(&model, std::slice::from_ref(&fields), false).unwrap(); let rays = trace_ray_bundle( &aperture, &fields, diff --git a/crates/cherry-rs/src/views/paraxial.rs b/crates/cherry-rs/src/views/paraxial.rs index 1c3c4e03..bf0b1a3f 100644 --- a/crates/cherry-rs/src/views/paraxial.rs +++ b/crates/cherry-rs/src/views/paraxial.rs @@ -105,7 +105,6 @@ type RayTransferMatrix = Mat2x2; pub struct ParaxialView { tangential_vecs: Vec, subviews: Vec, - wavelengths: Vec, } /// A description of a paraxial optical system. @@ -114,9 +113,21 @@ pub struct ParaxialView { #[derive(Debug)] #[cfg_attr(feature = "serde", derive(Serialize))] pub struct ParaxialViewDescription { - subviews: Vec, - /// Indexed by tangential_vec_id (index into the tangential-vector table). - primary_axial_color: Vec, + pub subviews: Vec, + pub primary_axial_color: Vec, +} + +/// The primary axial color aberration for one path and tangential direction. +/// +/// Tagged with `path_id`/`tangential_vec_id` because `tangential_vec_id` alone +/// can collide across paths (a rotationally symmetric path always collapses to +/// `tangential_vec_id == 0`). +#[derive(Debug, Clone, Copy, PartialEq)] +#[cfg_attr(feature = "serde", derive(Serialize))] +pub struct AxialColor { + pub path_id: usize, + pub tangential_vec_id: usize, + pub color: Float, } /// A paraxial subview of an optical system. @@ -128,6 +139,7 @@ pub struct ParaxialViewDescription { pub struct ParaxialSubView { path_id: usize, wavelength_id: usize, + wavelength: Float, tangential_vec_id: usize, is_obj_space_telecentric: bool, @@ -154,24 +166,24 @@ pub struct ParaxialSubView { #[derive(Debug)] #[cfg_attr(feature = "serde", derive(Serialize))] pub struct ParaxialSubViewDescription { - path_id: usize, - wavelength_id: usize, - tangential_vec_id: usize, - aperture_stop: usize, - back_focal_distance: Float, - back_principal_plane: Float, - chief_ray: ParaxialRayBundle, - effective_focal_length: Float, - entrance_pupil: Pupil, - exit_pupil: Pupil, - front_focal_distance: Float, - front_focal_length: Float, - front_principal_plane: Float, - image_space_fno: Float, - lagrange_invariants: Vec, - marginal_ray: ParaxialRayBundle, - paraxial_fno: Float, - paraxial_image_plane: ImagePlane, + pub path_id: usize, + pub wavelength_id: usize, + pub tangential_vec_id: usize, + pub aperture_stop: usize, + pub back_focal_distance: Float, + pub back_principal_plane: Float, + pub chief_ray: ParaxialRayBundle, + pub effective_focal_length: Float, + pub entrance_pupil: Pupil, + pub exit_pupil: Pupil, + pub front_focal_distance: Float, + pub front_focal_length: Float, + pub front_principal_plane: Float, + pub image_space_fno: Float, + pub lagrange_invariants: Vec, + pub marginal_ray: ParaxialRayBundle, + pub paraxial_fno: Float, + pub paraxial_image_plane: ImagePlane, } /// A paraxial entrance or exit pupil. @@ -272,8 +284,10 @@ impl ParaxialView { /// # Arguments /// * `sequential_model` - The sequential model to create a paraxial view /// of. - /// * `field_specs` - The field specs of the optical system. These are - /// necessary to compute parameters such as the chief ray. + /// * `field_specs_by_path` - One `FieldSpec` list per path in the model, + /// indexed by `path_id`. These are necessary to compute parameters such + /// as the chief ray. Must have length equal to + /// `sequential_model.path_count()`. /// * `is_obj_space_telecentric` - Whether the object space is telecentric. /// This forces the chief ray to be parallel to the optic axis. /// @@ -281,30 +295,49 @@ impl ParaxialView { /// A new ParaxialView. pub fn new( sequential_model: &SequentialModel, - field_specs: &[FieldSpec], + field_specs_by_path: &[Vec], is_obj_space_telecentric: bool, ) -> Result { + if field_specs_by_path.len() != sequential_model.path_count() { + return Err(anyhow!( + "field_specs_by_path has {} entries but the model has {} path(s)", + field_specs_by_path.len(), + sequential_model.path_count() + )); + } + let surfaces = sequential_model.surfaces(); let placements = sequential_model.placements(); - let tangential_vecs: Vec = if sequential_model.is_rotationally_symmetric() - { - vec![Vec3::new(0.0, 1.0, 0.0)] - } else { - unique_tangential_vecs(field_specs) - }; + let mut tangential_vecs: Vec = Vec::new(); let mut subviews = Vec::new(); - for path_id in 0..sequential_model.path_count() { + + for (path_id, field_specs) in field_specs_by_path.iter().enumerate() { + let path_tangential_vecs: Vec = + if sequential_model.is_rotationally_symmetric() { + vec![Vec3::new(0.0, 1.0, 0.0)] + } else { + unique_tangential_vecs(field_specs) + }; + // Append this path's own tangential vectors to the merged, + // whole-model table, remembering where they start. This is a + // single pass, not a post-hoc remap: the offset is computed once, + // inline, before any subview referencing it is constructed. + let tangential_vec_id_offset = tangential_vecs.len(); + tangential_vecs.extend(path_tangential_vecs.iter().copied()); + let stop_surface = sequential_model.stop_surface_for_path(path_id); let path_steps = sequential_model.path_steps(path_id); let surface_indices = sequential_model.path_surface_indices(path_id); let beam_splitter_arms = sequential_model.path_beam_splitter_arms(path_id); + let path_wavelengths = sequential_model.wavelengths_for_path(path_id); for (wav_idx, submodel) in sequential_model .submodels_for_path(path_id) .iter() .enumerate() { - for (v_idx, &v) in tangential_vecs.iter().enumerate() { + let wavelength = path_wavelengths[wav_idx]; + for (v_idx, &v) in path_tangential_vecs.iter().enumerate() { let data = SubModelData { sequential_sub_model: submodel as &dyn SequentialSubModel, surfaces, @@ -318,7 +351,8 @@ impl ParaxialView { let subview = ParaxialSubView::new( path_id, wav_idx, - v_idx, + wavelength, + tangential_vec_id_offset + v_idx, &data, v, is_obj_space_telecentric, @@ -331,7 +365,6 @@ impl ParaxialView { Ok(Self { tangential_vecs, subviews, - wavelengths: sequential_model.wavelengths().to_vec(), }) } @@ -403,56 +436,86 @@ impl ParaxialView { v.y().atan2(v.x()).to_degrees() } - /// Returns the tangential_vec_id whose tangential vector is closest (by dot - /// product) to the given azimuthal angle in radians. + /// Returns the tangential_vec_id, scoped to `path_id`, whose tangential + /// vector is closest (by dot product) to the given azimuthal angle in + /// radians. + /// + /// Scoping to `path_id` matters because each path's tangential vectors + /// occupy their own sub-range of the shared `tangential_vecs` table + /// (built incrementally in path order, see `ParaxialView::new`) — two + /// paths can share the same physical direction (e.g. both phi=90°) at + /// different ids, and a path-unaware search could return an id that + /// belongs to a different path's range, for which `get_for_path` would + /// then find nothing. /// /// For the common case where `phi_rad` exactly matches a stored phi key /// (bit-identical `tangential_fan_phi()` value), this finds the exact - /// entry. Falls back to index 0 if the table is empty. - pub fn tangential_vec_id_for_phi(&self, phi_rad: Float) -> usize { + /// entry. Falls back to index 0 if `path_id` has no tangential vectors. + pub fn tangential_vec_id_for_phi(&self, path_id: usize, phi_rad: Float) -> usize { let target: TangentialVector = Vec3::new(phi_rad.cos(), phi_rad.sin(), 0.0); - self.tangential_vecs + let mut path_ids: Vec = self + .subviews .iter() - .enumerate() + .filter(|sv| sv.path_id == path_id) + .map(|sv| sv.tangential_vec_id) + .collect(); + path_ids.sort_unstable(); + path_ids.dedup(); + + path_ids + .into_iter() + .map(|id| (id, self.tangential_vecs[id])) .max_by(|(_, a), (_, b)| { let da = a.x() * target.x() + a.y() * target.y(); let db = b.x() * target.x() + b.y() * target.y(); da.total_cmp(&db) }) - .map(|(i, _)| i) + .map(|(id, _)| id) .unwrap_or(0) } /// Computes the primary axial color aberration of the optical system. /// /// Primary axial color is the absolute difference in EFL between the - /// maximum and minimum wavelengths, reported per tangential-vector index. - pub fn primary_axial_color(&self) -> Vec { - let min_watangential_vec_id = self - .wavelengths - .iter() - .enumerate() - .min_by(|(_, a), (_, b)| a.total_cmp(b)) - .map(|(index, _)| index) - .unwrap_or_default(); - let max_watangential_vec_id = self - .wavelengths - .iter() - .enumerate() - .max_by(|(_, a), (_, b)| a.total_cmp(b)) - .map(|(index, _)| index) - .unwrap_or_default(); - - let mut primary_axial_color = vec![0.0; self.tangential_vecs.len()]; - for sv_min in self.get_by_wavelength_id(min_watangential_vec_id) { - if let Some(sv_max) = self.get(max_watangential_vec_id, sv_min.tangential_vec_id) { - let diff = - (sv_max.effective_focal_length() - sv_min.effective_focal_length()).abs(); - primary_axial_color[sv_min.tangential_vec_id] = diff; + /// maximum and minimum wavelengths, reported per `(path_id, + /// tangential_vec_id)` group — grouping by `tangential_vec_id` alone would + /// let two rotationally symmetric paths collide, since they both collapse + /// to `tangential_vec_id == 0`. + pub fn primary_axial_color(&self) -> Vec { + use std::collections::BTreeMap; + + type MinMaxWavelengthSubViews<'a> = + (Option<&'a ParaxialSubView>, Option<&'a ParaxialSubView>); + + let mut groups: BTreeMap<(usize, usize), MinMaxWavelengthSubViews<'_>> = BTreeMap::new(); + for sv in &self.subviews { + let entry = groups + .entry((sv.path_id, sv.tangential_vec_id)) + .or_insert((None, None)); + if entry.0.is_none_or(|cur| sv.wavelength < cur.wavelength) { + entry.0 = Some(sv); + } + if entry.1.is_none_or(|cur| sv.wavelength > cur.wavelength) { + entry.1 = Some(sv); } } - primary_axial_color + groups + .into_iter() + .map(|((path_id, tangential_vec_id), (sv_min, sv_max))| { + let color = match (sv_min, sv_max) { + (Some(a), Some(b)) => { + (b.effective_focal_length() - a.effective_focal_length()).abs() + } + _ => 0.0, + }; + AxialColor { + path_id, + tangential_vec_id, + color, + } + }) + .collect() } } @@ -480,6 +543,7 @@ impl ParaxialSubView { fn new( path_id: usize, wavelength_id: usize, + wavelength: Float, tangential_vec_id: usize, data: &SubModelData<'_>, v: TangentialVector, @@ -619,6 +683,7 @@ impl ParaxialSubView { Ok(Self { path_id, wavelength_id, + wavelength, tangential_vec_id, is_obj_space_telecentric, @@ -671,6 +736,10 @@ impl ParaxialSubView { self.wavelength_id } + pub fn wavelength(&self) -> Float { + self.wavelength + } + pub fn tangential_vec_id(&self) -> usize { self.tangential_vec_id } @@ -1659,6 +1728,7 @@ mod test { ParaxialSubView::new( 0, // path_id 0, + wavelengths[0], 0, &data, Vec3::new(0.0, 1.0, 0.0), // v = Y (phi=90°) @@ -1854,7 +1924,16 @@ mod test { stop_surface: None, }; - let view = ParaxialSubView::new(0, 0, 0, &data, Vec3::new(0.0, 1.0, 0.0), false).unwrap(); + let view = ParaxialSubView::new( + 0, + 0, + wavelengths[0], + 0, + &data, + Vec3::new(0.0, 1.0, 0.0), + false, + ) + .unwrap(); assert_eq!(*view.aperture_stop(), 2); } @@ -1913,7 +1992,7 @@ mod test { chi: 0.0, phi: 90.0, }]; - let pv = ParaxialView::new(&seq, &field, false).unwrap(); + let pv = ParaxialView::new(&seq, &[field], false).unwrap(); let sub = pv.get(0, 0).unwrap(); assert_eq!(*sub.aperture_stop(), 2); } @@ -1947,10 +2026,18 @@ mod test { chi: 0.0, phi: 90.0, }]; - let pv = ParaxialView::new(&model, &field, false).unwrap(); + let pv = ParaxialView::new(&model, &[field.clone(), field], false).unwrap(); - assert!(pv.get_for_path(0, 0, 0).is_some(), "path 0 subview missing"); - assert!(pv.get_for_path(1, 0, 0).is_some(), "path 1 subview missing"); + let tv0 = pv.tangential_vec_id_for_phi(0, std::f64::consts::FRAC_PI_2); + let tv1 = pv.tangential_vec_id_for_phi(1, std::f64::consts::FRAC_PI_2); + assert!( + pv.get_for_path(0, 0, tv0).is_some(), + "path 0 subview missing" + ); + assert!( + pv.get_for_path(1, 0, tv1).is_some(), + "path 1 subview missing" + ); } /// The aperture stop for each path must be computed from that path's own @@ -2037,7 +2124,7 @@ mod test { chi: 0.0, phi: 90.0, }]; - let pv = ParaxialView::new(&model, &field, false).unwrap(); + let pv = ParaxialView::new(&model, &[field.clone(), field], false).unwrap(); // Path 0: BS(SD=10) at step 1, Iris1(SD=5) at step 2. Iris1 is the stop. let path0 = pv.get_for_path(0, 0, 0).expect("path 0 subview"); @@ -2050,7 +2137,8 @@ mod test { // Path 1: BS(SD=10) at step 1, Iris2(SD=15) at step 2. BS is the stop. // Bug: if calc_aperture_stop uses store-indexed surfaces, it sees Iris1 // (SD=5) at step 2 position and incorrectly identifies step 2 as the stop. - let path1 = pv.get_for_path(1, 0, 0).expect("path 1 subview"); + let tv1 = pv.tangential_vec_id_for_phi(1, std::f64::consts::FRAC_PI_2); + let path1 = pv.get_for_path(1, 0, tv1).expect("path 1 subview"); assert_eq!( *path1.aperture_stop(), 1, @@ -2083,8 +2171,11 @@ mod test { assert_eq!(model.stop_surface_for_path(1), Some(3)); let field_specs = vec![FieldSpec::PointSource { x: 0.0, y: 1.5 }]; - let view = ParaxialView::new(&model, &field_specs, false).unwrap(); - let sub = view.get_for_path(1, 0, 0).expect("path 1 subview"); + let view = ParaxialView::new(&model, &[field_specs.clone(), field_specs], false).unwrap(); + let tangential_vec_id = view.tangential_vec_id_for_phi(1, std::f64::consts::FRAC_PI_2); + let sub = view + .get_for_path(1, 0, tangential_vec_id) + .expect("path 1 subview"); assert_eq!( *sub.aperture_stop(), @@ -2104,4 +2195,218 @@ mod test { assert_abs_diff_eq!(h, invariants[1], epsilon = 1e-4); } } + + /// AT-1: a two-path model called with a `field_specs_by_path` of the + /// wrong length (too short or too long) must return an error rather than + /// silently truncating or panicking. + #[test] + fn at1_field_specs_by_path_length_mismatch_is_rejected() { + use std::rc::Rc; + + use crate::examples::beam_splitter::two_path_model; + use crate::specs::gaps::ConstantRefractiveIndex; + + let n_air = Rc::new(ConstantRefractiveIndex::new(1.0, 0.0)); + let model = two_path_model(n_air, &[0.5876e-3], 10.0, 10.0); + let field = vec![FieldSpec::Angle { + chi: 0.0, + phi: 90.0, + }]; + + assert!(ParaxialView::new(&model, std::slice::from_ref(&field), false).is_err()); + assert!(ParaxialView::new(&model, &[field.clone(), field.clone(), field], false).is_err()); + } + + /// AT-3: giving each arm of a two-path model a different `FieldSpec` + /// height must produce subviews whose chief-ray data reflects that arm's + /// own field, not one value broadcast to both paths. + #[test] + fn at3_divergent_per_path_field_specs_produce_divergent_subview_data() { + use std::rc::Rc; + + use crate::examples::beam_splitter::two_path_model; + use crate::specs::gaps::ConstantRefractiveIndex; + + let n_air = Rc::new(ConstantRefractiveIndex::new(1.0, 0.0)); + let model = two_path_model(n_air, &[0.5876e-3], 10.0, 10.0); + + let field0 = vec![FieldSpec::Angle { + chi: 2.0, + phi: 90.0, + }]; + let field1 = vec![FieldSpec::Angle { + chi: 8.0, + phi: 90.0, + }]; + let pv = ParaxialView::new(&model, &[field0, field1], false).unwrap(); + + let tv0 = pv.tangential_vec_id_for_phi(0, std::f64::consts::FRAC_PI_2); + let tv1 = pv.tangential_vec_id_for_phi(1, std::f64::consts::FRAC_PI_2); + let sub0 = pv.get_for_path(0, 0, tv0).expect("path 0 subview"); + let sub1 = pv.get_for_path(1, 0, tv1).expect("path 1 subview"); + + let angle0 = sub0.chief_ray().rays_at_surface(0)[0].angle; + let angle1 = sub1.chief_ray().rays_at_surface(0)[0].angle; + + assert_abs_diff_eq!(angle0, 2.0_f64.to_radians().tan(), epsilon = 1e-6); + assert_abs_diff_eq!(angle1, 8.0_f64.to_radians().tan(), epsilon = 1e-6); + assert!( + (angle0 - angle1).abs() > 1e-3, + "each path's chief ray must reflect its own field, not a shared value" + ); + } + + /// A simple linearly dispersive index (`n = base + slope * wavelength`), + /// used only to give AT-7 a system where EFL genuinely varies with + /// wavelength — `ConstantRefractiveIndex` would make every path's primary + /// axial color trivially zero regardless of whether paths are mixed. + #[derive(Debug)] + struct LinearDispersion { + base: Float, + slope: Float, + } + + impl crate::RefractiveIndexSpec for LinearDispersion { + fn n(&self, wavelength: Float) -> Result { + Ok(self.base + self.slope * wavelength) + } + + fn k(&self, _wavelength: Float) -> Result { + Ok(0.0) + } + } + + /// AT-7: regression test for the pre-fix `primary_axial_color`, which + /// grouped subviews by `wavelength_id` alone (no path scoping) and then + /// diffed against a hardcoded path-0 lookup — silently wrong, and capable + /// of overwriting path 0's correct entry, for any multipath model. Uses a + /// two-path model with differing wavelength counts per path (same shape + /// as the wavelengths feature's own AT-6/AT-7 fixture) and a dispersive + /// index so each path's color is genuinely nonzero and path-specific. + #[test] + fn at7_primary_axial_color_does_not_mix_paths() { + use std::rc::Rc; + + use crate::RefractiveIndexSpec; + use crate::core::math::linalg::rotations::{EulerAngles, Rotation3D}; + use crate::core::sequential_model::builder::SequentialModelBuilder; + use crate::specs::gaps::GapSpec; + use crate::specs::paths::{PathSpec, PathSurfaceRef}; + use crate::specs::surfaces::{BeamSplitterPathKind, SurfaceSpec}; + + let n_air: Rc = Rc::new(LinearDispersion { + base: 1.0, + slope: 0.0, + }); + let n_glass: Rc = Rc::new(LinearDispersion { + base: 1.4, + slope: 0.2, + }); + let bs_rotation = + Rotation3D::IntrinsicPassiveRUF(EulerAngles((-45_f64).to_radians(), 0.0, 0.0)); + let img = || SurfaceSpec::Image { + rotation: Rotation3D::None, + decenter: Vec3::new(0.0, 0.0, 0.0), + rotation_offset: Rotation3D::None, + }; + let sphere = || SurfaceSpec::Sphere { + semi_diameter: 10.0, + radius_of_curvature: 50.0, + surf_kind: BoundaryKind::Refracting, + rotation: Rotation3D::None, + decenter: Vec3::new(0.0, 0.0, 0.0), + rotation_offset: Rotation3D::None, + }; + + let path0 = PathSpec { + surface_refs: vec![ + PathSurfaceRef::New(SurfaceSpec::Object), + PathSurfaceRef::New(SurfaceSpec::BeamSplitter { + semi_diameter: 10.0, + rotation: bs_rotation, + decenter: Vec3::new(0.0, 0.0, 0.0), + rotation_offset: Rotation3D::None, + }), + PathSurfaceRef::New(sphere()), + PathSurfaceRef::New(img()), + ], + gaps: vec![ + GapSpec { + thickness: Float::INFINITY, + refractive_index: n_air.clone(), + }, + GapSpec { + thickness: 10.0, + refractive_index: n_air.clone(), + }, + GapSpec { + thickness: 50.0, + refractive_index: n_glass.clone(), + }, + ], + beam_splitter_arms: vec![BeamSplitterPathKind::Transmitting], + stop_surface: None, + wavelengths: vec![0.55], // 1 wavelength: this path's own color must be 0 + }; + let path1 = PathSpec { + surface_refs: vec![ + PathSurfaceRef::Shared(0), + PathSurfaceRef::Shared(1), + PathSurfaceRef::New(sphere()), + PathSurfaceRef::New(img()), + ], + gaps: vec![ + GapSpec { + thickness: Float::INFINITY, + refractive_index: n_air.clone(), + }, + GapSpec { + thickness: 10.0, + refractive_index: n_air.clone(), + }, + GapSpec { + thickness: 50.0, + refractive_index: n_glass, + }, + ], + beam_splitter_arms: vec![BeamSplitterPathKind::Reflecting], + stop_surface: None, + wavelengths: vec![0.40, 0.55, 0.70], // 3 wavelengths: genuinely dispersive + }; + + let model = SequentialModelBuilder::new() + .paths(vec![path0, path1]) + .build() + .expect("build should succeed") + .model; + + let field = vec![FieldSpec::Angle { + chi: 0.0, + phi: 90.0, + }]; + let pv = ParaxialView::new(&model, &[field.clone(), field], false).unwrap(); + + let colors = pv.primary_axial_color(); + + let path0_color = colors + .iter() + .find(|ac| ac.path_id == 0) + .expect("path 0 axial color entry"); + let path1_color = colors + .iter() + .find(|ac| ac.path_id == 1) + .expect("path 1 axial color entry"); + + // Path 0 has exactly one wavelength, so its own min/max subview is the + // same subview: color must be exactly 0, regardless of path 1's data. + assert_abs_diff_eq!(path0_color.color, 0.0, epsilon = 1e-12); + // Path 1 has real dispersion across 3 wavelengths: its color must be + // nonzero, and computed purely from its own subviews (a cross-path + // mixing bug would either pull path 0's zero into this group or + // overwrite path 0's entry with a nonzero value from path 1). + assert!( + path1_color.color > 1e-6, + "path 1's dispersive color should be nonzero" + ); + } } diff --git a/crates/cherry-rs/src/views/ray_trace_3d/mod.rs b/crates/cherry-rs/src/views/ray_trace_3d/mod.rs index e7cc9a44..02cc7d0d 100644 --- a/crates/cherry-rs/src/views/ray_trace_3d/mod.rs +++ b/crates/cherry-rs/src/views/ray_trace_3d/mod.rs @@ -130,7 +130,7 @@ pub fn trace_ray_bundle( .submodel(wavelength_id) .ok_or_else(|| anyhow!("Submodel not found"))?; let tangential_vec_id = paraxial_view - .tangential_vec_id_for_phi(field_specs[field_id].tangential_fan_phi()); + .tangential_vec_id_for_phi(0, field_specs[field_id].tangential_fan_phi()); let paraxial_subview = paraxial_view .get(wavelength_id, tangential_vec_id) .ok_or_else(|| anyhow!("Submodel not found"))?; @@ -157,29 +157,49 @@ pub fn trace_ray_bundle( /// Perform a 3D ray trace on a sequential model. /// /// # Arguments -/// * `aperture_spec` - The aperture specification. -/// * `field_specs` - The field specifications. +/// * `aperture_specs_by_path` - One aperture specification per path, indexed by +/// `path_id`. Must have length equal to `sequential_model.path_count()`. +/// * `field_specs_by_path` - One field-spec list per path, indexed by +/// `path_id`. Must have length equal to `sequential_model.path_count()`. /// * `sequential_model` - The sequential model. /// * `paraxial_view` - A paraxial view. This is required for finding a system's /// entrance pupil. /// * `config` - Sampling configuration for all four ray bundles computed per /// field/wavelength combination. pub fn ray_trace_3d_view( - aperture_spec: &ApertureSpec, - field_specs: &[FieldSpec], + aperture_specs_by_path: &[ApertureSpec], + field_specs_by_path: &[Vec], sequential_model: &SequentialModel, paraxial_view: &ParaxialView, config: SamplingConfig, ) -> Result { - validate_field_specs(sequential_model, field_specs)?; + let path_count = sequential_model.path_count(); + if aperture_specs_by_path.len() != path_count { + return Err(anyhow!( + "aperture_specs_by_path has {} entries but the model has {} path(s)", + aperture_specs_by_path.len(), + path_count + )); + } + if field_specs_by_path.len() != path_count { + return Err(anyhow!( + "field_specs_by_path has {} entries but the model has {} path(s)", + field_specs_by_path.len(), + path_count + )); + } + for field_specs in field_specs_by_path { + validate_field_specs(sequential_model, field_specs)?; + } // Use Axis::U as the canonical submodel for all field/wavelength combinations. // For rotationally symmetric systems only Axis::U exists; for non-symmetric // systems this is the fallback that is always present. - let triples: Vec<(usize, usize, usize)> = (0..sequential_model.path_count()) + let triples: Vec<(usize, usize, usize)> = (0..path_count) .flat_map(|p| { let n_wavelengths = sequential_model.wavelengths_for_path(p).len(); - (0..field_specs.len()).flat_map(move |f| (0..n_wavelengths).map(move |w| (p, f, w))) + let n_fields = field_specs_by_path[p].len(); + (0..n_fields).flat_map(move |f| (0..n_wavelengths).map(move |w| (p, f, w))) }) .collect(); @@ -194,12 +214,15 @@ pub fn ray_trace_3d_view( wavelength_id, ); + let field_specs = &field_specs_by_path[path_id]; + let aperture_spec = &aperture_specs_by_path[path_id]; + let sequential_submodel = sequential_model .submodels_for_path(path_id) .get(wavelength_id) .ok_or_else(|| anyhow!("Submodel not found"))?; let tangential_vec_id = paraxial_view - .tangential_vec_id_for_phi(field_specs[field_id].tangential_fan_phi()); + .tangential_vec_id_for_phi(path_id, field_specs[field_id].tangential_fan_phi()); let paraxial_subview = paraxial_view .get_for_path(path_id, wavelength_id, tangential_vec_id) .ok_or_else(|| anyhow!("Paraxial subview not found"))?; @@ -918,7 +941,9 @@ mod tests { }, ]; - let paraxial_view = ParaxialView::new(&sequential_model, &field_specs, false).unwrap(); + let paraxial_view = + ParaxialView::new(&sequential_model, std::slice::from_ref(&field_specs), false) + .unwrap(); Setup { sequential_model, @@ -963,8 +988,8 @@ mod tests { full_pupil_spacing: 0.1, }; let results = ray_trace_3d_view( - &s.aperture_spec, - &s.field_specs, + &[s.aperture_spec], + std::slice::from_ref(&s.field_specs), &s.sequential_model, &s.paraxial_view, config, @@ -983,8 +1008,8 @@ mod tests { }; let results = ray_trace_3d_view( - &s.aperture_spec, - &s.field_specs, + &[s.aperture_spec], + std::slice::from_ref(&s.field_specs), &s.sequential_model, &s.paraxial_view, config, @@ -1037,7 +1062,8 @@ mod tests { chi: 5.0, phi: 90.0, }]; - let paraxial_view = ParaxialView::new(&seq_model, &field_specs, false).unwrap(); + let paraxial_view = + ParaxialView::new(&seq_model, std::slice::from_ref(&field_specs), false).unwrap(); let fan_rays = rays( seq_model.placements(), @@ -1083,7 +1109,7 @@ mod tests { chi: 5.0, phi: 90.0, }; - let paraxial_view = ParaxialView::new(&seq_model, &[field_spec], false).unwrap(); + let paraxial_view = ParaxialView::new(&seq_model, &[vec![field_spec]], false).unwrap(); let chief = rays( seq_model.placements(), @@ -1360,15 +1386,17 @@ mod tests { phi: 90.0, }, ]; - let paraxial_view = ParaxialView::new(&sequential_model, &field_specs, false).unwrap(); + let paraxial_view = + ParaxialView::new(&sequential_model, std::slice::from_ref(&field_specs), false) + .unwrap(); let config = SamplingConfig { n_fan_rays: 3, full_pupil_spacing: 0.1, }; let results = ray_trace_3d_view( - &aperture_spec, - &field_specs, + &[aperture_spec], + std::slice::from_ref(&field_specs), &sequential_model, ¶xial_view, config, @@ -1428,15 +1456,21 @@ mod tests { phi: 90.0, }]; let aperture_spec = ApertureSpec::EntrancePupil { semi_diameter: 5.0 }; - let paraxial_view = ParaxialView::new(&model, &field_specs, false).unwrap(); + let paraxial_view = + ParaxialView::new(&model, &[field_specs.clone(), field_specs.clone()], false).unwrap(); let config = SamplingConfig { n_fan_rays: 3, full_pupil_spacing: 0.5, }; - let results = - ray_trace_3d_view(&aperture_spec, &field_specs, &model, ¶xial_view, config) - .unwrap(); + let results = ray_trace_3d_view( + &[aperture_spec, aperture_spec], + &[field_specs.clone(), field_specs], + &model, + ¶xial_view, + config, + ) + .unwrap(); assert!( results.get_for_path(0, 0, 0).is_some(), @@ -1523,15 +1557,21 @@ mod tests { phi: 90.0, }]; let aperture_spec = ApertureSpec::EntrancePupil { semi_diameter: 5.0 }; - let paraxial_view = ParaxialView::new(&model, &field_specs, false).unwrap(); + let paraxial_view = + ParaxialView::new(&model, &[field_specs.clone(), field_specs.clone()], false).unwrap(); let config = SamplingConfig { n_fan_rays: 3, full_pupil_spacing: 0.5, }; - let results = - ray_trace_3d_view(&aperture_spec, &field_specs, &model, ¶xial_view, config) - .expect("ray trace should succeed with differing per-path wavelength counts"); + let results = ray_trace_3d_view( + &[aperture_spec, aperture_spec], + &[field_specs.clone(), field_specs.clone()], + &model, + ¶xial_view, + config, + ) + .expect("ray trace should succeed with differing per-path wavelength counts"); assert_eq!( results.iter().filter(|r| r.path_id() == 0).count(), @@ -1553,8 +1593,8 @@ mod tests { full_pupil_spacing: 0.1, }; let results = ray_trace_3d_view( - &s.aperture_spec, - &s.field_specs, + &[s.aperture_spec], + std::slice::from_ref(&s.field_specs), &s.sequential_model, &s.paraxial_view, config, @@ -1569,4 +1609,110 @@ mod tests { "On-axis chief ray should reach the image surface" ); } + + /// AT-2: a two-path model called with mismatched-length + /// `aperture_specs_by_path`/`field_specs_by_path` must return an error, + /// independently for each list. + #[test] + fn at2_ray_trace_3d_view_rejects_mismatched_per_path_list_lengths() { + use std::rc::Rc; + + use crate::examples::beam_splitter::two_path_model; + use crate::specs::gaps::ConstantRefractiveIndex; + + let n_air = Rc::new(ConstantRefractiveIndex::new(1.0, 0.0)); + let model = two_path_model(n_air, &[0.5876e-3], 10.0, 10.0); + let field = vec![FieldSpec::Angle { + chi: 0.0, + phi: 90.0, + }]; + let aperture = ApertureSpec::EntrancePupil { semi_diameter: 5.0 }; + let paraxial_view = + ParaxialView::new(&model, &[field.clone(), field.clone()], false).unwrap(); + let config = SamplingConfig { + n_fan_rays: 3, + full_pupil_spacing: 0.5, + }; + + // Wrong-length aperture_specs_by_path (1 entry for a 2-path model). + assert!( + ray_trace_3d_view( + &[aperture], + &[field.clone(), field.clone()], + &model, + ¶xial_view, + config, + ) + .is_err() + ); + + // Wrong-length field_specs_by_path (1 entry for a 2-path model). + assert!( + ray_trace_3d_view( + &[aperture, aperture], + &[field], + &model, + ¶xial_view, + config, + ) + .is_err() + ); + } + + /// AT-4: each path must be traced against its own `ApertureSpec`, not a + /// shared value. Two paths with different entrance-pupil semi-diameters + /// must produce `TraceResults` whose full-pupil ray bundle extents + /// differ correspondingly. + #[test] + fn at4_ray_trace_3d_view_traces_each_path_against_its_own_aperture() { + use std::rc::Rc; + + use crate::examples::beam_splitter::two_path_model; + use crate::specs::gaps::ConstantRefractiveIndex; + + let n_air = Rc::new(ConstantRefractiveIndex::new(1.0, 0.0)); + let model = two_path_model(n_air, &[0.5876e-3], 10.0, 10.0); + let field = vec![FieldSpec::Angle { + chi: 0.0, + phi: 90.0, + }]; + let small_aperture = ApertureSpec::EntrancePupil { semi_diameter: 2.0 }; + let large_aperture = ApertureSpec::EntrancePupil { + semi_diameter: 10.0, + }; + let paraxial_view = + ParaxialView::new(&model, &[field.clone(), field.clone()], false).unwrap(); + let config = SamplingConfig { + n_fan_rays: 3, + full_pupil_spacing: 0.5, + }; + + let results = ray_trace_3d_view( + &[small_aperture, large_aperture], + &[field.clone(), field], + &model, + ¶xial_view, + config, + ) + .unwrap(); + + let extent = |bundle: &RayBundle| -> Float { + bundle + .rays() + .iter() + .map(|r| r.y().abs()) + .fold(0.0, Float::max) + }; + + let path0 = results.get_for_path(0, 0, 0).expect("path 0 results"); + let path1 = results.get_for_path(1, 0, 0).expect("path 1 results"); + + let extent0 = extent(path0.full_pupil()); + let extent1 = extent(path1.full_pupil()); + + assert!( + extent1 > extent0, + "path 1 (larger aperture) should have greater ray extent than path 0: {extent0} vs {extent1}" + ); + } } diff --git a/crates/cherry-rs/tests/biconvex_lens_finite_object.rs b/crates/cherry-rs/tests/biconvex_lens_finite_object.rs index 66889085..faa5d75a 100644 --- a/crates/cherry-rs/tests/biconvex_lens_finite_object.rs +++ b/crates/cherry-rs/tests/biconvex_lens_finite_object.rs @@ -67,8 +67,8 @@ fn assert_ray_results_approx_eq(actual: &ParaxialRayBundle, expected: &[(f64, f6 #[test] fn test_paraxial_view_aperture_stop() { let model = sequential_model(n!(1.0), n!(1.517), &WAVELENGTHS); - let view = - ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false) + .expect("Could not create paraxial view"); for sub_view in view.iter() { let result = sub_view.aperture_stop(); @@ -80,8 +80,8 @@ fn test_paraxial_view_aperture_stop() { #[test] fn test_paraxial_view_back_focal_distance() { let model = sequential_model(n!(1.0), n!(1.517), &WAVELENGTHS); - let view = - ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false) + .expect("Could not create paraxial view"); for sub_view in view.iter() { let result = sub_view.back_focal_distance(); @@ -93,8 +93,8 @@ fn test_paraxial_view_back_focal_distance() { #[test] fn test_paraxial_view_back_principal_plane() { let model = sequential_model(n!(1.0), n!(1.517), &WAVELENGTHS); - let view = - ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false) + .expect("Could not create paraxial view"); for sub_view in view.iter() { let result = sub_view.back_principal_plane(); @@ -106,8 +106,8 @@ fn test_paraxial_view_back_principal_plane() { #[test] fn test_paraxial_view_entrance_pupil() { let model = sequential_model(n!(1.0), n!(1.517), &WAVELENGTHS); - let view = - ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false) + .expect("Could not create paraxial view"); for sub_view in view.iter() { let result = sub_view.entrance_pupil(); @@ -119,8 +119,8 @@ fn test_paraxial_view_entrance_pupil() { #[test] fn test_paraxial_view_exit_pupil() { let model = sequential_model(n!(1.0), n!(1.517), &WAVELENGTHS); - let view = - ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false) + .expect("Could not create paraxial view"); for sub_view in view.iter() { let result = sub_view.exit_pupil(); @@ -137,8 +137,8 @@ fn test_paraxial_view_exit_pupil() { #[test] fn test_paraxial_view_effective_focal_length() { let model = sequential_model(n!(1.0), n!(1.517), &WAVELENGTHS); - let view = - ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false) + .expect("Could not create paraxial view"); for sub_view in view.iter() { let result = sub_view.effective_focal_length(); @@ -150,8 +150,8 @@ fn test_paraxial_view_effective_focal_length() { #[test] fn test_paraxial_view_front_focal_distance() { let model = sequential_model(n!(1.0), n!(1.517), &WAVELENGTHS); - let view = - ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false) + .expect("Could not create paraxial view"); for sub_view in view.iter() { let result = sub_view.front_focal_distance(); @@ -163,8 +163,8 @@ fn test_paraxial_view_front_focal_distance() { #[test] fn test_paraxial_view_front_focal_length() { let model = sequential_model(n!(1.0), n!(1.517), &WAVELENGTHS); - let view = - ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false) + .expect("Could not create paraxial view"); for sub_view in view.iter() { let result = sub_view.front_focal_length(); @@ -176,8 +176,8 @@ fn test_paraxial_view_front_focal_length() { #[test] fn test_paraxial_view_front_principal_plane() { let model = sequential_model(n!(1.0), n!(1.517), &WAVELENGTHS); - let view = - ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false) + .expect("Could not create paraxial view"); for sub_view in view.iter() { let result = sub_view.front_principal_plane(); @@ -189,8 +189,8 @@ fn test_paraxial_view_front_principal_plane() { #[test] fn test_paraxial_view_image_plane() { let model = sequential_model(n!(1.0), n!(1.517), &WAVELENGTHS); - let view = - ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false) + .expect("Could not create paraxial view"); for sub_view in view.iter() { let result = sub_view.paraxial_image_plane(); @@ -211,8 +211,8 @@ fn test_paraxial_view_image_plane() { #[test] fn test_paraxial_view_marginal_ray() { let model = sequential_model(n!(1.0), n!(1.517), &WAVELENGTHS); - let view = - ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false) + .expect("Could not create paraxial view"); for sub_view in view.iter() { assert_ray_results_approx_eq(sub_view.marginal_ray(), &marginal_ray_expected(), 1e-4); @@ -222,8 +222,8 @@ fn test_paraxial_view_marginal_ray() { #[test] fn test_paraxial_view_chief_ray() { let model = sequential_model(n!(1.0), n!(1.517), &WAVELENGTHS); - let view = - ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false) + .expect("Could not create paraxial view"); for sub_view in view.iter() { assert_ray_results_approx_eq(sub_view.chief_ray(), &chief_ray_expected(), 1e-4); diff --git a/crates/cherry-rs/tests/concave_mirror.rs b/crates/cherry-rs/tests/concave_mirror.rs index 6bbfdd37..2eebbb7e 100644 --- a/crates/cherry-rs/tests/concave_mirror.rs +++ b/crates/cherry-rs/tests/concave_mirror.rs @@ -62,8 +62,8 @@ fn assert_ray_results_approx_eq(actual: &ParaxialRayBundle, expected: &[(f64, f6 #[test] fn concave_mirror_paraxial_chief_ray() { let model = sequential_model(n!(1.0), &WAVELENGTHS); - let view = - ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false) + .expect("Could not create paraxial view"); for sub_view in view.iter() { assert_ray_results_approx_eq(sub_view.chief_ray(), &chief_ray_expected(), 1e-4); @@ -73,8 +73,8 @@ fn concave_mirror_paraxial_chief_ray() { #[test] fn concave_mirror_paraxial_aperture_stop() { let model = sequential_model(n!(1.0), &WAVELENGTHS); - let view = - ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false) + .expect("Could not create paraxial view"); for sub_view in view.iter() { let result = sub_view.aperture_stop(); @@ -86,8 +86,8 @@ fn concave_mirror_paraxial_aperture_stop() { #[test] fn concave_mirror_paraxial_back_focal_distance() { let model = sequential_model(n!(1.0), &WAVELENGTHS); - let view = - ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false) + .expect("Could not create paraxial view"); for sub_view in view.iter() { let result = sub_view.back_focal_distance(); @@ -99,8 +99,8 @@ fn concave_mirror_paraxial_back_focal_distance() { #[test] fn concave_mirror_paraxial_back_principal_plane() { let model = sequential_model(n!(1.0), &WAVELENGTHS); - let view = - ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false) + .expect("Could not create paraxial view"); for sub_view in view.iter() { let result = sub_view.back_principal_plane(); @@ -112,8 +112,8 @@ fn concave_mirror_paraxial_back_principal_plane() { #[test] fn concave_mirror_paraxial_entrance_pupil() { let model = sequential_model(n!(1.0), &WAVELENGTHS); - let view = - ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false) + .expect("Could not create paraxial view"); for sub_view in view.iter() { let result = sub_view.entrance_pupil(); @@ -125,8 +125,8 @@ fn concave_mirror_paraxial_entrance_pupil() { #[test] fn concave_mirror_paraxial_exit_pupil() { let model = sequential_model(n!(1.0), &WAVELENGTHS); - let view = - ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false) + .expect("Could not create paraxial view"); for sub_view in view.iter() { let result = sub_view.exit_pupil(); @@ -143,8 +143,8 @@ fn concave_mirror_paraxial_exit_pupil() { #[test] fn concave_mirror_paraxial_effective_focal_length() { let model = sequential_model(n!(1.0), &WAVELENGTHS); - let view = - ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false) + .expect("Could not create paraxial view"); for sub_view in view.iter() { let result = sub_view.effective_focal_length(); @@ -156,8 +156,8 @@ fn concave_mirror_paraxial_effective_focal_length() { #[test] fn concave_mirror_paraxial_front_focal_distance() { let model = sequential_model(n!(1.0), &WAVELENGTHS); - let view = - ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false) + .expect("Could not create paraxial view"); for sub_view in view.iter() { let result = sub_view.front_focal_distance(); @@ -169,8 +169,8 @@ fn concave_mirror_paraxial_front_focal_distance() { #[test] fn concave_mirror_paraxial_front_principal_plane() { let model = sequential_model(n!(1.0), &WAVELENGTHS); - let view = - ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false) + .expect("Could not create paraxial view"); for sub_view in view.iter() { let result = sub_view.front_principal_plane(); @@ -182,8 +182,8 @@ fn concave_mirror_paraxial_front_principal_plane() { #[test] fn concave_mirror_paraxial_image_plane() { let model = sequential_model(n!(1.0), &WAVELENGTHS); - let view = - ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false) + .expect("Could not create paraxial view"); for sub_view in view.iter() { let result = sub_view.paraxial_image_plane(); @@ -204,8 +204,8 @@ fn concave_mirror_paraxial_image_plane() { #[test] fn concave_mirror_paraxial_marginal_ray() { let model = sequential_model(n!(1.0), &WAVELENGTHS); - let view = - ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false) + .expect("Could not create paraxial view"); for sub_view in view.iter() { assert_ray_results_approx_eq(sub_view.marginal_ray(), &marginal_ray_expected(), 1e-4); diff --git a/crates/cherry-rs/tests/convexplano_lens_materials.rs b/crates/cherry-rs/tests/convexplano_lens_materials.rs index 4077e454..eb6199e9 100644 --- a/crates/cherry-rs/tests/convexplano_lens_materials.rs +++ b/crates/cherry-rs/tests/convexplano_lens_materials.rs @@ -51,13 +51,13 @@ mod test_ri_info { let nbk7 = Rc::new(store.remove("popular_glass:BK7:SCHOTT").unwrap()); let model = sequential_model(air, nbk7, &WAVELENGTHS); - let view = - ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false) + .expect("Could not create paraxial view"); // For a single phi=90° field there is one tangential direction // (tangential_vec_id=0). let results = view.primary_axial_color(); assert_eq!(results.len(), 1); - assert_abs_diff_eq!(results[0], 0.7743, epsilon = 1e-4); + assert_abs_diff_eq!(results[0].color, 0.7743, epsilon = 1e-4); } } diff --git a/crates/cherry-rs/tests/convexplano_lens_ri.rs b/crates/cherry-rs/tests/convexplano_lens_ri.rs index c6249a01..f87f67e9 100644 --- a/crates/cherry-rs/tests/convexplano_lens_ri.rs +++ b/crates/cherry-rs/tests/convexplano_lens_ri.rs @@ -72,8 +72,8 @@ fn assert_ray_results_approx_eq(actual: &ParaxialRayBundle, expected: &[(f64, f6 #[test] fn convexplano_lens_ri_paraxial_chief_ray() { let model = sequential_model(n!(1.0), n!(1.515), &WAVELENGTHS); - let view = - ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false) + .expect("Could not create paraxial view"); for sub_view in view.iter() { assert_ray_results_approx_eq(sub_view.chief_ray(), &chief_ray_expected(), 1e-4); @@ -83,8 +83,8 @@ fn convexplano_lens_ri_paraxial_chief_ray() { #[test] fn convexplano_lens_ri_paraxial_aperture_stop() { let model = sequential_model(n!(1.0), n!(1.515), &WAVELENGTHS); - let view = - ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false) + .expect("Could not create paraxial view"); for sub_view in view.iter() { let result = sub_view.aperture_stop(); @@ -96,8 +96,8 @@ fn convexplano_lens_ri_paraxial_aperture_stop() { #[test] fn convexplano_lens_ri_paraxial_back_focal_distance() { let model = sequential_model(n!(1.0), n!(1.515), &WAVELENGTHS); - let view = - ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false) + .expect("Could not create paraxial view"); for sub_view in view.iter() { let result = sub_view.back_focal_distance(); @@ -109,8 +109,8 @@ fn convexplano_lens_ri_paraxial_back_focal_distance() { #[test] fn convexplano_lens_ri_paraxial_back_principal_plane() { let model = sequential_model(n!(1.0), n!(1.515), &WAVELENGTHS); - let view = - ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false) + .expect("Could not create paraxial view"); for sub_view in view.iter() { let result = sub_view.back_principal_plane(); @@ -122,8 +122,8 @@ fn convexplano_lens_ri_paraxial_back_principal_plane() { #[test] fn convexplano_lens_ri_paraxial_entrance_pupil() { let model = sequential_model(n!(1.0), n!(1.515), &WAVELENGTHS); - let view = - ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false) + .expect("Could not create paraxial view"); for sub_view in view.iter() { let result = sub_view.entrance_pupil(); @@ -135,8 +135,8 @@ fn convexplano_lens_ri_paraxial_entrance_pupil() { #[test] fn convexplano_lens_ri_paraxial_exit_pupil() { let model = sequential_model(n!(1.0), n!(1.515), &WAVELENGTHS); - let view = - ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false) + .expect("Could not create paraxial view"); for sub_view in view.iter() { let result = sub_view.exit_pupil(); @@ -153,8 +153,8 @@ fn convexplano_lens_ri_paraxial_exit_pupil() { #[test] fn convexplano_lens_ri_paraxial_effective_focal_length() { let model = sequential_model(n!(1.0), n!(1.515), &WAVELENGTHS); - let view = - ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false) + .expect("Could not create paraxial view"); for sub_view in view.iter() { let result = sub_view.effective_focal_length(); @@ -166,8 +166,8 @@ fn convexplano_lens_ri_paraxial_effective_focal_length() { #[test] fn convexplano_lens_ri_paraxial_front_focal_distance() { let model = sequential_model(n!(1.0), n!(1.515), &WAVELENGTHS); - let view = - ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false) + .expect("Could not create paraxial view"); for sub_view in view.iter() { let result = sub_view.front_focal_distance(); @@ -179,8 +179,8 @@ fn convexplano_lens_ri_paraxial_front_focal_distance() { #[test] fn convexplano_lens_ri_paraxial_front_principal_plane() { let model = sequential_model(n!(1.0), n!(1.515), &WAVELENGTHS); - let view = - ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false) + .expect("Could not create paraxial view"); for sub_view in view.iter() { let result = sub_view.front_principal_plane(); @@ -192,8 +192,8 @@ fn convexplano_lens_ri_paraxial_front_principal_plane() { #[test] fn convexplano_lens_ri_paraxial_image_plane() { let model = sequential_model(n!(1.0), n!(1.515), &WAVELENGTHS); - let view = - ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false) + .expect("Could not create paraxial view"); for sub_view in view.iter() { let result = sub_view.paraxial_image_plane(); @@ -214,8 +214,8 @@ fn convexplano_lens_ri_paraxial_image_plane() { #[test] fn convexplano_lens_ri_paraxial_marginal_ray() { let model = sequential_model(n!(1.0), n!(1.515), &WAVELENGTHS); - let view = - ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false) + .expect("Could not create paraxial view"); for sub_view in view.iter() { assert_ray_results_approx_eq(sub_view.marginal_ray(), &marginal_ray_expected(), 1e-4); diff --git a/crates/cherry-rs/tests/f_theta_scan_lens.rs b/crates/cherry-rs/tests/f_theta_scan_lens.rs index c1e9cf03..22a197a1 100644 --- a/crates/cherry-rs/tests/f_theta_scan_lens.rs +++ b/crates/cherry-rs/tests/f_theta_scan_lens.rs @@ -20,8 +20,8 @@ fn setup() -> ( let model = sequential_model(n!(1.0), n!(1.84666), &WAVELENGTHS); let aperture_spec = ApertureSpec::EntrancePupil { semi_diameter: 0.5 }; let field_specs = field_specs(); - let paraxial_view = - ParaxialView::new(&model, &field_specs, false).expect("Could not create paraxial view"); + let paraxial_view = ParaxialView::new(&model, std::slice::from_ref(&field_specs), false) + .expect("Could not create paraxial view"); (model, aperture_spec, field_specs, paraxial_view) } @@ -31,8 +31,8 @@ fn test_ray_trace_3d_on_axis() { let (model, aperture_spec, field_specs, paraxial_view) = setup(); let results = ray_trace_3d_view( - &aperture_spec, - &field_specs, + &[aperture_spec], + &[field_specs], &model, ¶xial_view, SamplingConfig { @@ -66,9 +66,10 @@ fn test_ray_trace_3d_off_axis() { }, ]; + let n_off_axis_fields = off_axis_fields.len(); let results = ray_trace_3d_view( - &aperture_spec, - &off_axis_fields, + &[aperture_spec], + &[off_axis_fields], &model, ¶xial_view, SamplingConfig { @@ -79,7 +80,7 @@ fn test_ray_trace_3d_off_axis() { ) .expect("Ray trace failed"); - assert_eq!(results.len(), off_axis_fields.len()); + assert_eq!(results.len(), n_off_axis_fields); } #[test] @@ -93,8 +94,8 @@ fn test_ray_trace_3d_square_grid() { }]; let results = ray_trace_3d_view( - &aperture_spec, - &fields, + &[aperture_spec], + &[fields], &model, ¶xial_view, SamplingConfig { diff --git a/crates/cherry-rs/tests/galvo_mirror.rs b/crates/cherry-rs/tests/galvo_mirror.rs index f4798519..d0505193 100644 --- a/crates/cherry-rs/tests/galvo_mirror.rs +++ b/crates/cherry-rs/tests/galvo_mirror.rs @@ -14,7 +14,7 @@ const FIELD_SPECS: [FieldSpec; 1] = [FieldSpec::Angle { #[test] fn galvo_mirror_efl_is_infinite() { let model = galvo_mirror::sequential_model(n!(1.0), &WAVELENGTHS); - let view = ParaxialView::new(&model, &FIELD_SPECS, false).expect("paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false).expect("paraxial view"); for sub_view in view.iter() { assert!( sub_view.effective_focal_length().is_infinite(), @@ -27,7 +27,7 @@ fn galvo_mirror_efl_is_infinite() { #[test] fn galvo_mirror_bfd_is_infinite() { let model = galvo_mirror::sequential_model(n!(1.0), &WAVELENGTHS); - let view = ParaxialView::new(&model, &FIELD_SPECS, false).expect("paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false).expect("paraxial view"); for sub_view in view.iter() { assert!( sub_view.back_focal_distance().is_infinite(), @@ -40,7 +40,7 @@ fn galvo_mirror_bfd_is_infinite() { #[test] fn galvo_mirror_marginal_ray_height_unchanged() { let model = galvo_mirror::sequential_model(n!(1.0), &WAVELENGTHS); - let view = ParaxialView::new(&model, &FIELD_SPECS, false).expect("paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false).expect("paraxial view"); for sub_view in view.iter() { let marginal = sub_view.marginal_ray(); let h_at_mirror = marginal.rays_at_surface(1)[0].height; diff --git a/crates/cherry-rs/tests/mirrors_figure_z.rs b/crates/cherry-rs/tests/mirrors_figure_z.rs index 78c1231e..c7e96d08 100644 --- a/crates/cherry-rs/tests/mirrors_figure_z.rs +++ b/crates/cherry-rs/tests/mirrors_figure_z.rs @@ -44,7 +44,7 @@ fn track_equals_z_for_straight_system() { #[test] fn mirrors_figure_z_paraxial_aperture_stop() { let model = mirrors_figure_z::sequential_model(n!(1.0), &WAVELENGTHS); - let view = ParaxialView::new(&model, &FIELD_SPECS, false).expect("paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false).expect("paraxial view"); for sub_view in view.iter() { assert_eq!(*sub_view.aperture_stop(), APERTURE_STOP); } @@ -53,7 +53,7 @@ fn mirrors_figure_z_paraxial_aperture_stop() { #[test] fn mirrors_figure_z_paraxial_exit_pupil() { let model = mirrors_figure_z::sequential_model(n!(1.0), &WAVELENGTHS); - let view = ParaxialView::new(&model, &FIELD_SPECS, false).expect("paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false).expect("paraxial view"); for sub_view in view.iter() { assert_abs_diff_eq!( sub_view.exit_pupil().location, @@ -71,7 +71,7 @@ fn mirrors_figure_z_paraxial_exit_pupil() { #[test] fn mirrors_figure_z_marginal_ray_uses_projected_sd() { let model = mirrors_figure_z::sequential_model(n!(1.0), &WAVELENGTHS); - let view = ParaxialView::new(&model, &FIELD_SPECS, false).expect("paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false).expect("paraxial view"); let r = 12.7_f64; let projected_u = r * (30.0_f64.to_radians()).cos(); @@ -91,8 +91,8 @@ fn entrance_pupil_sd_phi_90_foreshortened() { phi: 90.0, }]; let model = mirrors_figure_z::sequential_model(n!(1.0), &WAVELENGTHS); - let view = ParaxialView::new(&model, &field_specs, false).expect("paraxial view"); - let tangential_vec_id = view.tangential_vec_id_for_phi(FRAC_PI_2); + let view = ParaxialView::new(&model, &[field_specs.to_vec()], false).expect("paraxial view"); + let tangential_vec_id = view.tangential_vec_id_for_phi(0, FRAC_PI_2); let ep = view.get(0, tangential_vec_id).unwrap().entrance_pupil(); assert_abs_diff_eq!(ep.semi_diameter, ENTRANCE_PUPIL_SD_U, epsilon = 1e-4); } @@ -103,8 +103,8 @@ fn entrance_pupil_sd_phi_90_foreshortened() { fn entrance_pupil_sd_phi_0_not_foreshortened() { let field_specs = [FieldSpec::Angle { chi: 0.0, phi: 0.0 }]; let model = mirrors_figure_z::sequential_model(n!(1.0), &WAVELENGTHS); - let view = ParaxialView::new(&model, &field_specs, false).expect("paraxial view"); - let tangential_vec_id = view.tangential_vec_id_for_phi(0.0); + let view = ParaxialView::new(&model, &[field_specs.to_vec()], false).expect("paraxial view"); + let tangential_vec_id = view.tangential_vec_id_for_phi(0, 0.0); let ep = view.get(0, tangential_vec_id).unwrap().entrance_pupil(); assert_abs_diff_eq!(ep.semi_diameter, ENTRANCE_PUPIL_SD_R, epsilon = 1e-4); } @@ -122,10 +122,10 @@ fn chief_ray_uses_matching_field_phi() { FieldSpec::Angle { chi: 3.0, phi: 0.0 }, ]; let model = mirrors_figure_z::sequential_model(n!(1.0), &WAVELENGTHS); - let view = ParaxialView::new(&model, &field_specs, false).expect("paraxial view"); + let view = ParaxialView::new(&model, &[field_specs.to_vec()], false).expect("paraxial view"); - let v_phi90 = view.tangential_vec_id_for_phi(FRAC_PI_2); - let v_phi0 = view.tangential_vec_id_for_phi(0.0); + let v_phi90 = view.tangential_vec_id_for_phi(0, FRAC_PI_2); + let v_phi0 = view.tangential_vec_id_for_phi(0, 0.0); let angle_phi90 = view.get(0, v_phi90).unwrap().chief_ray().rays_at_surface(0)[0].angle; let angle_phi0 = view.get(0, v_phi0).unwrap().chief_ray().rays_at_surface(0)[0].angle; diff --git a/crates/cherry-rs/tests/petzval_lens.rs b/crates/cherry-rs/tests/petzval_lens.rs index 3335be25..feb3d7bf 100644 --- a/crates/cherry-rs/tests/petzval_lens.rs +++ b/crates/cherry-rs/tests/petzval_lens.rs @@ -3,7 +3,7 @@ use cherry_rs::examples::petzval_lens::*; fn paraxial_view() -> ParaxialView { let model = sequential_model(); - ParaxialView::new(&model, &field_specs(), false).expect("Could not create paraxial view") + ParaxialView::new(&model, &[field_specs()], false).expect("Could not create paraxial view") } #[test] diff --git a/crates/cherry-rs/tests/thin_lens_singlet.rs b/crates/cherry-rs/tests/thin_lens_singlet.rs index a0847a26..5effe852 100644 --- a/crates/cherry-rs/tests/thin_lens_singlet.rs +++ b/crates/cherry-rs/tests/thin_lens_singlet.rs @@ -10,7 +10,7 @@ const FIELD_SPECS: [FieldSpec; 1] = [FieldSpec::Angle { chi: 0.0, phi: 0.0 }]; #[test] fn thin_lens_efl_equals_focal_length() { let model = thin_lens_singlet::sequential_model(n!(1.0), &WAVELENGTHS); - let view = ParaxialView::new(&model, &FIELD_SPECS, false).expect("paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false).expect("paraxial view"); for sub_view in view.iter() { approx::assert_abs_diff_eq!(*sub_view.effective_focal_length(), 100.0, epsilon = 1e-9); } @@ -19,7 +19,7 @@ fn thin_lens_efl_equals_focal_length() { #[test] fn thin_lens_bfd_equals_focal_length() { let model = thin_lens_singlet::sequential_model(n!(1.0), &WAVELENGTHS); - let view = ParaxialView::new(&model, &FIELD_SPECS, false).expect("paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false).expect("paraxial view"); for sub_view in view.iter() { approx::assert_abs_diff_eq!(*sub_view.back_focal_distance(), 100.0, epsilon = 1e-9); } @@ -28,7 +28,7 @@ fn thin_lens_bfd_equals_focal_length() { #[test] fn thin_lens_marginal_ray_crosses_axis_at_focal_plane() { let model = thin_lens_singlet::sequential_model(n!(1.0), &WAVELENGTHS); - let view = ParaxialView::new(&model, &FIELD_SPECS, false).expect("paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false).expect("paraxial view"); for sub_view in view.iter() { let marginal = sub_view.marginal_ray(); // Surface 0 = Object (at infinity), 1 = ThinLens, 2 = Image (placed at diff --git a/crates/cherry-rs/tests/wf_epi_excitation.rs b/crates/cherry-rs/tests/wf_epi_excitation.rs index 8d32129c..006dccf6 100644 --- a/crates/cherry-rs/tests/wf_epi_excitation.rs +++ b/crates/cherry-rs/tests/wf_epi_excitation.rs @@ -25,8 +25,8 @@ const PARAXIAL_FNO: f64 = -0.3922; #[test] fn wf_epi_excitation_paraxial_aperture_stop() { let model = sequential_model(n!(1.0), n!(1.5), &WAVELENGTHS); - let view = - ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false) + .expect("Could not create paraxial view"); for sub_view in view.iter() { let result = sub_view.aperture_stop(); @@ -38,8 +38,8 @@ fn wf_epi_excitation_paraxial_aperture_stop() { #[test] fn wf_epi_excitation_paraxial_back_principal_plane() { let model = sequential_model(n!(1.0), n!(1.5), &WAVELENGTHS); - let view = - ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false) + .expect("Could not create paraxial view"); for sub_view in view.iter() { let result = sub_view.back_principal_plane(); @@ -51,8 +51,8 @@ fn wf_epi_excitation_paraxial_back_principal_plane() { #[test] fn wf_epi_excitation_paraxial_back_focal_distance() { let model = sequential_model(n!(1.0), n!(1.5), &WAVELENGTHS); - let view = - ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false) + .expect("Could not create paraxial view"); for sub_view in view.iter() { let result = sub_view.back_focal_distance(); @@ -64,8 +64,8 @@ fn wf_epi_excitation_paraxial_back_focal_distance() { #[test] fn wf_epi_excitation_paraxial_effective_focal_length() { let model = sequential_model(n!(1.0), n!(1.5), &WAVELENGTHS); - let view = - ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false) + .expect("Could not create paraxial view"); for sub_view in view.iter() { let result = sub_view.effective_focal_length(); @@ -77,8 +77,8 @@ fn wf_epi_excitation_paraxial_effective_focal_length() { #[test] fn wf_epi_excitation_entrance_pupil_location() { let model = sequential_model(n!(1.0), n!(1.5), &WAVELENGTHS); - let view = - ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false) + .expect("Could not create paraxial view"); for sub_view in view.iter() { let entrance_pupil = sub_view.entrance_pupil(); @@ -98,8 +98,8 @@ fn wf_epi_excitation_entrance_pupil_location() { #[test] fn wf_epi_excitation_paraxial_front_focal_distance() { let model = sequential_model(n!(1.0), n!(1.5), &WAVELENGTHS); - let view = - ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false) + .expect("Could not create paraxial view"); for sub_view in view.iter() { let result = sub_view.front_focal_distance(); @@ -111,8 +111,8 @@ fn wf_epi_excitation_paraxial_front_focal_distance() { #[test] fn wf_epi_excitation_paraxial_front_focal_length() { let model = sequential_model(n!(1.0), n!(1.5), &WAVELENGTHS); - let view = - ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false) + .expect("Could not create paraxial view"); for sub_view in view.iter() { let result = sub_view.front_focal_length(); @@ -124,8 +124,8 @@ fn wf_epi_excitation_paraxial_front_focal_length() { #[test] fn wf_epi_excitation_paraxial_front_principal_plane() { let model = sequential_model(n!(1.0), n!(1.5), &WAVELENGTHS); - let view = - ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false) + .expect("Could not create paraxial view"); for sub_view in view.iter() { let result = sub_view.front_principal_plane(); @@ -137,8 +137,8 @@ fn wf_epi_excitation_paraxial_front_principal_plane() { #[test] fn wf_epi_excitation_paraxial_image_space_fno() { let model = sequential_model(n!(1.0), n!(1.5), &WAVELENGTHS); - let view = - ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false) + .expect("Could not create paraxial view"); for sub_view in view.iter() { let result = sub_view.image_space_fno(); @@ -150,8 +150,8 @@ fn wf_epi_excitation_paraxial_image_space_fno() { #[test] fn wf_epi_excitation_paraxial_image_location() { let model = sequential_model(n!(1.0), n!(1.5), &WAVELENGTHS); - let view = - ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false) + .expect("Could not create paraxial view"); for sub_view in view.iter() { let result = sub_view.paraxial_image_plane().location; @@ -162,8 +162,8 @@ fn wf_epi_excitation_paraxial_image_location() { #[test] fn wf_epi_excitation_paraxial_image_size() { let model = sequential_model(n!(1.0), n!(1.5), &WAVELENGTHS); - let view = - ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false) + .expect("Could not create paraxial view"); for sub_view in view.iter() { let result = sub_view.paraxial_image_plane().semi_diameter; @@ -174,8 +174,8 @@ fn wf_epi_excitation_paraxial_image_size() { #[test] fn wf_epi_excitation_paraxial_lagrange_invariant() { let model = sequential_model(n!(1.0), n!(1.5), &WAVELENGTHS); - let view = - ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false) + .expect("Could not create paraxial view"); for sub_view in view.iter() { let result = sub_view.lagrange_invariants(); @@ -189,8 +189,8 @@ fn wf_epi_excitation_paraxial_lagrange_invariant() { #[test] fn wf_epi_excitation_paraxial_fno() { let model = sequential_model(n!(1.0), n!(1.5), &WAVELENGTHS); - let view = - ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + let view = ParaxialView::new(&model, &[FIELD_SPECS.to_vec()], false) + .expect("Could not create paraxial view"); for sub_view in view.iter() { let result = sub_view.paraxial_fno(); diff --git a/crates/cherry-rs/tests/wf_epi_microscope.rs b/crates/cherry-rs/tests/wf_epi_microscope.rs index 552bb610..21860715 100644 --- a/crates/cherry-rs/tests/wf_epi_microscope.rs +++ b/crates/cherry-rs/tests/wf_epi_microscope.rs @@ -5,7 +5,26 @@ use cherry_rs::{ApertureSpec, FieldSpec, ParaxialView, SamplingConfig, Sequentia const EXCITATION_WAVELENGTHS: [f64; 1] = [0.488]; const EMISSION_WAVELENGTHS: [f64; 1] = [0.520]; -const FIELD_SPECS: [FieldSpec; 1] = [FieldSpec::PointSource { x: 0.0, y: 1.5 }]; +const EXCITATION_FIELD_SPECS: [FieldSpec; 1] = [FieldSpec::PointSource { x: 0.0, y: 1.5 }]; + +/// Derives the emission path's own `FieldSpec` from the excitation arm's +/// computed transverse image height for its `y: 1.5` field point — the +/// emission path images the fluorescence spot the excitation path forms at +/// the specimen, not an independently chosen field point (FR-13). +fn emission_field_specs(model: &SequentialModel) -> Vec { + let exc_view = ParaxialView::new(model, &[EXCITATION_FIELD_SPECS.to_vec(), vec![]], false) + .expect("excitation paraxial view"); + let sub_view = exc_view.get_for_path(0, 0, 0).expect("path 0 subview"); + let image_height = sub_view.paraxial_image_plane().semi_diameter; + vec![FieldSpec::PointSource { + x: 0.0, + y: image_height, + }] +} + +fn field_specs_by_path(model: &SequentialModel) -> Vec> { + vec![EXCITATION_FIELD_SPECS.to_vec(), emission_field_specs(model)] +} const EXC_EFFECTIVE_FOCAL_LENGTH: f64 = -1.8750; const EXC_IMAGE_LOCATION: f64 = 5.0000; @@ -69,8 +88,8 @@ fn at_stop_surface_emission() { #[test] fn at_excitation_paraxial_effective_focal_length() { let model = model(); - let view = - ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + let view = ParaxialView::new(&model, &field_specs_by_path(&model), false) + .expect("Could not create paraxial view"); let sub_view = view.get_for_path(0, 0, 0).expect("path 0 subview"); assert_abs_diff_eq!( EXC_EFFECTIVE_FOCAL_LENGTH, @@ -82,8 +101,8 @@ fn at_excitation_paraxial_effective_focal_length() { #[test] fn at_excitation_paraxial_image_location() { let model = model(); - let view = - ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + let view = ParaxialView::new(&model, &field_specs_by_path(&model), false) + .expect("Could not create paraxial view"); let sub_view = view.get_for_path(0, 0, 0).expect("path 0 subview"); assert_abs_diff_eq!( EXC_IMAGE_LOCATION, @@ -95,8 +114,8 @@ fn at_excitation_paraxial_image_location() { #[test] fn at_excitation_paraxial_entrance_pupil_location() { let model = model(); - let view = - ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + let view = ParaxialView::new(&model, &field_specs_by_path(&model), false) + .expect("Could not create paraxial view"); let sub_view = view.get_for_path(0, 0, 0).expect("path 0 subview"); assert_abs_diff_eq!( EXC_ENTRANCE_PUPIL_LOCATION, @@ -108,8 +127,8 @@ fn at_excitation_paraxial_entrance_pupil_location() { #[test] fn at_excitation_paraxial_entrance_pupil_size() { let model = model(); - let view = - ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + let view = ParaxialView::new(&model, &field_specs_by_path(&model), false) + .expect("Could not create paraxial view"); let sub_view = view.get_for_path(0, 0, 0).expect("path 0 subview"); assert_abs_diff_eq!( EXC_ENTRANCE_PUPIL_SIZE, @@ -121,8 +140,8 @@ fn at_excitation_paraxial_entrance_pupil_size() { #[test] fn at_excitation_paraxial_lagrange_invariant() { let model = model(); - let view = - ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + let view = ParaxialView::new(&model, &field_specs_by_path(&model), false) + .expect("Could not create paraxial view"); let sub_view = view.get_for_path(0, 0, 0).expect("path 0 subview"); for &h in sub_view.lagrange_invariants() { assert_abs_diff_eq!(EXC_LAGRANGE_INVARIANT, h, epsilon = 1e-4); @@ -132,9 +151,12 @@ fn at_excitation_paraxial_lagrange_invariant() { #[test] fn at_emission_paraxial_image_location() { let model = model(); - let view = - ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); - let sub_view = view.get_for_path(1, 0, 0).expect("path 1 subview"); + let view = ParaxialView::new(&model, &field_specs_by_path(&model), false) + .expect("Could not create paraxial view"); + let tangential_vec_id = view.tangential_vec_id_for_phi(1, std::f64::consts::FRAC_PI_2); + let sub_view = view + .get_for_path(1, 0, tangential_vec_id) + .expect("path 1 subview"); assert_abs_diff_eq!( EMI_IMAGE_LOCATION, sub_view.paraxial_image_plane().location, @@ -159,7 +181,8 @@ fn at_ray_trace_succeeds_with_differing_wavelength_counts_per_path() { &[0.488], // excitation: 1 wavelength &[0.500, 0.520, 0.540], // emission: 3 wavelengths ); - let view = ParaxialView::new(&model, &FIELD_SPECS, false).unwrap(); + let field_specs = field_specs_by_path(&model); + let view = ParaxialView::new(&model, &field_specs, false).unwrap(); // AT-7: subview counts per path match each path's own wavelength count. let path0_subviews = view.iter().filter(|sv| sv.path_id() == 0).count(); @@ -172,15 +195,33 @@ fn at_ray_trace_succeeds_with_differing_wavelength_counts_per_path() { n_fan_rays: 5, full_pupil_spacing: 0.1, }; - let trace = ray_trace_3d_view(&aperture, &FIELD_SPECS, &model, &view, config) + let trace = ray_trace_3d_view(&[aperture, aperture], &field_specs, &model, &view, config) .expect("ray trace should succeed with differing per-path wavelength counts"); assert_eq!( trace.iter().filter(|r| r.path_id() == 0).count(), - FIELD_SPECS.len() + field_specs[0].len() ); assert_eq!( trace.iter().filter(|r| r.path_id() == 1).count(), - 3 * FIELD_SPECS.len() + 3 * field_specs[1].len() ); } + +/// FR-13/AT-8: the emission path's `FieldSpec` height must equal the +/// excitation path's own computed transverse image height for its `y: 1.5` +/// field point — not an independently chosen constant. +#[test] +fn at_emission_field_height_equals_excitation_image_height() { + let model = model(); + let field_specs = field_specs_by_path(&model); + let view = ParaxialView::new(&model, &field_specs, false).unwrap(); + let exc_sub_view = view.get_for_path(0, 0, 0).unwrap(); + let exc_image_height = exc_sub_view.paraxial_image_plane().semi_diameter; + match field_specs[1][0] { + FieldSpec::PointSource { y, .. } => { + assert_abs_diff_eq!(y, exc_image_height, epsilon = 1e-9); + } + _ => panic!("expected PointSource"), + } +}