1pub const MAX_GLASS_PROFILE_KNOTS: usize = 6;
5
6const POSITION_EPSILON: f32 = 1.0e-5;
7
8#[derive(Clone, Copy, Debug, PartialEq)]
10pub struct GlassProfileKnot {
11 position: f32,
12 height: f32,
13 tangent: f32,
14}
15
16impl GlassProfileKnot {
17 const ZERO: Self = Self {
18 position: 0.0,
19 height: 0.0,
20 tangent: 0.0,
21 };
22
23 pub fn position(self) -> f32 {
24 self.position
25 }
26
27 pub fn height(self) -> f32 {
28 self.height
29 }
30
31 pub fn tangent(self) -> f32 {
32 self.tangent
33 }
34}
35
36#[derive(Clone, Copy, Debug, PartialEq, thiserror::Error)]
38pub enum GlassProfileError {
39 #[error("a glass profile needs at least two knots")]
40 TooFewKnots,
41 #[error("a glass profile supports at most {MAX_GLASS_PROFILE_KNOTS} knots, got {count}")]
42 TooManyKnots { count: usize },
43 #[error("glass profile knot {index} contains a non-finite coordinate")]
44 NonFiniteKnot { index: usize },
45 #[error("glass profile knot {index} must stay in normalized 0..1 space")]
46 KnotOutOfRange { index: usize },
47 #[error("glass profile positions must start at 0 and end at 1")]
48 MissingEndpoints,
49 #[error("glass profile positions must increase strictly at knot {index}")]
50 PositionsNotIncreasing { index: usize },
51 #[error("X-Z and Y-Z profiles must share their center height")]
52 CenterHeightMismatch,
53 #[error("glass profile depth must be finite and non-negative")]
54 InvalidDepth,
55 #[error("glass profile radial power must be finite and in 1.5..8")]
56 InvalidRadialPower,
57 #[error("glass profile axis coupling must be finite and in 0..1")]
58 InvalidAxisCoupling,
59}
60
61#[derive(Clone, Copy, Debug, PartialEq)]
67pub struct GlassProfileCurve {
68 knots: [GlassProfileKnot; MAX_GLASS_PROFILE_KNOTS],
69 len: u8,
70}
71
72impl GlassProfileCurve {
73 pub fn from_points(points: &[(f32, f32)]) -> Result<Self, GlassProfileError> {
74 if points.len() < 2 {
75 return Err(GlassProfileError::TooFewKnots);
76 }
77 if points.len() > MAX_GLASS_PROFILE_KNOTS {
78 return Err(GlassProfileError::TooManyKnots {
79 count: points.len(),
80 });
81 }
82
83 let mut knots = [GlassProfileKnot::ZERO; MAX_GLASS_PROFILE_KNOTS];
84 for (index, &(position, height)) in points.iter().enumerate() {
85 if !position.is_finite() || !height.is_finite() {
86 return Err(GlassProfileError::NonFiniteKnot { index });
87 }
88 if !(0.0..=1.0).contains(&position) || !(0.0..=1.0).contains(&height) {
89 return Err(GlassProfileError::KnotOutOfRange { index });
90 }
91 if index > 0 && position <= points[index - 1].0 {
92 return Err(GlassProfileError::PositionsNotIncreasing { index });
93 }
94 knots[index] = GlassProfileKnot {
95 position,
96 height,
97 tangent: 0.0,
98 };
99 }
100 if points[0].0.abs() > POSITION_EPSILON
101 || (points[points.len() - 1].0 - 1.0).abs() > POSITION_EPSILON
102 {
103 return Err(GlassProfileError::MissingEndpoints);
104 }
105
106 generate_monotone_tangents(&mut knots[..points.len()]);
107 Ok(Self {
108 knots,
109 len: points.len() as u8,
110 })
111 }
112
113 pub fn knots(&self) -> &[GlassProfileKnot] {
114 &self.knots[..self.len as usize]
115 }
116
117 pub fn evaluate(&self, position: f32) -> (f32, f32) {
119 let knots = self.knots();
120 let position = position.clamp(0.0, 1.0);
121 if position <= knots[0].position {
122 return (knots[0].height, knots[0].tangent);
123 }
124 let last = knots.len() - 1;
125 if position >= knots[last].position {
126 return (knots[last].height, knots[last].tangent);
127 }
128 let segment = knots
129 .windows(2)
130 .position(|pair| position <= pair[1].position)
131 .unwrap_or(last - 1);
132 evaluate_hermite(knots[segment], knots[segment + 1], position)
133 }
134
135 fn constant(height: f32) -> Self {
136 Self::from_points(&[(0.0, height), (1.0, height)])
137 .expect("constant normalized profile is valid")
138 }
139}
140
141impl Default for GlassProfileCurve {
142 fn default() -> Self {
143 Self::constant(0.5)
144 }
145}
146
147#[derive(Clone, Copy, Debug, PartialEq)]
150pub struct GlassSurfaceProfile {
151 x_profile: GlassProfileCurve,
152 y_profile: GlassProfileCurve,
153 depth: f32,
154 radial_power: f32,
155 axis_coupling: f32,
156}
157
158#[derive(Clone, Copy, Debug, PartialEq)]
159pub struct GlassSurfaceSample {
160 pub height: f32,
161 pub gradient: (f32, f32),
162 pub radial_position: f32,
163}
164
165impl GlassSurfaceProfile {
166 pub fn new(
167 x_profile: GlassProfileCurve,
168 y_profile: GlassProfileCurve,
169 depth: f32,
170 radial_power: f32,
171 ) -> Result<Self, GlassProfileError> {
172 if !depth.is_finite() || depth < 0.0 {
173 return Err(GlassProfileError::InvalidDepth);
174 }
175 if !radial_power.is_finite() || !(1.5..=8.0).contains(&radial_power) {
176 return Err(GlassProfileError::InvalidRadialPower);
177 }
178 if (x_profile.knots()[0].height - y_profile.knots()[0].height).abs() > POSITION_EPSILON {
179 return Err(GlassProfileError::CenterHeightMismatch);
180 }
181 Ok(Self {
182 x_profile,
183 y_profile,
184 depth,
185 radial_power,
186 axis_coupling: 0.0,
187 })
188 }
189
190 pub fn isotropic(
191 profile: GlassProfileCurve,
192 depth: f32,
193 radial_power: f32,
194 ) -> Result<Self, GlassProfileError> {
195 Self::new(profile, profile, depth, radial_power)
196 }
197
198 pub fn flat() -> Self {
199 Self::isotropic(GlassProfileCurve::default(), 0.0, 2.0)
200 .expect("flat surface profile is valid")
201 }
202
203 pub fn regular() -> Self {
204 let curve = GlassProfileCurve::from_points(&[
205 (0.0, 0.10),
206 (0.50, 0.10),
207 (0.70, 0.28),
208 (0.86, 1.00),
209 (1.0, 0.48),
210 ])
211 .expect("regular surface profile is valid");
212 Self::isotropic(curve, 4.0, 3.6).expect("regular surface profile is coherent")
213 }
214
215 pub fn lens() -> Self {
216 let profile = GlassProfileCurve::from_points(&[
217 (0.0, 0.05),
218 (0.16, 0.094),
219 (0.30, 0.28),
220 (0.52, 0.55),
221 (0.76, 1.00),
222 (1.0, 0.45),
223 ])
224 .expect("lens profile is valid");
225 Self::isotropic(profile, 5.5, 3.2).expect("lens surface profile is coherent")
226 }
227
228 pub fn x_profile(self) -> GlassProfileCurve {
229 self.x_profile
230 }
231
232 pub fn y_profile(self) -> GlassProfileCurve {
233 self.y_profile
234 }
235
236 pub fn depth(self) -> f32 {
237 self.depth
238 }
239
240 pub fn radial_power(self) -> f32 {
241 self.radial_power
242 }
243
244 pub fn axis_coupling(self) -> f32 {
245 self.axis_coupling
246 }
247
248 pub fn sample_normalized(self, position: (f32, f32)) -> GlassSurfaceSample {
249 let x = position.0.clamp(-1.0, 1.0);
250 let y = position.1.clamp(-1.0, 1.0);
251 let abs_x = x.abs();
252 let abs_y = y.abs();
253 let a = abs_x.powf(self.radial_power);
254 let b = abs_y.powf(self.radial_power);
255 let q = a + b;
256 if q <= 1.0e-6 {
257 return GlassSurfaceSample {
258 height: self.x_profile.evaluate(0.0).0,
259 gradient: (0.0, 0.0),
260 radial_position: 0.0,
261 };
262 }
263 let radial = q.powf(1.0 / self.radial_power);
264 let profile_position = radial.clamp(0.0, 1.0);
265 let x_sample = self.x_profile.evaluate(profile_position);
266 let y_sample = self.y_profile.evaluate(profile_position);
267 let y_weight = b / q;
268 let sign_x = if x < 0.0 { -1.0 } else { 1.0 };
269 let sign_y = if y < 0.0 { -1.0 } else { 1.0 };
270 let x_power = abs_x.powf(self.radial_power - 1.0) * sign_x;
271 let y_power = abs_y.powf(self.radial_power - 1.0) * sign_y;
272 let radial_factor = q.powf(1.0 / self.radial_power - 1.0);
273 let radial_derivative = x_sample.1 + (y_sample.1 - x_sample.1) * y_weight;
274 let profile_delta = y_sample.0 - x_sample.0;
275 let q_squared = q * q;
276 let oval_height = x_sample.0 + (y_sample.0 - x_sample.0) * y_weight;
277 let oval_gradient = (
278 radial_derivative * radial_factor * x_power
279 - profile_delta * b * self.radial_power * x_power / q_squared,
280 radial_derivative * radial_factor * y_power
281 + profile_delta * a * self.radial_power * y_power / q_squared,
282 );
283 let x_axis_sample = self.x_profile.evaluate(abs_x);
284 let y_axis_sample = self.y_profile.evaluate(abs_y);
285 let center_height = self.x_profile.evaluate(0.0).0;
286 let toric_height = x_axis_sample.0 + y_axis_sample.0 - center_height;
287 let toric_gradient = (x_axis_sample.1 * sign_x, y_axis_sample.1 * sign_y);
288 let coupling = self.axis_coupling;
289 let height_delta = toric_height - oval_height;
290 GlassSurfaceSample {
291 height: oval_height + height_delta * coupling,
292 gradient: (
293 oval_gradient.0 + (toric_gradient.0 - oval_gradient.0) * coupling,
294 oval_gradient.1 + (toric_gradient.1 - oval_gradient.1) * coupling,
295 ),
296 radial_position: profile_position,
297 }
298 }
299
300 pub fn with_depth(self, depth: f32) -> Result<Self, GlassProfileError> {
301 let mut profile = Self::new(self.x_profile, self.y_profile, depth, self.radial_power)?;
302 profile.axis_coupling = self.axis_coupling;
303 Ok(profile)
304 }
305
306 pub fn with_radial_power(self, radial_power: f32) -> Result<Self, GlassProfileError> {
307 let mut profile = Self::new(self.x_profile, self.y_profile, self.depth, radial_power)?;
308 profile.axis_coupling = self.axis_coupling;
309 Ok(profile)
310 }
311
312 pub fn with_axis_coupling(mut self, axis_coupling: f32) -> Result<Self, GlassProfileError> {
313 if !axis_coupling.is_finite() || !(0.0..=1.0).contains(&axis_coupling) {
314 return Err(GlassProfileError::InvalidAxisCoupling);
315 }
316 self.axis_coupling = axis_coupling;
317 Ok(self)
318 }
319}
320
321impl Default for GlassSurfaceProfile {
322 fn default() -> Self {
323 Self::regular()
324 }
325}
326
327fn generate_monotone_tangents(knots: &mut [GlassProfileKnot]) {
328 let segment_count = knots.len() - 1;
329 let mut widths = [0.0; MAX_GLASS_PROFILE_KNOTS - 1];
330 let mut slopes = [0.0; MAX_GLASS_PROFILE_KNOTS - 1];
331 for index in 0..segment_count {
332 widths[index] = knots[index + 1].position - knots[index].position;
333 slopes[index] = (knots[index + 1].height - knots[index].height) / widths[index];
334 }
335
336 knots[0].tangent = 0.0;
340 knots[segment_count].tangent = slopes[segment_count - 1];
341 for index in 1..segment_count {
342 let before = slopes[index - 1];
343 let after = slopes[index];
344 knots[index].tangent = if before * after <= 0.0 {
345 0.0
346 } else {
347 let before_width = widths[index - 1];
348 let after_width = widths[index];
349 let first_weight = 2.0 * after_width + before_width;
350 let second_weight = after_width + 2.0 * before_width;
351 (first_weight + second_weight) / (first_weight / before + second_weight / after)
352 };
353 }
354}
355
356fn evaluate_hermite(start: GlassProfileKnot, end: GlassProfileKnot, position: f32) -> (f32, f32) {
357 let width = end.position - start.position;
358 let t = ((position - start.position) / width).clamp(0.0, 1.0);
359 let t2 = t * t;
360 let t3 = t2 * t;
361 let h00 = 2.0 * t3 - 3.0 * t2 + 1.0;
362 let h10 = t3 - 2.0 * t2 + t;
363 let h01 = -2.0 * t3 + 3.0 * t2;
364 let h11 = t3 - t2;
365 let height = h00 * start.height
366 + h10 * width * start.tangent
367 + h01 * end.height
368 + h11 * width * end.tangent;
369
370 let dh00 = 6.0 * t2 - 6.0 * t;
371 let dh10 = 3.0 * t2 - 4.0 * t + 1.0;
372 let dh01 = -dh00;
373 let dh11 = 3.0 * t2 - 2.0 * t;
374 let tangent = (dh00 * start.height
375 + dh10 * width * start.tangent
376 + dh01 * end.height
377 + dh11 * width * end.tangent)
378 / width;
379 (height, tangent)
380}
381
382#[cfg(test)]
383mod tests {
384 use super::*;
385
386 #[test]
387 fn curve_validates_normalized_strictly_ordered_points() {
388 assert_eq!(
389 GlassProfileCurve::from_points(&[(0.0, 0.0)]),
390 Err(GlassProfileError::TooFewKnots)
391 );
392 assert_eq!(
393 GlassProfileCurve::from_points(&[(0.0, 0.0), (0.5, 0.5), (0.5, 1.0)]),
394 Err(GlassProfileError::PositionsNotIncreasing { index: 2 })
395 );
396 assert_eq!(
397 GlassProfileCurve::from_points(&[(0.1, 0.0), (1.0, 1.0)]),
398 Err(GlassProfileError::MissingEndpoints)
399 );
400 assert_eq!(
401 GlassProfileCurve::from_points(&[(0.0, -0.1), (1.0, 1.0)]),
402 Err(GlassProfileError::KnotOutOfRange { index: 0 })
403 );
404 assert_eq!(
405 GlassProfileCurve::from_points(&[(0.0, 0.0), (1.0, f32::NAN)]),
406 Err(GlassProfileError::NonFiniteKnot { index: 1 })
407 );
408 }
409
410 #[test]
411 fn curve_evaluation_is_continuous_and_does_not_overshoot_knots() {
412 let curve =
413 GlassProfileCurve::from_points(&[(0.0, 0.1), (0.4, 0.1), (0.75, 1.0), (1.0, 0.4)])
414 .expect("profile");
415 assert_eq!(curve.evaluate(0.0), (0.1, 0.0));
416 assert!((curve.evaluate(1.0).0 - 0.4).abs() < 1.0e-6);
417 for step in 0..=100 {
418 let height = curve.evaluate(step as f32 / 100.0).0;
419 assert!((0.1 - 1.0e-5..=1.0 + 1.0e-5).contains(&height));
420 }
421 let left = curve.evaluate(0.75 - 1.0e-4).1;
422 let right = curve.evaluate(0.75 + 1.0e-4).1;
423 assert!((left - right).abs() < 0.02, "cubic tangent must stay C1");
424 }
425
426 #[test]
427 fn surface_requires_a_coherent_center_and_physical_parameters() {
428 let low = GlassProfileCurve::from_points(&[(0.0, 0.1), (1.0, 1.0)]).expect("low profile");
429 let high = GlassProfileCurve::from_points(&[(0.0, 0.2), (1.0, 1.0)]).expect("high profile");
430 assert_eq!(
431 GlassSurfaceProfile::new(low, high, 4.0, 2.0),
432 Err(GlassProfileError::CenterHeightMismatch)
433 );
434 assert_eq!(
435 GlassSurfaceProfile::isotropic(low, -1.0, 2.0),
436 Err(GlassProfileError::InvalidDepth)
437 );
438 assert_eq!(
439 GlassSurfaceProfile::isotropic(low, 1.0, 8.1),
440 Err(GlassProfileError::InvalidRadialPower)
441 );
442 }
443
444 #[test]
445 fn public_profile_accessors_report_the_authored_surface() {
446 let profile = GlassSurfaceProfile::lens();
447 assert_eq!(profile.x_profile().knots().len(), 6);
448 assert_eq!(profile.y_profile().knots().len(), 6);
449 assert!((profile.depth() - 5.5).abs() < 1.0e-6);
450 assert!((profile.radial_power() - 3.2).abs() < 1.0e-6);
451 let knot = profile.x_profile().knots()[3];
452 assert!((knot.position() - 0.52).abs() < 1.0e-6);
453 assert!((knot.height() - 0.55).abs() < 1.0e-6);
454 assert!(knot.tangent().is_finite());
455
456 let crest = profile.x_profile().knots()[4];
457 assert!((crest.position() - 0.76).abs() < 1.0e-6);
458 assert!((profile.x_profile().knots()[5].height() - 0.45).abs() < 1.0e-6);
459 }
460
461 #[test]
462 fn lens_preset_is_a_recessed_face_with_one_raised_returning_meniscus() {
463 let profile = GlassSurfaceProfile::lens();
464 for curve in [profile.x_profile(), profile.y_profile()] {
465 assert!(curve.evaluate(0.0).0 <= 0.15);
466 assert!(curve.evaluate(0.50).1 >= 1.2);
467 assert!((1.2..=2.5).contains(&curve.evaluate(0.65).1));
468 assert!((-3.2..=-2.3).contains(&curve.evaluate(0.96).1));
469 assert!((0.40..=0.50).contains(&curve.evaluate(1.0).0));
470 }
471 }
472
473 #[test]
474 fn flat_and_regular_presets_are_valid() {
475 let flat = GlassSurfaceProfile::flat();
476 assert_eq!(flat.depth(), 0.0);
477 for profile in [GlassSurfaceProfile::regular(), GlassSurfaceProfile::lens()] {
478 for curve in [profile.x_profile(), profile.y_profile()] {
479 for knot in curve.knots() {
480 assert!((0.0..=1.0).contains(&knot.position()));
481 assert!((0.0..=1.0).contains(&knot.height()));
482 assert!(knot.tangent().is_finite());
483 }
484 }
485 }
486 }
487
488 #[test]
489 fn surface_builders_revalidate_physical_parameters() {
490 let profile = GlassSurfaceProfile::lens()
491 .with_depth(5.5)
492 .expect("depth")
493 .with_radial_power(4.0)
494 .expect("power");
495 assert_eq!(profile.depth(), 5.5);
496 assert_eq!(profile.radial_power(), 4.0);
497 assert_eq!(
498 profile.with_depth(f32::NAN),
499 Err(GlassProfileError::InvalidDepth)
500 );
501 assert_eq!(
502 profile.with_radial_power(1.0),
503 Err(GlassProfileError::InvalidRadialPower)
504 );
505 assert_eq!(
506 profile.with_axis_coupling(-0.1),
507 Err(GlassProfileError::InvalidAxisCoupling)
508 );
509 assert_eq!(
510 profile.with_axis_coupling(1.1),
511 Err(GlassProfileError::InvalidAxisCoupling)
512 );
513 }
514
515 #[test]
516 fn elliptical_surface_preserves_axis_profiles_and_cross_axis_curvature() {
517 let x = GlassProfileCurve::from_points(&[(0.0, 0.1), (0.5, 0.3), (1.0, 0.8)])
518 .expect("X-Z profile");
519 let y = GlassProfileCurve::from_points(&[(0.0, 0.1), (0.5, 0.6), (1.0, 0.9)])
520 .expect("Y-Z profile");
521 let profile = GlassSurfaceProfile::new(x, y, 4.0, 2.0).expect("surface profile");
522
523 let on_x = profile.sample_normalized((0.7, 0.0));
524 let on_y = profile.sample_normalized((0.0, -0.4));
525 let off_axis = profile.sample_normalized((0.7, -0.4));
526 assert!((on_x.height - x.evaluate(0.7).0).abs() < 1.0e-6);
527 assert!((on_y.height - y.evaluate(0.4).0).abs() < 1.0e-6);
528 assert!(off_axis.gradient.0.abs() > 0.1);
529 assert!(off_axis.gradient.1.abs() > 0.1);
530
531 let flat_sided = GlassSurfaceProfile::new(x, y, 4.0, 6.0)
532 .expect("flat-sided surface")
533 .sample_normalized((0.7, -0.4));
534 assert!(off_axis.gradient.1.abs() > flat_sided.gradient.1.abs());
535 }
536
537 #[test]
538 fn axis_coupling_interpolates_sag_and_gradient_without_changing_axis_profiles() {
539 let x = GlassProfileCurve::from_points(&[(0.0, 0.1), (0.5, 0.3), (1.0, 0.8)])
540 .expect("X-Z profile");
541 let y = GlassProfileCurve::from_points(&[(0.0, 0.1), (0.5, 0.6), (1.0, 0.9)])
542 .expect("Y-Z profile");
543 let oval = GlassSurfaceProfile::new(x, y, 4.0, 2.0).expect("oval");
544 let toric = oval.with_axis_coupling(1.0).expect("toric surface");
545 let halfway = oval.with_axis_coupling(0.5).expect("coupled surface");
546 let position = (0.7, -0.4);
547 let oval_sample = oval.sample_normalized(position);
548 let halfway_sample = halfway.sample_normalized(position);
549
550 assert_eq!(oval.axis_coupling(), 0.0);
551 assert_eq!(toric.axis_coupling(), 1.0);
552 assert!((toric.sample_normalized((0.7, 0.0)).height - x.evaluate(0.7).0).abs() < 1.0e-6);
553 assert!((toric.sample_normalized((0.0, -0.4)).height - y.evaluate(0.4).0).abs() < 1.0e-6);
554 let toric_height = x.evaluate(position.0).0 + y.evaluate(-position.1).0 - x.evaluate(0.0).0;
555 assert!(
556 (halfway_sample.height
557 - (oval_sample.height + (toric_height - oval_sample.height) * 0.5))
558 .abs()
559 < 1.0e-6
560 );
561 let toric_sample = toric.sample_normalized(position);
562 assert!((toric_sample.gradient.0 - x.evaluate(position.0).1).abs() < 1.0e-6);
563 assert!((toric_sample.gradient.1 + y.evaluate(-position.1).1).abs() < 1.0e-6);
564
565 let epsilon = 1.0e-4;
566 let dx = (halfway
567 .sample_normalized((position.0 + epsilon, position.1))
568 .height
569 - halfway
570 .sample_normalized((position.0 - epsilon, position.1))
571 .height)
572 / (2.0 * epsilon);
573 let dy = (halfway
574 .sample_normalized((position.0, position.1 + epsilon))
575 .height
576 - halfway
577 .sample_normalized((position.0, position.1 - epsilon))
578 .height)
579 / (2.0 * epsilon);
580 assert!((halfway_sample.gradient.0 - dx).abs() < 2.0e-3);
581 assert!((halfway_sample.gradient.1 - dy).abs() < 2.0e-3);
582 }
583}