Skip to main content

cranpose_ui_graphics/
glass_profile.rs

1//! Configurable physical cross-sections for liquid-glass surfaces.
2
3/// Maximum knots in one principal-axis profile.
4pub const MAX_GLASS_PROFILE_KNOTS: usize = 6;
5
6const POSITION_EPSILON: f32 = 1.0e-5;
7
8/// One normalized `(axis, height)` sample and its generated cubic tangent.
9#[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/// Validation failure while constructing a physical glass profile.
37#[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/// A normalized center-to-edge cubic cross-section.
62///
63/// Positions and heights are both authored in `0..1`. Tangents are generated
64/// with a monotonicity-preserving cubic law so interactive sliders cannot
65/// introduce unrequested oscillations between knots.
66#[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    /// Evaluates `(height, d_height/d_position)` at a normalized coordinate.
118    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/// Two principal cross-sections defining one coupled oval surface inside a
148/// superelliptic aperture.
149#[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    // A center-to-edge radial profile is mirrored through the origin, so its
337    // center tangent must be zero. The outer endpoint remains free to meet
338    // the surrounding plane with the authored return slope.
339    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}