From 9edf8edb7bb1a8a97a733f64ef74412912141a0d Mon Sep 17 00:00:00 2001 From: Kyle Douglass Date: Thu, 2 Jul 2026 14:05:02 +0200 Subject: [PATCH] feat: Multipath thin lens modeling of a widefield epifluorescence microscope --- .../src/core/sequential_model/builder.rs | 18 +- .../src/core/sequential_model/cursor.rs | 26 +++ .../src/core/sequential_model/mod.rs | 201 ++++++++++++++++-- .../cherry-rs/src/examples/beam_splitter.rs | 2 + crates/cherry-rs/src/examples/mod.rs | 1 + .../cherry-rs/src/examples/wf_epi_emission.rs | 1 + .../src/examples/wf_epi_excitation.rs | 4 +- .../src/examples/wf_epi_microscope.rs | 144 +++++++++++++ crates/cherry-rs/src/lib.rs | 2 +- crates/cherry-rs/src/specs/gaps.rs | 2 +- crates/cherry-rs/src/specs/paths.rs | 34 ++- crates/cherry-rs/src/views/paraxial.rs | 2 + .../tests/beam_splitter_multipath.rs | 1 + crates/cherry-rs/tests/wf_epi_microscope.rs | 137 ++++++++++++ 14 files changed, 541 insertions(+), 34 deletions(-) create mode 100644 crates/cherry-rs/src/examples/wf_epi_microscope.rs create mode 100644 crates/cherry-rs/tests/wf_epi_microscope.rs diff --git a/crates/cherry-rs/src/core/sequential_model/builder.rs b/crates/cherry-rs/src/core/sequential_model/builder.rs index 1f1c860c..7264c46c 100644 --- a/crates/cherry-rs/src/core/sequential_model/builder.rs +++ b/crates/cherry-rs/src/core/sequential_model/builder.rs @@ -61,16 +61,11 @@ impl SequentialModelBuilder { if self.paths.is_some() { let paths = self.paths.unwrap(); let wavelengths = self.wavelengths.unwrap(); - let stop_surface = self.stop_surface; #[cfg(feature = "serde")] - let model = SequentialModel::from_path_specs( - paths, - &wavelengths, - stop_surface, - self.registry.as_ref(), - )?; + let model = + SequentialModel::from_path_specs(paths, &wavelengths, self.registry.as_ref())?; #[cfg(not(feature = "serde"))] - let model = SequentialModel::from_path_specs(paths, &wavelengths, stop_surface)?; + let model = SequentialModel::from_path_specs(paths, &wavelengths)?; return Ok(BuildResult { model, gap_specs: vec![], @@ -609,6 +604,7 @@ mod tests { refractive_index: n!(1.0), }], beam_splitter_arms: vec![], + stop_surface: None, } } @@ -633,6 +629,7 @@ mod tests { refractive_index: n!(1.0), }], beam_splitter_arms: vec![], + stop_surface: None, }; let result = SequentialModelBuilder::new() .paths(vec![bad_path]) @@ -654,6 +651,7 @@ mod tests { refractive_index: n!(1.0), }], beam_splitter_arms: vec![], + stop_surface: None, }; let result = SequentialModelBuilder::new() .paths(vec![bad_path]) @@ -686,6 +684,7 @@ mod tests { }, ], beam_splitter_arms: vec![], + stop_surface: None, }; let result = SequentialModelBuilder::new() .paths(vec![bad_path]) @@ -712,6 +711,7 @@ mod tests { refractive_index: n!(1.0), }], beam_splitter_arms: vec![], + stop_surface: None, }; let result = SequentialModelBuilder::new() .paths(vec![path0, bad_path1]) @@ -754,6 +754,7 @@ mod tests { }, ], beam_splitter_arms: vec![], // missing arm declaration for the BS + stop_surface: None, }; let result = SequentialModelBuilder::new() .paths(vec![path]) @@ -832,6 +833,7 @@ mod tests { }, ], beam_splitter_arms: vec![], + stop_surface: None, }; let model_new = SequentialModelBuilder::new() .paths(vec![path]) diff --git a/crates/cherry-rs/src/core/sequential_model/cursor.rs b/crates/cherry-rs/src/core/sequential_model/cursor.rs index d1d7adba..1b74e543 100644 --- a/crates/cherry-rs/src/core/sequential_model/cursor.rs +++ b/crates/cherry-rs/src/core/sequential_model/cursor.rs @@ -49,6 +49,20 @@ impl Cursor { } } + /// Create a new cursor from an explicit position and frame (right, up, + /// forward). Used by the builder to seed a secondary path's cursor from + /// another path's cursor frame (e.g. `ObjectLinkedTo`). `track` is reset + /// to `0.0`. + pub(crate) fn from_frame(pos: Vec3, right: Vec3, up: Vec3, forward: Vec3) -> Self { + Self { + pos, + right, + up, + forward, + track: 0.0, + } + } + /// Advance the cursor by a given distance along the z-direction. pub fn advance(&mut self, distance: Float) { // Edge case for advancing from negative infinity to 0. @@ -135,6 +149,18 @@ mod test { ); } + #[test] + fn from_frame_sets_all_fields() { + let pos = Vec3::new(1.0, 2.0, 3.0); + let right = Vec3::new(1.0, 0.0, 0.0); + let up = Vec3::new(0.0, 0.0, -1.0); + let forward = Vec3::new(0.0, -1.0, 0.0); + let cursor = Cursor::from_frame(pos, right, up, forward); + assert_eq!(cursor.pos(), pos); + assert_eq!(cursor.forward(), forward); + assert_eq!(cursor.track(), 0.0); + } + #[test] fn cursor_start_from_neg_infinity() { let mut cursor = Cursor::new(Float::NEG_INFINITY); diff --git a/crates/cherry-rs/src/core/sequential_model/mod.rs b/crates/cherry-rs/src/core/sequential_model/mod.rs index 71df0fb7..4a971042 100644 --- a/crates/cherry-rs/src/core/sequential_model/mod.rs +++ b/crates/cherry-rs/src/core/sequential_model/mod.rs @@ -23,7 +23,7 @@ use crate::core::{ use crate::specs::surfaces::PlacementSpec; use crate::specs::{ gaps::GapSpec, - paths::{PathSpec, PathSurfaceRef}, + paths::{LinkedObjectOrientation, PathSpec, PathSurfaceRef}, surfaces::{BeamSplitterPathKind, BoundaryKind, SurfaceSpec}, }; @@ -323,6 +323,19 @@ pub fn reversed_surface_id(num_surfaces: usize, surf_id: usize) -> usize { num_surfaces - surf_id - 1 } +/// Counts the leading contiguous `Shared` steps of a `Reversed` +/// `ObjectLinkedTo` path, skipping the `ObjectLinkedTo` step itself. +/// +/// Only meaningful for `Reversed` paths; callers must gate on orientation +/// before using the result (see +/// [`SequentialModel::from_path_specs_with_builder`]). +fn count_leading_shared(refs: &[PathSurfaceRef]) -> usize { + refs.iter() + .skip(1) + .take_while(|r| matches!(r, PathSurfaceRef::Shared(_))) + .count() +} + impl Gap { pub(crate) fn try_from_spec(spec: &GapSpec, wavelength: Float) -> Result { let thickness = spec.thickness; @@ -521,7 +534,6 @@ impl SequentialModel { fn from_path_specs_with_builder( paths: Vec, wavelengths: &[Float], - stop_surface: Option, mut build_surface: impl FnMut(&SurfaceSpec) -> Result>, ) -> Result { if wavelengths.is_empty() { @@ -531,6 +543,12 @@ impl SequentialModel { let mut store_surfaces: Vec> = Vec::new(); let mut store_placements: Vec = Vec::new(); let mut optical_paths: Vec = Vec::new(); + // Full effective gap list (inferred + user-supplied) and surface index + // list for each processed path, keyed by path index. Used to infer + // gaps for the leading `Shared` block of a `Reversed` `ObjectLinkedTo` + // path. + let mut saved_surface_indices: Vec> = Vec::new(); + let mut saved_gap_specs: Vec> = Vec::new(); for ps in paths { let n_refs = ps.surface_refs.len(); @@ -538,20 +556,130 @@ impl SequentialModel { if n_refs == 0 { return Err(anyhow!("a PathSpec must have at least one surface_ref")); } - if ps.gaps.len() + 1 != n_refs { + + if let Some(pos) = ps + .surface_refs + .iter() + .position(|r| matches!(r, PathSurfaceRef::ObjectLinkedTo { .. })) + && pos != 0 + { return Err(anyhow!( - "PathSpec has {} surface_ref(s) but {} gap(s); expected {} gap(s)", + "ObjectLinkedTo may only appear as the first surface_ref of a PathSpec" + )); + } + + let linked = match ps.surface_refs.first() { + Some(PathSurfaceRef::ObjectLinkedTo { + path: p, + orientation, + }) => { + if *p >= optical_paths.len() { + return Err(anyhow!( + "ObjectLinkedTo references path {p} which has not been processed" + )); + } + let last_store_idx = *optical_paths[*p] + .surface_indices + .last() + .ok_or_else(|| anyhow!("linked path {p} is empty"))?; + if store_surfaces[last_store_idx].surface_kind() != SurfaceKind::Image { + return Err(anyhow!( + "linked path {p} does not end with an Image surface" + )); + } + Some((*p, *orientation)) + } + _ => None, + }; + + let n_leading_shared = match linked { + Some((_, LinkedObjectOrientation::Reversed)) => { + count_leading_shared(&ps.surface_refs) + } + _ => 0, + }; + + // Reversed paths: leading Shared steps must form a contiguous + // block; no Shared step may appear after the first New step. + if let Some((_, LinkedObjectOrientation::Reversed)) = linked { + let first_new_after_object = ps.surface_refs[1..] + .iter() + .position(|r| matches!(r, PathSurfaceRef::New(_))); + if let Some(new_pos) = first_new_after_object { + let has_shared_after_new = ps.surface_refs[new_pos + 2..] + .iter() + .any(|r| matches!(r, PathSurfaceRef::Shared(_))); + if has_shared_after_new { + return Err(anyhow!( + "Reversed ObjectLinkedTo path: Shared steps must form a \ + contiguous leading block before the first New step" + )); + } + } + } + + let expected_gaps = n_refs - 1 - n_leading_shared; + if ps.gaps.len() != expected_gaps { + return Err(anyhow!( + "PathSpec has {} surface_ref(s) ({} leading shared) but {} gap(s); \ + expected {} gap(s)", n_refs, + n_leading_shared, ps.gaps.len(), - n_refs - 1, + expected_gaps, )); } + // Precompute inferred gaps for the leading Shared block of a + // Reversed path from the linked path's saved gap specs. + let leading_inferred_gaps: Vec = + if let Some((p, LinkedObjectOrientation::Reversed)) = linked { + ps.surface_refs[1..=n_leading_shared] + .iter() + .map(|sref| { + let store_idx = match sref { + PathSurfaceRef::Shared(i) => *i, + _ => unreachable!("leading shared block validated above"), + }; + let j = saved_surface_indices[p] + .iter() + .position(|&idx| idx == store_idx) + .ok_or_else(|| { + anyhow!("Shared({store_idx}) not found in linked path {p}") + })?; + Ok(saved_gap_specs[p][j].clone()) + }) + .collect::>>()? + } else { + Vec::new() + }; + // Build the dense arm vec by consuming beam_splitter_arms in step order. let mut bs_arms_iter = ps.beam_splitter_arms.into_iter(); let mut dense_bs_arms: Vec> = Vec::with_capacity(n_refs); - let mut cursor = Cursor::new(-ps.gaps[0].thickness); + let mut cursor = match linked { + Some((p, orientation)) => { + let step = optical_paths[p] + .steps + .last() + .ok_or_else(|| anyhow!("linked path {p} has no steps"))?; + let m = step.cursor_rotation_matrix; + let right = Vec3::new(m.e[0][0], m.e[0][1], m.e[0][2]); + let (up, forward) = match orientation { + LinkedObjectOrientation::SameDirection => ( + Vec3::new(m.e[1][0], m.e[1][1], m.e[1][2]), + Vec3::new(m.e[2][0], m.e[2][1], m.e[2][2]), + ), + LinkedObjectOrientation::Reversed => ( + Vec3::new(-m.e[1][0], -m.e[1][1], -m.e[1][2]), + Vec3::new(-m.e[2][0], -m.e[2][1], -m.e[2][2]), + ), + }; + Cursor::from_frame(step.cursor_position, right, up, forward) + } + None => Cursor::new(-ps.gaps[0].thickness), + }; let mut surface_indices: Vec = Vec::new(); let mut path_steps: Vec = Vec::new(); @@ -569,6 +697,7 @@ impl SequentialModel { *i < store_surfaces.len() && store_surfaces[*i].surface_kind() == SurfaceKind::BeamSplitter } + PathSurfaceRef::ObjectLinkedTo { .. } => false, }; let bs_arm = if is_bs { Some(bs_arms_iter.next().ok_or_else(|| { @@ -671,10 +800,35 @@ impl SequentialModel { surface_indices.push(*i); } + PathSurfaceRef::ObjectLinkedTo { .. } => { + if !is_first { + return Err(anyhow!( + "ObjectLinkedTo may only appear as the first surface_ref" + )); + } + + let surface: Box = build_surface(&SurfaceSpec::Object)?; + let placement = SurfacePlacement::from_decenter_and_rotation( + Vec3::new(0.0, 0.0, 0.0), + Mat3x3::identity(), + Mat3x3::identity(), + &cursor, + ); + + let store_idx = store_surfaces.len(); + store_placements.push(placement); + store_surfaces.push(surface); + surface_indices.push(store_idx); + } } if !is_last { - cursor.advance(ps.gaps[step].thickness); + let gap = if step < n_leading_shared { + &leading_inferred_gaps[step] + } else { + &ps.gaps[step - n_leading_shared] + }; + cursor.advance(gap.thickness); } } @@ -685,24 +839,34 @@ impl SequentialModel { )); } + let all_gap_specs: Vec = leading_inferred_gaps + .iter() + .chain(ps.gaps.iter()) + .cloned() + .collect(); + let mut submodels: Vec = Vec::new(); for &wavelength in wavelengths.iter() { - let gaps = Self::gap_specs_to_gaps(&ps.gaps, wavelength)?; + let gaps = Self::gap_specs_to_gaps(&all_gap_specs, wavelength)?; submodels.push(SequentialSubModelBase::new(gaps)); } + + if let Some(i) = ps.stop_surface { + Self::validate_stop_surface(&store_surfaces, i)?; + } + + saved_surface_indices.push(surface_indices.clone()); + saved_gap_specs.push(all_gap_specs); + optical_paths.push(OpticalPath { surface_indices, beam_splitter_arms: dense_bs_arms, submodels, - stop_surface, + stop_surface: ps.stop_surface, steps: path_steps, }); } - if let Some(i) = stop_surface { - Self::validate_stop_surface(&store_surfaces, i)?; - } - let store = SurfaceStore { surfaces: store_surfaces, placements: store_placements, @@ -719,22 +883,17 @@ impl SequentialModel { pub(crate) fn from_path_specs( paths: Vec, wavelengths: &[Float], - stop_surface: Option, registry: Option<&SurfaceRegistry>, ) -> Result { - Self::from_path_specs_with_builder(paths, wavelengths, stop_surface, |spec| { + Self::from_path_specs_with_builder(paths, wavelengths, |spec| { surface_from_spec(spec, registry) }) } /// Builds a multipath model from `PathSpec`s (non-serde variant). #[cfg(not(feature = "serde"))] - pub(crate) fn from_path_specs( - paths: Vec, - wavelengths: &[Float], - stop_surface: Option, - ) -> Result { - Self::from_path_specs_with_builder(paths, wavelengths, stop_surface, surface_from_spec) + pub(crate) fn from_path_specs(paths: Vec, wavelengths: &[Float]) -> Result { + Self::from_path_specs_with_builder(paths, wavelengths, surface_from_spec) } /// Number of optical paths in the model. diff --git a/crates/cherry-rs/src/examples/beam_splitter.rs b/crates/cherry-rs/src/examples/beam_splitter.rs index 59b88c78..c62821f3 100644 --- a/crates/cherry-rs/src/examples/beam_splitter.rs +++ b/crates/cherry-rs/src/examples/beam_splitter.rs @@ -57,6 +57,7 @@ pub fn two_path_model( }, ], beam_splitter_arms: vec![BeamSplitterPathKind::Transmitting], + stop_surface: None, }; // Path 1: reflected arm. @@ -75,6 +76,7 @@ pub fn two_path_model( }, ], beam_splitter_arms: vec![BeamSplitterPathKind::Reflecting], + stop_surface: None, }; SequentialModelBuilder::new() diff --git a/crates/cherry-rs/src/examples/mod.rs b/crates/cherry-rs/src/examples/mod.rs index 3fd75d42..95ab3eff 100644 --- a/crates/cherry-rs/src/examples/mod.rs +++ b/crates/cherry-rs/src/examples/mod.rs @@ -10,3 +10,4 @@ pub mod petzval_lens; pub mod thin_lens_singlet; pub mod wf_epi_emission; pub mod wf_epi_excitation; +pub mod wf_epi_microscope; diff --git a/crates/cherry-rs/src/examples/wf_epi_emission.rs b/crates/cherry-rs/src/examples/wf_epi_emission.rs index 0c2ea32d..a66a966f 100644 --- a/crates/cherry-rs/src/examples/wf_epi_emission.rs +++ b/crates/cherry-rs/src/examples/wf_epi_emission.rs @@ -87,6 +87,7 @@ pub fn sequential_model( ], gaps: vec![gap_0, gap_1, gap_2, gap_3, gap_4], beam_splitter_arms: vec![BeamSplitterPathKind::Transmitting], + stop_surface: None, }; SequentialModelBuilder::new() diff --git a/crates/cherry-rs/src/examples/wf_epi_excitation.rs b/crates/cherry-rs/src/examples/wf_epi_excitation.rs index 963df1d6..0835db95 100644 --- a/crates/cherry-rs/src/examples/wf_epi_excitation.rs +++ b/crates/cherry-rs/src/examples/wf_epi_excitation.rs @@ -37,7 +37,7 @@ pub fn sequential_model( refractive_index: n_air, }; let gap_3 = GapSpec { - thickness: 1.0, + thickness: 5.0, refractive_index: n_oil, }; @@ -76,11 +76,11 @@ pub fn sequential_model( ], gaps: vec![gap_0, gap_1, gap_2, gap_3], beam_splitter_arms: vec![BeamSplitterPathKind::Reflecting], + stop_surface: Some(3), }; SequentialModelBuilder::new() .paths(vec![path]) - .stop_surface(3) .wavelengths(wavelengths.to_vec()) .build() .expect("wf_epi_excitation model builds") diff --git a/crates/cherry-rs/src/examples/wf_epi_microscope.rs b/crates/cherry-rs/src/examples/wf_epi_microscope.rs new file mode 100644 index 00000000..32015904 --- /dev/null +++ b/crates/cherry-rs/src/examples/wf_epi_microscope.rs @@ -0,0 +1,144 @@ +//! Combined widefield epifluorescence microscope: excitation and emission +//! paths sharing an objective and dichroic beam splitter. +//! +//! Path 0 (excitation): source → tube lens (f = 40 mm) → beam splitter +//! (reflecting arm, +45°) → objective (f = 3.3333 mm, aperture stop) → +//! sample. +//! +//! Path 1 (emission): sample (linked, reversed, to the excitation Image) → +//! objective (shared) → beam splitter (shared, transmitting arm) → fold +//! mirror (−45°) → tube lens (f = 200 mm) → camera. +//! +//! The emission path's Object is co-located with the excitation path's +//! Image (the sample plane); its cursor is derived from the excitation +//! Image cursor with R unchanged and U, F negated (`Reversed`). The gaps +//! from the emission Object to the objective and from the objective to the +//! beam splitter are inferred from the excitation path's saved gap specs, +//! since both paths traverse the same physical space. +use std::rc::Rc; + +use crate::{ + BeamSplitterPathKind, BoundaryKind, EulerAngles, GapSpec, LinkedObjectOrientation, PathSpec, + PathSurfaceRef, RefractiveIndexSpec, Rotation3D, SequentialModel, SurfaceSpec, Vec3, + core::{Float, sequential_model::builder::SequentialModelBuilder}, +}; + +pub fn sequential_model( + n_air: Rc, + n_oil: Rc, + wavelengths: &[f64], +) -> SequentialModel { + // Excitation path gaps. + let gap_0 = GapSpec { + thickness: 40.0, + refractive_index: n_air.clone(), + }; + let gap_1 = GapSpec { + thickness: 100.0, + refractive_index: n_air.clone(), + }; + let gap_2 = GapSpec { + thickness: 50.0, + refractive_index: n_air.clone(), + }; + let gap_3 = GapSpec { + thickness: 5.0, + refractive_index: n_oil, + }; + + // Emission path gaps (user-supplied only; Object→Objective and + // Objective→BeamSplitter are inferred from the excitation path). + let gap_bs_mirror = GapSpec { + thickness: 50.0, + refractive_index: n_air.clone(), + }; + let gap_mirror_tube = GapSpec { + thickness: 50.0, + refractive_index: n_air.clone(), + }; + let gap_tube_camera = GapSpec { + thickness: 200.0, + refractive_index: n_air, + }; + + let bs_rotation_exc = + Rotation3D::IntrinsicPassiveRUF(EulerAngles((45.0_f64 as Float).to_radians(), 0.0, 0.0)); + let mirror_rotation = + Rotation3D::IntrinsicPassiveRUF(EulerAngles((-45.0_f64 as Float).to_radians(), 0.0, 0.0)); + + let path_excitation = PathSpec { + surface_refs: vec![ + PathSurfaceRef::New(SurfaceSpec::Object), + PathSurfaceRef::New(SurfaceSpec::ThinLens { + semi_diameter: 25.0, + focal_length: 40.0, + rotation: Rotation3D::None, + decenter: Vec3::new(0.0, 0.0, 0.0), + rotation_offset: Rotation3D::None, + }), + PathSurfaceRef::New(SurfaceSpec::BeamSplitter { + semi_diameter: 36.0, + rotation: bs_rotation_exc, + decenter: Vec3::new(0.0, 0.0, 0.0), + rotation_offset: Rotation3D::None, + }), + PathSurfaceRef::New(SurfaceSpec::ThinLens { + semi_diameter: 4.25, + focal_length: 3.3333, + rotation: Rotation3D::None, + decenter: Vec3::new(0.0, 0.0, 0.0), + rotation_offset: Rotation3D::None, + }), + PathSurfaceRef::New(SurfaceSpec::Image { + rotation: Rotation3D::None, + decenter: Vec3::new(0.0, 0.0, 0.0), + rotation_offset: Rotation3D::None, + }), + ], + gaps: vec![gap_0, gap_1, gap_2, gap_3], + beam_splitter_arms: vec![BeamSplitterPathKind::Reflecting], + stop_surface: Some(3), + }; + + let path_emission = PathSpec { + surface_refs: vec![ + PathSurfaceRef::ObjectLinkedTo { + path: 0, + orientation: LinkedObjectOrientation::Reversed, + }, + PathSurfaceRef::Shared(3), // Objective + PathSurfaceRef::Shared(2), // BeamSplitter + PathSurfaceRef::New(SurfaceSpec::Conic { + semi_diameter: 10.0, + radius_of_curvature: Float::INFINITY, + conic_constant: 0.0, + surf_kind: BoundaryKind::Reflecting, + rotation: mirror_rotation, + decenter: Vec3::new(0.0, 0.0, 0.0), + rotation_offset: Rotation3D::None, + }), + PathSurfaceRef::New(SurfaceSpec::ThinLens { + semi_diameter: 20.0, + focal_length: 200.0, + rotation: Rotation3D::None, + decenter: Vec3::new(0.0, 0.0, 0.0), + rotation_offset: Rotation3D::None, + }), + PathSurfaceRef::New(SurfaceSpec::Image { + rotation: Rotation3D::None, + decenter: Vec3::new(0.0, 0.0, 0.0), + rotation_offset: Rotation3D::None, + }), + ], + gaps: vec![gap_bs_mirror, gap_mirror_tube, gap_tube_camera], + beam_splitter_arms: vec![BeamSplitterPathKind::Transmitting], + stop_surface: Some(3), + }; + + SequentialModelBuilder::new() + .paths(vec![path_excitation, path_emission]) + .wavelengths(wavelengths.to_vec()) + .build() + .expect("wf_epi_microscope model builds") + .model +} diff --git a/crates/cherry-rs/src/lib.rs b/crates/cherry-rs/src/lib.rs index 886a05f4..ed30eb25 100644 --- a/crates/cherry-rs/src/lib.rs +++ b/crates/cherry-rs/src/lib.rs @@ -160,7 +160,7 @@ pub use specs::{ aperture::ApertureSpec, fields::{FieldSpec, PupilSampling}, gaps::{ConstantRefractiveIndex, GapSpec, RefractiveIndexSpec}, - paths::{PathSpec, PathSurfaceRef}, + paths::{LinkedObjectOrientation, PathSpec, PathSurfaceRef}, surfaces::{BeamSplitterPathKind, BoundaryKind, Mask, PlacementSpec, SurfaceSpec}, }; pub use views::{ diff --git a/crates/cherry-rs/src/specs/gaps.rs b/crates/cherry-rs/src/specs/gaps.rs index 3b3e807b..e0fb7748 100644 --- a/crates/cherry-rs/src/specs/gaps.rs +++ b/crates/cherry-rs/src/specs/gaps.rs @@ -16,7 +16,7 @@ macro_rules! n { } /// Specifies a gap in a sequential optical system model. -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct GapSpec { pub thickness: Float, pub refractive_index: Rc, diff --git a/crates/cherry-rs/src/specs/paths.rs b/crates/cherry-rs/src/specs/paths.rs index b44a0958..5b0fe236 100644 --- a/crates/cherry-rs/src/specs/paths.rs +++ b/crates/cherry-rs/src/specs/paths.rs @@ -14,12 +14,18 @@ use crate::specs::{ pub struct PathSpec { /// Ordered sequence of surfaces for this path. pub surface_refs: Vec, - /// Step-indexed gaps. `gaps.len()` must equal `surface_refs.len() - 1`. + /// Step-indexed gaps. `gaps.len()` must equal `surface_refs.len() - 1`, + /// except for a `Reversed` [`PathSurfaceRef::ObjectLinkedTo`] path, where + /// leading `Shared` steps have their gaps inferred automatically (see + /// [`PathSurfaceRef::ObjectLinkedTo`]). pub gaps: Vec, /// Arm declaration for each beam splitter surface in this path, in step /// order. Provide one entry per beam splitter step; the builder matches /// them to beam splitter surfaces in order of appearance. pub beam_splitter_arms: Vec, + /// User-specified aperture stop as a store index for this path, or `None` + /// to fall back to heuristic aperture-stop selection. + pub stop_surface: Option, } /// One element of a [`PathSpec`]'s surface sequence. @@ -29,6 +35,31 @@ pub enum PathSurfaceRef { /// Reference the surface already at this store index, introduced by an /// earlier [`PathSpec`]. Shared(usize), + /// Place a new `Object` surface at the same 3D position as the `Image` + /// surface of a previously processed path, deriving the cursor frame from + /// that path's terminal cursor. Must be the first `surface_ref` of a + /// `PathSpec`. + ObjectLinkedTo { + /// Zero-based index of a previously processed [`PathSpec`]. + path: usize, + /// How the new cursor frame is derived from the linked path's + /// terminal (Image) cursor frame. + orientation: LinkedObjectOrientation, + }, +} + +/// Controls how a secondary path's cursor frame is derived from a primary +/// path's Image cursor frame when using +/// [`PathSurfaceRef::ObjectLinkedTo`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LinkedObjectOrientation { + /// Use the primary Image cursor's R, U, and F unchanged; the secondary + /// path departs forward past the Image into new territory. + SameDirection, + /// Keep R, negate U and F (a 180° rotation about R); the secondary path + /// travels back through the shared surfaces traversed by the primary + /// path. + Reversed, } #[cfg(test)] @@ -52,6 +83,7 @@ mod tests { refractive_index: n!(1.0), }], beam_splitter_arms: vec![], + stop_surface: None, }; assert_eq!(ps.surface_refs.len(), 2); } diff --git a/crates/cherry-rs/src/views/paraxial.rs b/crates/cherry-rs/src/views/paraxial.rs index 838710ad..1ed901f8 100644 --- a/crates/cherry-rs/src/views/paraxial.rs +++ b/crates/cherry-rs/src/views/paraxial.rs @@ -1966,6 +1966,7 @@ mod test { ], gaps: vec![gap_inf(), gap_50(), gap_50()], beam_splitter_arms: vec![BeamSplitterPathKind::Transmitting], + stop_surface: None, }; // Path 1: Object(Shared) → BS(Shared) → Iris2(SD=15) → Image_1 @@ -1979,6 +1980,7 @@ mod test { ], gaps: vec![gap_inf(), gap_50(), gap_50()], beam_splitter_arms: vec![BeamSplitterPathKind::Reflecting], + stop_surface: None, }; let model = SequentialModelBuilder::new() diff --git a/crates/cherry-rs/tests/beam_splitter_multipath.rs b/crates/cherry-rs/tests/beam_splitter_multipath.rs index 6299cc2f..44a04a22 100644 --- a/crates/cherry-rs/tests/beam_splitter_multipath.rs +++ b/crates/cherry-rs/tests/beam_splitter_multipath.rs @@ -111,6 +111,7 @@ fn at13_repeated_store_index_is_permitted_via_pathspec() { }, ], beam_splitter_arms: vec![], + stop_surface: None, }; let result = SequentialModelBuilder::new() .paths(vec![path]) diff --git a/crates/cherry-rs/tests/wf_epi_microscope.rs b/crates/cherry-rs/tests/wf_epi_microscope.rs new file mode 100644 index 00000000..44bdabbd --- /dev/null +++ b/crates/cherry-rs/tests/wf_epi_microscope.rs @@ -0,0 +1,137 @@ +use approx::assert_abs_diff_eq; + +use cherry_rs::examples::wf_epi_microscope::sequential_model; +use cherry_rs::{FieldSpec, ParaxialView, SequentialModel, n}; + +const WAVELENGTHS: [f64; 1] = [0.5876]; +const FIELD_SPECS: [FieldSpec; 1] = [FieldSpec::PointSource { x: 0.0, y: 1.5 }]; + +const EXC_EFFECTIVE_FOCAL_LENGTH: f64 = -1.8750; +const EXC_IMAGE_LOCATION: f64 = 5.0000; +const EXC_ENTRANCE_PUPIL_LOCATION: f64 = -54.5455; +const EXC_ENTRANCE_PUPIL_SIZE: f64 = 1.5454; +const EXC_LAGRANGE_INVARIANT: f64 = -0.1594; + +const EMI_IMAGE_LOCATION: f64 = 199.8800167976479; + +fn model() -> SequentialModel { + sequential_model(n!(1.0), n!(1.5), &WAVELENGTHS) +} + +#[test] +fn at_model_has_two_paths() { + let model = model(); + assert_eq!(model.path_count(), 2); +} + +#[test] +fn at_emission_object_position_equals_excitation_image_position() { + let model = model(); + let exc_image = model.placements()[4].position; + let emi_object = model.placements()[5].position; + assert_abs_diff_eq!(exc_image.x(), emi_object.x(), epsilon = 1e-9); + assert_abs_diff_eq!(exc_image.y(), emi_object.y(), epsilon = 1e-9); + assert_abs_diff_eq!(exc_image.z(), emi_object.z(), epsilon = 1e-9); +} + +#[test] +fn at_objective_in_both_paths() { + let model = model(); + assert!(model.path_surface_indices(0).contains(&3)); + assert!(model.path_surface_indices(1).contains(&3)); +} + +#[test] +fn at_beamsplitter_in_both_paths() { + let model = model(); + assert!(model.path_surface_indices(0).contains(&2)); + assert!(model.path_surface_indices(1).contains(&2)); +} + +#[test] +fn at_stop_surface_excitation() { + let model = model(); + assert_eq!(model.stop_surface_for_path(0), Some(3)); +} + +#[test] +fn at_stop_surface_emission() { + let model = model(); + assert_eq!(model.stop_surface_for_path(1), Some(3)); +} + +#[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 sub_view = view.get_for_path(0, 0, 0).expect("path 0 subview"); + assert_abs_diff_eq!( + EXC_EFFECTIVE_FOCAL_LENGTH, + sub_view.effective_focal_length(), + epsilon = 1e-4 + ); +} + +#[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 sub_view = view.get_for_path(0, 0, 0).expect("path 0 subview"); + assert_abs_diff_eq!( + EXC_IMAGE_LOCATION, + sub_view.paraxial_image_plane().location, + epsilon = 1e-4 + ); +} + +#[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 sub_view = view.get_for_path(0, 0, 0).expect("path 0 subview"); + assert_abs_diff_eq!( + EXC_ENTRANCE_PUPIL_LOCATION, + sub_view.entrance_pupil().location, + epsilon = 1e-3 + ); +} + +#[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 sub_view = view.get_for_path(0, 0, 0).expect("path 0 subview"); + assert_abs_diff_eq!( + EXC_ENTRANCE_PUPIL_SIZE, + sub_view.entrance_pupil().semi_diameter, + epsilon = 1e-3 + ); +} + +#[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 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); + } +} + +#[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"); + assert_abs_diff_eq!( + EMI_IMAGE_LOCATION, + sub_view.paraxial_image_plane().location, + epsilon = 1e-4 + ); +}