mod common;
use common::svg::{document, rgb, ticks, Plot};
use common::{check_between, heading};
use glam::DVec3;
use pantometry::prelude::*;
use pantometry_core::oriented_against;
use pantometry_optics::geometry::{cap_intersect, hexapolar_unit, refract, Ray};
use pantometry_optics::material::Material;
struct Surface {
z: f64,
radius: f64,
semi: f64,
after: Material,
}
fn doublet() -> Vec<Surface> {
let crown = Material::from_catalog("N-BK7").expect("N-BK7 is in the catalogue");
let flint = Material::from_catalog("F2").expect("F2 is in the catalogue");
let (v1, v2) = (crown.abbe(), flint.abbe());
let (n1, n2) = (crown.n_d(), flint.n_d());
let power = 1.0 / 0.100;
let phi1 = power * v1 / (v1 - v2);
let phi2 = -power * v2 / (v1 - v2);
let r1 = 0.0619;
let r2 = 1.0 / (1.0 / r1 - phi1 / (n1 - 1.0));
let r3 = 1.0 / (1.0 / r2 - phi2 / (n2 - 1.0));
println!(
" solved from V = {v1:.1} and {v2:.1}: R1 {:.2} mm, R2 {:.2} mm, R3 {:.2} mm",
r1 * 1e3,
r2 * 1e3,
r3 * 1e3
);
vec![
Surface {
z: 0.0,
radius: r1,
semi: 0.010,
after: crown,
},
Surface {
z: 0.0040,
radius: r2,
semi: 0.010,
after: flint,
},
Surface {
z: 0.0065,
radius: r3,
semi: 0.010,
after: Material::air(),
},
]
}
fn singlet_like(_doublet: &[Surface]) -> Vec<Surface> {
let crown = Material::from_catalog("N-BK7").expect("N-BK7 is in the catalogue");
let r = 2.0 * (crown.n_d() - 1.0) * 0.100;
vec![
Surface {
z: 0.0,
radius: r,
semi: 0.010,
after: crown,
},
Surface {
z: 0.004,
radius: -r,
semi: 0.010,
after: Material::air(),
},
]
}
fn focus_of(height: f64, wavelength: Length, surfaces: &[Surface]) -> Option<f64> {
let mut ray = Ray::new(LengthVec::m(height, 0.0, -0.05), DVec3::new(0.0, 0.0, 1.0));
let mut before = Material::air();
for s in surfaces {
let Some(hit) = cap_intersect(
ray,
LengthVec::m(0.0, 0.0, s.z),
DVec3::new(0.0, 0.0, 1.0),
Length::m(s.radius),
Length::m(s.semi),
) else {
if std::env::var("TRACE").is_ok() {
eprintln!(" MISS at z={}", s.z);
}
return None;
};
let eta = before.index(wavelength) / s.after.index(wavelength);
let n = oriented_against(hit.normal, ray.dir);
let Some(dir) = refract(ray.dir, n, eta) else {
if std::env::var("TRACE").is_ok() {
eprintln!(" TIR at z={} eta={eta}", s.z);
}
return None;
};
if std::env::var("TRACE").is_ok() {
eprintln!(
" z={} t={:.6} normal={:?} dir={:?}",
s.z,
hit.t.to_si(),
hit.normal,
dir
);
}
ray = ray.redirect(hit.t, dir);
before = s.after.clone();
}
let p = ray.origin.to_si();
if ray.dir.x.abs() < 1e-15 {
return None;
}
let t = -p.x / ray.dir.x;
let z = p.z + ray.dir.z * t;
Some(z - surfaces.last()?.z)
}
fn thin_lens_focal(wavelength: Length, surfaces: &[Surface]) -> f64 {
let mut power = 0.0;
let mut before = Material::air().index(wavelength);
for s in surfaces {
let after = s.after.index(wavelength);
power += (after - before) / s.radius;
before = after;
}
1.0 / power
}
fn main() {
let d = Length::nm(587.6); let f_line = Length::nm(486.1); let c_line = Length::nm(656.3); let lens = doublet();
heading("Where the rays say the focus is, against the lensmaker's equation");
let paraxial = focus_of(1e-4, d, &lens).expect("a paraxial ray gets through");
let thin = thin_lens_focal(d, &lens);
check_between(
"traced paraxial focal length",
paraxial,
thin * 0.95,
thin * 1.05,
"m",
);
println!(
" thin-lens estimate {:.4} m, traced {:.4} m — the thin form ignores 6.5 mm of glass",
thin, paraxial
);
heading("Spherical aberration: which way each design bends the edge of the pupil");
let singlet = singlet_like(&lens);
let s_para = focus_of(1e-4, d, &singlet).expect("paraxial through the singlet");
let s_marg = focus_of(0.0095, d, &singlet).expect("marginal through the singlet");
check_between(
"singlet: marginal focus minus paraxial",
s_marg - s_para,
-0.01,
0.0,
"m",
);
let marginal = focus_of(0.0095, d, &lens).expect("the marginal ray gets through");
let longitudinal = marginal - paraxial;
println!(
" singlet {:+.3} mm, doublet {:+.3} mm — the singlet undercorrects, this doublet overcorrects",
(s_marg - s_para) * 1e3,
longitudinal * 1e3
);
check_between(
"doublet spherical, as a fraction of the singlet's",
longitudinal.abs() / (s_marg - s_para).abs(),
0.0,
0.6,
"x",
);
heading("The achromat earns its name");
let (ff, fc) = (
focus_of(1e-4, f_line, &lens).expect("blue gets through"),
focus_of(1e-4, c_line, &lens).expect("red gets through"),
);
let doublet_spread = (ff - fc).abs();
let singlet_spread = (focus_of(1e-4, f_line, &singlet).unwrap()
- focus_of(1e-4, c_line, &singlet).unwrap())
.abs();
check_between(
"doublet F-to-C spread, as a fraction of the singlet's",
doublet_spread / singlet_spread,
0.0,
0.25,
"x",
);
println!(
" doublet {:.3} mm against singlet {:.3} mm — {:.0}x tighter",
doublet_spread * 1e3,
singlet_spread * 1e3,
singlet_spread / doublet_spread
);
heading("The spot the whole pupil makes, at the paraxial focus");
let plane = lens.last().unwrap().z + paraxial;
let mut radii = Vec::new();
for (u, v) in hexapolar_unit(6) {
let h = (u * u + v * v).sqrt() * 0.0095;
if h < 1e-9 {
continue;
}
if let Some(f) = focus_of(h, d, &lens) {
let z_focus = lens.last().unwrap().z + f;
let miss = h * (plane - z_focus) / (z_focus - lens.last().unwrap().z).max(1e-12);
radii.push(miss.abs());
}
}
radii.sort_by(|a, b| a.partial_cmp(b).unwrap());
let rms = (radii.iter().map(|r| r * r).sum::<f64>() / radii.len() as f64).sqrt();
println!(
" {} rays through, RMS spot radius {:.1} um",
radii.len(),
rms * 1e6
);
let airy = 1.22 * d.to_si() * paraxial / (2.0 * 0.0095);
println!(
" the diffraction limit for this aperture is {:.1} um",
airy * 1e6
);
assert!(
rms > airy * 0.1,
"a geometric spot far under the Airy radius means the trace is not tracing"
);
if let Some(path) = std::env::args().nth(1) {
let svg = draw(&lens, d, f_line, c_line);
common::write(&path, &svg);
println!("\n wrote {path}");
} else {
println!("\n give a filename to draw the ray fan");
}
}
fn draw(lens: &[Surface], d: Length, f: Length, c: Length) -> String {
let z_end = (lens.last().unwrap().z + focus_of(1e-4, d, lens).unwrap()) * 1e3;
let mut plot = Plot::new(880.0, 420.0, (-12.0, z_end * 1.06), (-12.0, 12.0))
.viewport(64.0, 54.0, 800.0, 320.0);
for (wavelength, colour) in [
(c, rgb(226, 78, 52)),
(d, rgb(238, 196, 62)),
(f, rgb(74, 132, 238)),
] {
for k in -6..=6 {
let h = k as f64 * 0.0095 / 6.0;
if h.abs() < 1e-9 {
continue;
}
let Some(focus) = focus_of(h, wavelength, lens) else {
continue;
};
let z_focus = (lens.last().unwrap().z + focus) * 1e3;
plot.polyline([(-12.0, h * 1e3), (0.0, h * 1e3)], &colour, 1.0);
plot.polyline(
[(lens.last().unwrap().z * 1e3, h * 1e3), (z_focus, 0.0)],
&colour,
1.0,
);
}
}
for s in lens {
plot.polyline(
[(s.z * 1e3, -s.semi * 1e3), (s.z * 1e3, s.semi * 1e3)],
&rgb(120, 122, 132),
1.8,
);
}
plot.polyline(
[(-12.0, 0.0), (z_end * 1.06, 0.0)],
&rgb(150, 152, 162),
0.8,
);
plot.axes(
&ticks(-12.0, z_end * 1.06, 8),
&ticks(-12.0, 12.0, 5),
|v| format!("{v:.0}"),
|v| format!("{v:.0}"),
);
plot.title("a cemented achromat, traced");
plot.caption("z (mm) against ray height (mm) — red C, yellow d, blue F");
plot.footnote(
"the three colours cross the axis within a tenth of a millimetre of each other, which is what makes it an achromat",
);
document(880.0, 420.0, [plot.finish()])
}