From 8837119e19ca4d9887e15783823647aeae5cabdb Mon Sep 17 00:00:00 2001 From: Kyle Douglass Date: Mon, 29 Jun 2026 14:13:00 +0200 Subject: [PATCH 1/5] feat: Compute front focal lengths and standardize sign conventions across tests --- README.md | 1 + crates/cherry-rs/src/views/paraxial.rs | 54 +++++++++++++++---- .../tests/biconvex_lens_finite_object.rs | 16 +++++- crates/cherry-rs/tests/convexplano_lens_ri.rs | 2 +- crates/cherry-rs/tests/wf_epi_excitation.rs | 42 +++++++++++++++ 5 files changed, 103 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index d6c10756..ea002444 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ Cherry might be for you if: - [ ] **Help Wanted** 3D views - [ ] **Help Wanted** Fuzzy search for materials - [ ] [Cardinal lens](https://opg.optica.org/ao/fulltext.cfm?uri=ao-63-4-1110) surface type for ideal objective models +- [ ] Cross-validate integration test results with Optiland ### Done diff --git a/crates/cherry-rs/src/views/paraxial.rs b/crates/cherry-rs/src/views/paraxial.rs index a99610e8..013729b1 100644 --- a/crates/cherry-rs/src/views/paraxial.rs +++ b/crates/cherry-rs/src/views/paraxial.rs @@ -139,6 +139,7 @@ pub struct ParaxialSubView { entrance_pupil: Pupil, exit_pupil: Pupil, front_focal_distance: Float, + front_focal_length: Float, front_principal_plane: Float, image_space_fno: Float, marginal_ray: ParaxialRayBundle, @@ -163,6 +164,7 @@ pub struct ParaxialSubViewDescription { entrance_pupil: Pupil, exit_pupil: Pupil, front_focal_distance: Float, + front_focal_length: Float, front_principal_plane: Float, image_space_fno: Float, marginal_ray: ParaxialRayBundle, @@ -569,11 +571,17 @@ impl ParaxialSubView { &marginal_ray, )?; let effective_focal_length = Self::calc_effective_focal_length(¶llel_ray); + let front_focal_length = Self::calc_front_focal_length( + sequential_sub_model, + surfaces, + surface_indices, + &reverse_parallel_ray, + )?; let back_principal_plane = Self::calc_back_principal_plane(back_focal_distance, effective_focal_length)?; let front_principal_plane = - Self::calc_front_principal_plane(front_focal_distance, effective_focal_length); + Self::calc_front_principal_plane(front_focal_distance, front_focal_length); let chief_ray = Self::calc_chief_ray( surfaces, @@ -620,6 +628,7 @@ impl ParaxialSubView { entrance_pupil, exit_pupil, front_focal_distance, + front_focal_length, front_principal_plane, image_space_fno, marginal_ray, @@ -641,6 +650,7 @@ impl ParaxialSubView { entrance_pupil: self.entrance_pupil.clone(), exit_pupil: self.exit_pupil.clone(), front_focal_distance: self.front_focal_distance, + front_focal_length: self.front_focal_length, front_principal_plane: self.front_principal_plane, image_space_fno: self.image_space_fno, marginal_ray: self.marginal_ray.clone(), @@ -693,6 +703,10 @@ impl ParaxialSubView { &self.front_focal_distance } + pub fn front_focal_length(&self) -> &Float { + &self.front_focal_length + } + pub fn front_principal_plane(&self) -> &Float { &self.front_principal_plane } @@ -751,8 +765,7 @@ impl ParaxialSubView { return Ok(Float::INFINITY); } - // Distance is always positive - Ok(bfd.abs()) + Ok(bfd) } fn calc_back_principal_plane( @@ -996,20 +1009,41 @@ impl ParaxialSubView { return Ok(Float::INFINITY); } - // Distance is always positive - Ok(ffd.abs()) + Ok(ffd) } - fn calc_front_principal_plane( - front_focal_distance: Float, - effective_focal_length: Float, - ) -> Float { + fn calc_front_focal_length( + sequential_sub_model: &dyn SequentialSubModel, + surfaces: &[Box], + surface_indices: &[usize], + reverse_parallel_ray: &ParaxialRayBundle, + ) -> Result { + // y_1: height at the first physical surface in the reverse trace + let last_physical_step_index = last_physical_step(surface_indices, surfaces) + .ok_or(anyhow!("There are no physical surfaces"))?; + let y_index = reversed_surface_id(sequential_sub_model.len() + 1, last_physical_step_index); + let y_1 = reverse_parallel_ray.rays_at_surface(y_index)[0].height; + + // u_final: angle at the last physical surface in the reverse trace + let first_physical_step_index = first_physical_step(surface_indices, surfaces) + .ok_or(anyhow!("There are no physical surfaces"))?; + let u_index = + reversed_surface_id(sequential_sub_model.len() + 1, first_physical_step_index); + let u_final = reverse_parallel_ray.rays_at_surface(u_index)[0].angle; + + if u_final == 0.0 { + return Ok(Float::INFINITY); + } + Ok(-y_1 / u_final) + } + + fn calc_front_principal_plane(front_focal_distance: Float, front_focal_length: Float) -> Float { // Principal planes make no sense for lenses without power if front_focal_distance.is_infinite() { return Float::NAN; } - effective_focal_length - front_focal_distance + front_focal_distance - front_focal_length } fn calc_marginal_ray( diff --git a/crates/cherry-rs/tests/biconvex_lens_finite_object.rs b/crates/cherry-rs/tests/biconvex_lens_finite_object.rs index b957daef..7ea4e45c 100644 --- a/crates/cherry-rs/tests/biconvex_lens_finite_object.rs +++ b/crates/cherry-rs/tests/biconvex_lens_finite_object.rs @@ -23,7 +23,8 @@ const EXIT_PUPIL: Pupil = Pupil { location: -2.4019, semi_diameter: 12.8540, }; -const FRONT_FOCAL_DISTANCE: f64 = 98.4360; +const FRONT_FOCAL_DISTANCE: f64 = -98.4360; +const FRONT_FOCAL_LENGTH: f64 = -99.6297; const FRONT_PRINCIPAL_PLANE: f64 = 1.1937; const PARAXIAL_IMAGE_PLANE: ImagePlane = ImagePlane { @@ -159,6 +160,19 @@ 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"); + + for sub_view in view.iter() { + let result = sub_view.front_focal_length(); + + assert_abs_diff_eq!(FRONT_FOCAL_LENGTH, *result, epsilon = 1e-4) + } +} + #[test] fn test_paraxial_view_front_principal_plane() { let model = sequential_model(n!(1.0), n!(1.517), &WAVELENGTHS); diff --git a/crates/cherry-rs/tests/convexplano_lens_ri.rs b/crates/cherry-rs/tests/convexplano_lens_ri.rs index 42e3631a..c57e747e 100644 --- a/crates/cherry-rs/tests/convexplano_lens_ri.rs +++ b/crates/cherry-rs/tests/convexplano_lens_ri.rs @@ -28,7 +28,7 @@ const EXIT_PUPIL: Pupil = Pupil { location: -3.4983, semi_diameter: 12.5, }; -const FRONT_FOCAL_DISTANCE: f64 = EFFECTIVE_FOCAL_LENGTH; +const FRONT_FOCAL_DISTANCE: f64 = -EFFECTIVE_FOCAL_LENGTH; const FRONT_PRINCIPAL_PLANE: f64 = 0.0; // For a 5 degree field angle diff --git a/crates/cherry-rs/tests/wf_epi_excitation.rs b/crates/cherry-rs/tests/wf_epi_excitation.rs index 0a8377b7..08f0675d 100644 --- a/crates/cherry-rs/tests/wf_epi_excitation.rs +++ b/crates/cherry-rs/tests/wf_epi_excitation.rs @@ -13,6 +13,9 @@ const BACK_PRINCIPAL_PLANE: f64 = 7.0312; const EFFECTIVE_FOCAL_LENGTH: f64 = -1.8750; const ENTRANCE_PUPIL_LOCATION: f64 = -54.5455; const ENTRANCE_PUPIL_SIZE: f64 = 1.5454; +const FRONT_FOCAL_DISTANCE: f64 = -55.0; +const FRONT_FOCAL_LENGTH: f64 = 1.25; +const FRONT_PRINCIPAL_PLANE: f64 = -56.25; #[test] fn wf_epi_excitation_paraxial_aperture_stop() { @@ -86,3 +89,42 @@ 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"); + + for sub_view in view.iter() { + let result = sub_view.front_focal_distance(); + + assert_abs_diff_eq!(FRONT_FOCAL_DISTANCE, *result, epsilon = 1e-4) + } +} + +#[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"); + + for sub_view in view.iter() { + let result = sub_view.front_focal_length(); + + assert_abs_diff_eq!(FRONT_FOCAL_LENGTH, *result, epsilon = 1e-4) + } +} + +#[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"); + + for sub_view in view.iter() { + let result = sub_view.front_principal_plane(); + + assert_abs_diff_eq!(FRONT_PRINCIPAL_PLANE, *result, epsilon = 1e-4) + } +} From 990a0725dcee4858cb4d1b99d6afba5f20acc929 Mon Sep 17 00:00:00 2001 From: Kyle Douglass Date: Mon, 29 Jun 2026 15:41:43 +0200 Subject: [PATCH 2/5] Add f/# checks to widefield epi integration test --- .../src/core/sequential_model/solves/fno.rs | 4 +-- crates/cherry-rs/src/views/paraxial.rs | 4 +-- crates/cherry-rs/tests/wf_epi_excitation.rs | 28 +++++++++++++++++++ 3 files changed, 32 insertions(+), 4 deletions(-) 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 8b784713..8597fd47 100644 --- a/crates/cherry-rs/src/core/sequential_model/solves/fno.rs +++ b/crates/cherry-rs/src/core/sequential_model/solves/fno.rs @@ -205,7 +205,7 @@ mod tests { let target = 4.0; let (_, pv) = build_with_fno_solve(target); let sub = pv.get(0, 0).unwrap(); - assert_abs_diff_eq!(sub.paraxial_fno(), target, epsilon = 1e-4); + assert_abs_diff_eq!(sub.paraxial_fno().abs(), target, epsilon = 1e-4); } #[test] @@ -315,7 +315,7 @@ mod tests { let sub = pv.get(0, 0).unwrap(); // F/# constraint satisfied. - assert_abs_diff_eq!(sub.paraxial_fno(), target_fno, epsilon = 1e-3); + assert_abs_diff_eq!(sub.paraxial_fno().abs(), target_fno, epsilon = 1e-3); // MarginalRaySolve constraint: marginal ray height at image ≈ 0. let bundle = marginal_ray_bundle(&model, 0).unwrap(); diff --git a/crates/cherry-rs/src/views/paraxial.rs b/crates/cherry-rs/src/views/paraxial.rs index 013729b1..f1ea95df 100644 --- a/crates/cherry-rs/src/views/paraxial.rs +++ b/crates/cherry-rs/src/views/paraxial.rs @@ -611,7 +611,7 @@ impl ParaxialSubView { .refractive_index .n(); let u_last = marginal_ray.rays_at_surface(last_phys_id)[0].angle; - let paraxial_fno = 1.0 / (2.0 * n_image * u_last.abs()); + let paraxial_fno = 1.0 / (2.0 * n_image * u_last); let image_space_fno = effective_focal_length / (2.0 * entrance_pupil.semi_diameter); Ok(Self { @@ -1856,7 +1856,7 @@ mod test { let (sub, _) = setup(); // Last physical surface is index 2 (plano surface); n_image = 1.0 (air). let u_last = sub.marginal_ray().rays_at_surface(2)[0].angle; - let expected = 1.0 / (2.0 * u_last.abs()); + let expected = 1.0 / (2.0 * u_last); assert_abs_diff_eq!(sub.paraxial_fno(), expected, epsilon = 1e-6); } diff --git a/crates/cherry-rs/tests/wf_epi_excitation.rs b/crates/cherry-rs/tests/wf_epi_excitation.rs index 08f0675d..bc53e299 100644 --- a/crates/cherry-rs/tests/wf_epi_excitation.rs +++ b/crates/cherry-rs/tests/wf_epi_excitation.rs @@ -16,6 +16,8 @@ const ENTRANCE_PUPIL_SIZE: f64 = 1.5454; const FRONT_FOCAL_DISTANCE: f64 = -55.0; const FRONT_FOCAL_LENGTH: f64 = 1.25; const FRONT_PRINCIPAL_PLANE: f64 = -56.25; +const IMAGE_SPACE_FNO: f64 = -0.6066; +const PARAXIAL_FNO: f64 = -0.3922; #[test] fn wf_epi_excitation_paraxial_aperture_stop() { @@ -128,3 +130,29 @@ fn wf_epi_excitation_paraxial_front_principal_plane() { assert_abs_diff_eq!(FRONT_PRINCIPAL_PLANE, *result, epsilon = 1e-4) } } + +#[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"); + + for sub_view in view.iter() { + let result = sub_view.image_space_fno(); + + assert_abs_diff_eq!(IMAGE_SPACE_FNO, result, epsilon = 1e-4) + } +} + +#[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"); + + for sub_view in view.iter() { + let result = sub_view.paraxial_fno(); + + assert_abs_diff_eq!(PARAXIAL_FNO, result, epsilon = 1e-4) + } +} From 78c53215b33f0f0e846ba77e0d6aa9f4e0b430f2 Mon Sep 17 00:00:00 2001 From: Kyle Douglass Date: Tue, 30 Jun 2026 09:35:50 +0200 Subject: [PATCH 3/5] Compute image location and size for the WF test case --- crates/cherry-rs/src/views/paraxial.rs | 15 ++++------- .../tests/biconvex_lens_finite_object.rs | 2 +- crates/cherry-rs/tests/convexplano_lens_ri.rs | 2 +- crates/cherry-rs/tests/wf_epi_excitation.rs | 26 +++++++++++++++++++ 4 files changed, 33 insertions(+), 12 deletions(-) diff --git a/crates/cherry-rs/src/views/paraxial.rs b/crates/cherry-rs/src/views/paraxial.rs index f1ea95df..e69f1da5 100644 --- a/crates/cherry-rs/src/views/paraxial.rs +++ b/crates/cherry-rs/src/views/paraxial.rs @@ -594,13 +594,8 @@ impl ParaxialSubView { field_specs, &entrance_pupil, )?; - let paraxial_image_plane = Self::calc_paraxial_image_plane( - surfaces, - placements, - surface_indices, - &marginal_ray, - &chief_ray, - )?; + let paraxial_image_plane = + Self::calc_paraxial_image_plane(surfaces, surface_indices, &marginal_ray, &chief_ray)?; let last_phys_id = last_physical_step(surface_indices, surfaces) .ok_or_else(|| anyhow!("There are no physical surfaces"))?; @@ -1095,21 +1090,21 @@ impl ParaxialSubView { /// Compute the paraxial image plane. fn calc_paraxial_image_plane( surfaces: &[Box], - placements: &[SurfacePlacement], surface_indices: &[usize], marginal_ray: &ParaxialRayBundle, chief_ray: &ParaxialRayBundle, ) -> Result { let last_physical_step_id = last_physical_step(surface_indices, surfaces) .ok_or(anyhow!("There are no physical surfaces"))?; - let store_idx = surface_indices[last_physical_step_id]; let d_axis = axis_intercepts(marginal_ray.rays_at_surface(last_physical_step_id))?[0]; let location = if d_axis.is_infinite() { // Ensure positive infinity is returned for infinite image planes Float::INFINITY } else { - placements[store_idx].track + d_axis + // Compute the paraxial image plane location relative to the last physical + // surface + d_axis }; // Propagate the chief ray from the last physical surface to the image plane to diff --git a/crates/cherry-rs/tests/biconvex_lens_finite_object.rs b/crates/cherry-rs/tests/biconvex_lens_finite_object.rs index 7ea4e45c..66889085 100644 --- a/crates/cherry-rs/tests/biconvex_lens_finite_object.rs +++ b/crates/cherry-rs/tests/biconvex_lens_finite_object.rs @@ -28,7 +28,7 @@ const FRONT_FOCAL_LENGTH: f64 = -99.6297; const FRONT_PRINCIPAL_PLANE: f64 = 1.1937; const PARAXIAL_IMAGE_PLANE: ImagePlane = ImagePlane { - location: 199.7684, + location: 196.1684, semi_diameter: 4.9048, }; diff --git a/crates/cherry-rs/tests/convexplano_lens_ri.rs b/crates/cherry-rs/tests/convexplano_lens_ri.rs index c57e747e..c6249a01 100644 --- a/crates/cherry-rs/tests/convexplano_lens_ri.rs +++ b/crates/cherry-rs/tests/convexplano_lens_ri.rs @@ -33,7 +33,7 @@ const FRONT_PRINCIPAL_PLANE: f64 = 0.0; // For a 5 degree field angle const PARAXIAL_IMAGE_PLANE: ImagePlane = ImagePlane { - location: 51.8987, + location: 46.5987, semi_diameter: 4.3829, }; diff --git a/crates/cherry-rs/tests/wf_epi_excitation.rs b/crates/cherry-rs/tests/wf_epi_excitation.rs index bc53e299..1378ba71 100644 --- a/crates/cherry-rs/tests/wf_epi_excitation.rs +++ b/crates/cherry-rs/tests/wf_epi_excitation.rs @@ -16,6 +16,8 @@ const ENTRANCE_PUPIL_SIZE: f64 = 1.5454; const FRONT_FOCAL_DISTANCE: f64 = -55.0; const FRONT_FOCAL_LENGTH: f64 = 1.25; const FRONT_PRINCIPAL_PLANE: f64 = -56.25; +const IMAGE_LOCATION: f64 = 5.0000; +const IMAGE_SIZE: f64 = 0.125; const IMAGE_SPACE_FNO: f64 = -0.6066; const PARAXIAL_FNO: f64 = -0.3922; @@ -144,6 +146,30 @@ 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"); + + for sub_view in view.iter() { + let result = sub_view.paraxial_image_plane().location; + assert_abs_diff_eq!(IMAGE_LOCATION, result, epsilon = 1e-4); + } +} + +#[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"); + + for sub_view in view.iter() { + let result = sub_view.paraxial_image_plane().semi_diameter; + assert_abs_diff_eq!(IMAGE_SIZE, result, epsilon = 1e-4); + } +} + #[test] fn wf_epi_excitation_paraxial_fno() { let model = sequential_model(n!(1.0), n!(1.5), &WAVELENGTHS); From 6e81baff0c2cee1de5ac7e1b43e3fb5415edf5ac Mon Sep 17 00:00:00 2001 From: Kyle Douglass Date: Tue, 30 Jun 2026 10:19:03 +0200 Subject: [PATCH 4/5] Implement Lagrange invariant --- crates/cherry-rs/src/gui/windows/paraxial.rs | 20 ++++++++++ crates/cherry-rs/src/views/paraxial.rs | 42 ++++++++++++++++++++ crates/cherry-rs/tests/wf_epi_excitation.rs | 16 ++++++++ 3 files changed, 78 insertions(+) diff --git a/crates/cherry-rs/src/gui/windows/paraxial.rs b/crates/cherry-rs/src/gui/windows/paraxial.rs index 16a72c97..e40a3928 100644 --- a/crates/cherry-rs/src/gui/windows/paraxial.rs +++ b/crates/cherry-rs/src/gui/windows/paraxial.rs @@ -201,6 +201,15 @@ fn render_table_body( }); } }); + + tb_row(body, row_h, "|H| (Lagrange invariant)", ids, pv, |sv| { + let stop = *sv.aperture_stop(); + sv.lagrange_invariants() + .get(stop) + .copied() + .unwrap_or(f64::NAN) + .abs() + }); } fn tb_sep(body: &mut egui_extras::TableBody<'_>, n_cols: usize) { @@ -388,6 +397,17 @@ mod tests { harness.get_by_label("Image space F/#"); } + #[test] + fn paraxial_data_shows_lagrange_invariant_row() { + let result = make_result(&["0.567"]); + let mut harness = Harness::new(|ctx| { + let mut open = true; + ParaxialWindow::show(ctx, &mut open, Some(&result)); + }); + harness.step(); + harness.get_by_label("|H| (Lagrange invariant)"); + } + #[test] fn single_wavelength_no_primary_axial_color() { let result = make_result(&["0.567"]); diff --git a/crates/cherry-rs/src/views/paraxial.rs b/crates/cherry-rs/src/views/paraxial.rs index e69f1da5..838710ad 100644 --- a/crates/cherry-rs/src/views/paraxial.rs +++ b/crates/cherry-rs/src/views/paraxial.rs @@ -142,6 +142,7 @@ pub struct ParaxialSubView { 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, @@ -167,6 +168,7 @@ pub struct ParaxialSubViewDescription { 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, @@ -608,6 +610,11 @@ impl ParaxialSubView { let u_last = marginal_ray.rays_at_surface(last_phys_id)[0].angle; let paraxial_fno = 1.0 / (2.0 * n_image * u_last); let image_space_fno = effective_focal_length / (2.0 * entrance_pupil.semi_diameter); + let lagrange_invariants = (0..sequential_sub_model.gaps().len()) + .map(|i| { + Self::calc_lagrange_invariant(i, &marginal_ray, &chief_ray, sequential_sub_model) + }) + .collect::>>()?; Ok(Self { path_id, @@ -626,6 +633,7 @@ impl ParaxialSubView { front_focal_length, front_principal_plane, image_space_fno, + lagrange_invariants, marginal_ray, paraxial_fno, paraxial_image_plane, @@ -648,6 +656,7 @@ impl ParaxialSubView { front_focal_length: self.front_focal_length, front_principal_plane: self.front_principal_plane, image_space_fno: self.image_space_fno, + lagrange_invariants: self.lagrange_invariants.clone(), marginal_ray: self.marginal_ray.clone(), paraxial_fno: self.paraxial_fno, paraxial_image_plane: self.paraxial_image_plane.clone(), @@ -726,6 +735,10 @@ impl ParaxialSubView { self.image_space_fno } + pub fn lagrange_invariants(&self) -> &[Float] { + &self.lagrange_invariants + } + fn calc_aperture_stop( surfaces: &[Box], placements: &[SurfacePlacement], @@ -1041,6 +1054,23 @@ impl ParaxialSubView { front_focal_distance - front_focal_length } + fn calc_lagrange_invariant( + surface_id: usize, + marginal_ray: &ParaxialRayBundle, + chief_ray: &ParaxialRayBundle, + sequential_sub_model: &dyn SequentialSubModel, + ) -> Result { + let n = sequential_sub_model + .gaps() + .get(surface_id) + .ok_or_else(|| anyhow!("surface_id {surface_id} out of range"))? + .refractive_index + .n(); + let mr = marginal_ray.rays_at_surface(surface_id); + let cr = chief_ray.rays_at_surface(surface_id); + Ok(n * (mr[0].height * cr[0].angle - cr[0].height * mr[0].angle)) + } + fn calc_marginal_ray( surfaces: &[Box], placements: &[SurfacePlacement], @@ -1982,4 +2012,16 @@ mod test { "path 1 stop should be BS (step 1)" ); } + + #[test] + fn test_lagrange_invariant_is_conserved() { + let (view, _) = setup(); + + // Convexplano lens has 3 gaps; invariant is stored for each (indices 0–2). + let invariants = view.lagrange_invariants(); + assert!(invariants.len() >= 2); + for &h in invariants { + assert_abs_diff_eq!(h, invariants[1], epsilon = 1e-4); + } + } } diff --git a/crates/cherry-rs/tests/wf_epi_excitation.rs b/crates/cherry-rs/tests/wf_epi_excitation.rs index 1378ba71..8d32129c 100644 --- a/crates/cherry-rs/tests/wf_epi_excitation.rs +++ b/crates/cherry-rs/tests/wf_epi_excitation.rs @@ -19,6 +19,7 @@ const FRONT_PRINCIPAL_PLANE: f64 = -56.25; const IMAGE_LOCATION: f64 = 5.0000; const IMAGE_SIZE: f64 = 0.125; const IMAGE_SPACE_FNO: f64 = -0.6066; +const LAGRANGE_INVARIANT: f64 = -0.1594; const PARAXIAL_FNO: f64 = -0.3922; #[test] @@ -170,6 +171,21 @@ 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"); + + for sub_view in view.iter() { + let result = sub_view.lagrange_invariants(); + + for &h in result { + assert_abs_diff_eq!(LAGRANGE_INVARIANT, h, epsilon = 1e-4); + } + } +} + #[test] fn wf_epi_excitation_paraxial_fno() { let model = sequential_model(n!(1.0), n!(1.5), &WAVELENGTHS); From dcaf89e49b026d1eef4f2fedc8ce260a1b31e12e Mon Sep 17 00:00:00 2001 From: Kyle Douglass Date: Tue, 30 Jun 2026 10:38:24 +0200 Subject: [PATCH 5/5] Expose widefield excitation system to the GUI --- crates/cherry-rs/src/gui/app.rs | 6 ++ crates/cherry-rs/src/gui/examples.rs | 65 ++++++++++++++ crates/cherry-rs/src/gui/windows/paraxial.rs | 89 ++++++++++---------- 3 files changed, 114 insertions(+), 46 deletions(-) diff --git a/crates/cherry-rs/src/gui/app.rs b/crates/cherry-rs/src/gui/app.rs index d5bfb78f..194db9eb 100644 --- a/crates/cherry-rs/src/gui/app.rs +++ b/crates/cherry-rs/src/gui/app.rs @@ -515,6 +515,8 @@ impl eframe::App for CherryApp { self.load_specs(examples::f_theta_scan_lens()); ui.close(); } + ui.separator(); + ui.label("Systems"); if ui .button("Galvo and Scan Lens (Negrean and Mansvelder)") .clicked() @@ -522,6 +524,10 @@ impl eframe::App for CherryApp { self.load_specs(examples::galvo_scan_lens_negrean_mansvelder()); ui.close(); } + if ui.button("Widefield Epifluorescence Excitation").clicked() { + self.load_specs(examples::wf_epi_excitation()); + ui.close(); + } }); ui.add_space(16.0); diff --git a/crates/cherry-rs/src/gui/examples.rs b/crates/cherry-rs/src/gui/examples.rs index 581caf0a..03a6ff4e 100644 --- a/crates/cherry-rs/src/gui/examples.rs +++ b/crates/cherry-rs/src/gui/examples.rs @@ -546,6 +546,59 @@ pub fn galvo_scan_lens_negrean_mansvelder() -> SystemSpecs { } } +/// Reduced widefield epifluorescence microscope excitation path. +/// +/// An object, a thin relay lens (f = 40 mm), a flat fold mirror at 45°, and a +/// second thin lens (f = 3.3333 mm) that acts as the aperture stop, modelling +/// a microscope objective back aperture immersed in oil (n = 1.5). The fold +/// mirror contributes no power; only the unfolded track length of 150 mm +/// between the two lenses matters for paraxial calculations. +pub fn wf_epi_excitation() -> SystemSpecs { + SystemSpecs { + surfaces: vec![ + SurfaceRow::new_object("40.0"), + SurfaceRow::new_thin_lens("25.0", "40.0", "100.0", "1.0"), + SurfaceRow { + variant: SurfaceVariant::Sphere, + boundary_variant: BoundaryVariant::Reflecting, + refractive_index: "1.0".into(), + thickness: "50.0".into(), + semi_diameter: "36.0".into(), + radius_of_curvature: "Infinity".into(), + conic_constant: String::new(), + focal_length: String::new(), + theta: "45".into(), + psi: "0".into(), + material_key: None, + }, + SurfaceRow::new_thin_lens("4.25", "3.3333", "1.0", "1.5"), + SurfaceRow::new_image(), + ], + fields: vec![FieldRow { + chi: "1.5".into(), + phi: "90.0".into(), + x: "0.0".into(), + }], + aperture_semi_diameter: "1.5454".into(), + wavelengths: vec!["0.5876".into()], + field_mode: FieldMode::PointSource, + use_materials: false, + selected_materials: Vec::new(), + cross_section_n_rays: 11, + full_pupil_spacing: "0.1".into(), + n_fan_rays: 65, + background_n: "1.0".into(), + background_material_key: None, + stop_surface: Some(3), + solves: vec![SolveSpec::MarginalRayHeight { + gap_index: 3, + target_height: 0.0, + wavelength_id: 0, + }], + lens_groups: Vec::new(), + } +} + /// f = +100 mm concave mirror. pub fn concave_mirror() -> SystemSpecs { SystemSpecs { @@ -625,6 +678,18 @@ mod tests { .expect("model"); } + #[test] + fn wf_epi_excitation_example_converts_to_valid_model() { + let specs = wf_epi_excitation(); + let parsed = parse(&specs); + SequentialModelBuilder::new() + .gap_specs(parsed.gaps) + .surface_specs(parsed.surfaces) + .wavelengths(parsed.wavelengths) + .build() + .expect("model"); + } + /// The M solve on the lens-to-image gap must resolve to the lens's back /// focal distance (== focal length, for a thin lens in air with an /// object at infinity), demonstrating that the image plane tracks the diff --git a/crates/cherry-rs/src/gui/windows/paraxial.rs b/crates/cherry-rs/src/gui/windows/paraxial.rs index e40a3928..2e6eb11b 100644 --- a/crates/cherry-rs/src/gui/windows/paraxial.rs +++ b/crates/cherry-rs/src/gui/windows/paraxial.rs @@ -130,59 +130,65 @@ fn render_table_body( tb_sep(body, n_cols); } - tb_row(body, row_h, "EFL", ids, pv, |sv| { + tb_row(body, row_h, "Effective focal length", ids, pv, |sv| { *sv.effective_focal_length() }); - tb_row(body, row_h, "BFD", ids, pv, |sv| *sv.back_focal_distance()); - tb_row(body, row_h, "FFD", ids, pv, |sv| *sv.front_focal_distance()); - tb_row(body, row_h, "Paraxial F/#", ids, pv, |sv| sv.paraxial_fno()); - tb_row(body, row_h, "Image space F/#", ids, pv, |sv| { - sv.image_space_fno() + tb_row(body, row_h, "Back focal distance", ids, pv, |sv| { + *sv.back_focal_distance() }); - - tb_sep(body, n_cols); - tb_row( body, row_h, - "Entrance pupil dist. from first surface", + "Back principal plane location", ids, pv, - |sv| sv.entrance_pupil().location, + |sv| *sv.back_principal_plane(), ); - tb_row(body, row_h, "Entrance pupil semi-diameter", ids, pv, |sv| { - sv.entrance_pupil().semi_diameter + tb_row(body, row_h, "Front focal length", ids, pv, |sv| { + *sv.front_focal_length() + }); + tb_row(body, row_h, "Front focal distance", ids, pv, |sv| { + *sv.front_focal_distance() }); tb_row( body, row_h, - "Exit pupil dist. from last surface", + "Front principal plane location", ids, pv, - |sv| sv.exit_pupil().location, + |sv| *sv.front_principal_plane(), ); - tb_row(body, row_h, "Exit pupil semi-diameter", ids, pv, |sv| { - sv.exit_pupil().semi_diameter + + tb_sep(body, n_cols); + + tb_row(body, row_h, "Paraxial F/#", ids, pv, |sv| sv.paraxial_fno()); + tb_row(body, row_h, "Image space F/#", ids, pv, |sv| { + sv.image_space_fno() + }); + + tb_row(body, row_h, "Lagrange invariant", ids, pv, |sv| { + let stop = *sv.aperture_stop(); + sv.lagrange_invariants() + .get(stop) + .copied() + .unwrap_or(f64::NAN) + .abs() }); tb_sep(body, n_cols); - tb_row( - body, - row_h, - "Front principal plane dist. from first surface", - ids, - pv, - |sv| *sv.front_principal_plane(), - ); - tb_row( - body, - row_h, - "Back principal plane dist. from last surface", - ids, - pv, - |sv| *sv.back_principal_plane(), - ); + tb_row(body, row_h, "Entrance pupil location", ids, pv, |sv| { + sv.entrance_pupil().location + }); + tb_row(body, row_h, "Entrance pupil semi-diameter", ids, pv, |sv| { + sv.entrance_pupil().semi_diameter + }); + tb_row(body, row_h, "Exit pupil location", ids, pv, |sv| { + sv.exit_pupil().location + }); + tb_row(body, row_h, "Exit pupil semi-diameter", ids, pv, |sv| { + sv.exit_pupil().semi_diameter + }); tb_sep(body, n_cols); @@ -201,15 +207,6 @@ fn render_table_body( }); } }); - - tb_row(body, row_h, "|H| (Lagrange invariant)", ids, pv, |sv| { - let stop = *sv.aperture_stop(); - sv.lagrange_invariants() - .get(stop) - .copied() - .unwrap_or(f64::NAN) - .abs() - }); } fn tb_sep(body: &mut egui_extras::TableBody<'_>, n_cols: usize) { @@ -340,9 +337,9 @@ mod tests { ParaxialWindow::show(ctx, &mut open, Some(&result)); }); harness.step(); - harness.get_by_label("EFL"); - harness.get_by_label("BFD"); - harness.get_by_label("FFD"); + harness.get_by_label("Effective focal length"); + harness.get_by_label("Back focal distance"); + harness.get_by_label("Front focal distance"); } #[test] @@ -405,7 +402,7 @@ mod tests { ParaxialWindow::show(ctx, &mut open, Some(&result)); }); harness.step(); - harness.get_by_label("|H| (Lagrange invariant)"); + harness.get_by_label("Lagrange invariant"); } #[test]