use glam::DVec3;
use pantometry::prelude::*;
use pantometry::scene::{Frame, Panel, PanelData};
use pantometry_optics::geometry::{cap_intersect, hexapolar_unit, plane_intersect, refract, Ray};
mod common;
use common::{check, check_between, heading};
const N_CROWN: f64 = 1.5168;
const N_FLINT: f64 = 1.6477;
const FOCAL: f64 = 100e-3;
const SEMI: f64 = 8.0e-3;
const GLASS: f64 = 9.5e-3;
const FOLD_AT: f64 = 60e-3;
const BACK_Z: f64 = 7e-3;
const FIELDS: [f64; 3] = [0.0, 1.5, 3.0];
fn main() {
heading("A doublet, from the powers it has to add up to");
let split = 1.55;
let (r_crown, r_cement) = curvatures(split);
println!(
" {:<30} {:>9.3} mm air to crown",
"front radius",
r_crown * 1e3
);
println!(
" {:<30} {:>9.3} mm crown to flint",
"cemented radius",
r_cement * 1e3
);
println!(" {:<30} {:>9} flint to air", "back", "flat");
let phi = 1.0 / FOCAL;
check(
"the three powers add to 1/f",
1.0 / (phi * split + (phi - phi * split)),
FOCAL,
1e-12,
"m",
);
heading("The bench, placed in three dimensions");
let mut bench = Bench {
crown_r: r_crown,
cement_r: r_cement,
fold_at: FOLD_AT,
fold_normal: DVec3::new(-1.0, 0.0, 1.0).normalize(),
image_along: 0.0,
};
let (f_measured, bfd) = bench.measure().expect("a paraxial ray gets through");
bench.image_along = bfd - (FOLD_AT - BACK_Z);
println!(
" {:<30} {:>9.3} mm measured from a traced ray, against {:.1} thin-lens",
"effective focal length",
f_measured * 1e3,
FOCAL * 1e3
);
println!(
" {:<30} {:>9.3} mm from the back face, so the plane is {:.1} mm past the mirror",
"back focal distance",
bfd * 1e3,
bench.image_along * 1e3
);
check_between(
"a thick lens focuses shorter than its thin-lens prescription",
bfd / f_measured,
0.90,
1.0,
"x",
);
println!(" {:<30} {:>9.1} mm along +z", "doublet at", 0.0);
println!(
" {:<30} {:>9.1} mm normal at 45 degrees",
"fold mirror at",
bench.fold_at * 1e3
);
println!(
" {:<30} {:>9.1} mm along +x after the fold",
"image plane at",
bench.image_along * 1e3
);
heading("Rays through it, and the closed forms they must obey");
let Traced { spots, .. } = trace_fields(&bench);
for (k, angle_deg) in FIELDS.iter().enumerate() {
println!(
" {angle_deg:>4.1} deg {:>3} of {:>3} rays through",
spots[k].len(),
PUPIL_RAYS
);
assert!(
spots[k].len() * 10 >= PUPIL_RAYS * 9,
"at least 90% of the pupil must make it, or the bench is vignetting"
);
}
let axial = Ray::new(LengthVec::from_si(DVec3::new(0.0, 0.0, 0.0)), DVec3::Z);
let turned = reflect(axial.dir, bench.fold_normal);
println!(
" {:<30} ({:.6}, {:.6}, {:.6})",
"axis after the fold", turned.x, turned.y, turned.z
);
check("the fold turns the axis to +x", turned.x, 1.0, 1e-12, "");
check_between("and leaves nothing along z", turned.z.abs(), 0.0, 1e-12, "");
heading("Bending: one parameter, scanned for the smallest spot");
let scan: Vec<(f64, f64)> = (0..=28)
.filter_map(|k| {
let s = 1.0 + 1.4 * k as f64 / 28.0;
on_axis_rms(&bench, s).map(|spot| (s, spot))
})
.collect();
let &(best_split, best_rms) = scan
.iter()
.min_by(|a, b| a.1.total_cmp(&b.1))
.expect("some split traces");
for (s, spot) in scan.iter().step_by(4) {
println!(
" split {s:>5.2} RMS spot {:>8.2} um{}",
spot * 1e6,
if (*s - best_split).abs() < 1e-12 {
" <- best"
} else {
""
}
);
}
let started = on_axis_rms(&bench, split).expect("the starting split traces");
println!(
" {:<30} {:>9.2} um -> {:.2} um at split {best_split:.2}",
"bending the doublet",
started * 1e6,
best_rms * 1e6
);
check_between(
"bending is worth something",
started / best_rms,
1.5,
500.0,
"x",
);
assert!(
best_split > scan[0].0 && best_split < scan[scan.len() - 1].0,
"the optimum is at the edge of the scan: {best_split}"
);
let (br, bc) = curvatures(best_split);
bench.crown_r = br;
bench.cement_r = bc;
let (f_bent, bfd_bent) = bench.measure().expect("the bent doublet still focuses");
bench.image_along = bfd_bent - (FOLD_AT - BACK_Z);
check("bending does not move f", f_bent, f_measured, 0.03, "m");
let Traced {
paths,
colours,
spots,
} = trace_fields(&bench);
for (k, angle_deg) in FIELDS.iter().enumerate() {
let theta = angle_deg.to_radians();
let centroid = centroid_of(&spots[k]);
let want = f_bent * theta.tan();
if *angle_deg == 0.0 {
check_between(
"on axis the centroid is on axis",
centroid.1.abs(),
0.0,
2e-5,
"m",
);
} else {
check(
&format!("{angle_deg:.1} deg lands at f tan(theta)"),
centroid.1.abs(),
want,
0.06,
"m",
);
}
}
heading("Is it in focus? A number, not a look");
let rms = rms_radius(&spots[0]);
let na = SEMI / f_bent;
let airy = 0.61 * 587.6e-9 / na;
println!(
" {:<30} {:>9.2} um RMS spot radius on axis",
"geometric",
rms * 1e6
);
println!(
" {:<30} {:>9.2} um 0.61 lambda / NA",
"diffraction limit",
airy * 1e6
);
println!(
" {:<30} {:>9.2}x the Airy radius",
"the geometric spot is",
rms / airy
);
check_between(
"the bent design is diffraction-limited",
rms / airy,
0.0,
1.0,
"x the Airy radius",
);
println!(
" {:<30} {:>9.1}x before bending, which the scan removed",
"it was",
started / airy
);
if let Some(path) = common::output_path() {
let frame = Frame {
time_s: 0.0,
panels: vec![Panel {
name: "bench".into(),
unit: "deg field",
data: PanelData::paths(paths, colours),
}],
readings: vec![
Reading::new("bench", "focal length", f_bent * 1e3, "mm"),
Reading::new("bench", "semi-aperture", SEMI * 1e3, "mm"),
Reading::new("bench", "rms spot", rms * 1e6, "um"),
Reading::new("bench", "diffraction limit", airy * 1e6, "um"),
],
};
let frames = std::slice::from_ref(&frame);
let asset = if path.ends_with(".json") {
pantometry::view::to_json("optical bench", frames)
} else if path.ends_with(".gltf") {
let out = pantometry::view::gltf("optical bench", &frame);
for note in &out.skipped {
println!(" not exported: {note}");
}
out.document
} else {
pantometry::view::html("optical bench", frames)
};
common::write(&path, &asset);
println!("\n drag to rotate, scroll to zoom");
} else {
println!("\n give a filename ending .html for the 3D layout");
}
}
const PUPIL_RAYS: usize = 61;
fn curvatures(split: f64) -> (f64, f64) {
let phi = 1.0 / FOCAL;
(
(N_CROWN - 1.0) / (phi * split),
(N_FLINT - N_CROWN) / (phi - phi * split),
)
}
type TracedRay = (Vec<[f64; 3]>, (f64, f64));
struct Traced {
paths: Vec<Vec<[f64; 3]>>,
colours: Vec<f64>,
spots: Vec<Vec<(f64, f64)>>,
}
fn trace_fields(bench: &Bench) -> Traced {
let pupil = hexapolar_unit(4);
let (mut paths, mut colours, mut spots) = (Vec::new(), Vec::new(), Vec::new());
for angle_deg in FIELDS {
let theta = angle_deg.to_radians();
let dir = DVec3::new(0.0, theta.sin(), theta.cos()).normalize();
let mut landed = Vec::new();
for (u, v) in &pupil {
let at_pupil = DVec3::new(u * SEMI, v * SEMI, 0.0);
let ray = Ray::new(LengthVec::from_si(at_pupil - dir * 40e-3), dir);
if let Some((path, hit)) = bench.trace(ray) {
landed.push(hit);
paths.push(path);
colours.push(angle_deg);
}
}
spots.push(landed);
}
Traced {
paths,
colours,
spots,
}
}
fn on_axis_rms(bench: &Bench, split: f64) -> Option<f64> {
let (crown_r, cement_r) = curvatures(split);
let mut trial = Bench {
crown_r,
cement_r,
image_along: 0.0,
..*bench
};
let (_, bfd) = trial.measure()?;
trial.image_along = bfd - (FOLD_AT - BACK_Z);
let landed = trace_fields(&trial).spots;
let axial = landed.first()?;
(axial.len() * 10 >= PUPIL_RAYS * 9).then(|| rms_radius(axial))
}
#[derive(Clone, Copy)]
struct Bench {
crown_r: f64,
cement_r: f64,
fold_at: f64,
fold_normal: DVec3,
image_along: f64,
}
impl Bench {
fn trace(&self, ray: Ray) -> Option<TracedRay> {
let mut path = vec![point(ray.origin)];
let mut r = self.lens(&mut path, ray)?;
let t = plane_intersect(
r,
LengthVec::from_si(DVec3::new(0.0, 0.0, self.fold_at)),
self.fold_normal,
)?;
path.push(point(r.at(t)));
r = r.redirect(t, reflect(r.dir, self.fold_normal));
let t = plane_intersect(
r,
LengthVec::from_si(DVec3::new(self.image_along, 0.0, self.fold_at)),
DVec3::X,
)?;
let end = r.at(t);
path.push(point(end));
Some((path, (end.to_si().z - self.fold_at, end.to_si().y)))
}
fn lens(&self, path: &mut Vec<[f64; 3]>, ray: Ray) -> Option<Ray> {
let mut r = ray;
let hit = cap_intersect(
r,
LengthVec::ZERO,
DVec3::Z,
Length::from_si(self.crown_r),
Length::from_si(GLASS),
)?;
r = self.bend(path, r, hit.t, hit.normal, 1.0, N_CROWN)?;
let hit = cap_intersect(
r,
LengthVec::from_si(DVec3::new(0.0, 0.0, 4e-3)),
DVec3::Z,
Length::from_si(self.cement_r),
Length::from_si(GLASS),
)?;
r = self.bend(path, r, hit.t, hit.normal, N_CROWN, N_FLINT)?;
let t = plane_intersect(
r,
LengthVec::from_si(DVec3::new(0.0, 0.0, BACK_Z)),
DVec3::Z,
)?;
self.bend(path, r, t, DVec3::Z, N_FLINT, 1.0)
}
fn measure(&self) -> Option<(f64, f64)> {
let h = 0.5e-3;
let mut path = Vec::new();
let entering = Ray::new(LengthVec::from_si(DVec3::new(0.0, h, -40e-3)), DVec3::Z);
let out = self.lens(&mut path, entering)?;
let (o, d) = (out.origin.to_si(), out.dir);
let t = -o.y / d.y;
let crossing = o.z + d.z * t;
let angle = (-d.y / d.z).atan();
Some((h / angle.tan(), crossing - BACK_Z))
}
fn bend(
&self,
path: &mut Vec<[f64; 3]>,
r: Ray,
t: Length,
normal: DVec3,
n1: f64,
n2: f64,
) -> Option<Ray> {
path.push(point(r.at(t)));
let n = oriented_against(normal, r.dir);
let dir = refract(r.dir, n, n1 / n2)?;
Some(r.redirect(t, dir))
}
}
fn point(p: LengthVec) -> [f64; 3] {
let v = p.to_si();
[v.x, v.y, v.z]
}
fn centroid_of(spots: &[(f64, f64)]) -> (f64, f64) {
let n = spots.len().max(1) as f64;
(
spots.iter().map(|(a, _)| a).sum::<f64>() / n,
spots.iter().map(|(_, b)| b).sum::<f64>() / n,
)
}
fn rms_radius(spots: &[(f64, f64)]) -> f64 {
let (cx, cy) = centroid_of(spots);
let n = spots.len().max(1) as f64;
(spots
.iter()
.map(|(a, b)| (a - cx).powi(2) + (b - cy).powi(2))
.sum::<f64>()
/ n)
.sqrt()
}