Skip to main content

telar_motion_core/
easing.rs

1/// Timing function mapping normalized time `t` in `[0, 1]` to eased progress.
2#[derive(Debug, Clone, Copy, PartialEq)]
3pub enum Easing {
4    Linear,
5    EaseIn,
6    EaseOut,
7    EaseInOut,
8    /// CSS `cubic-bezier(x1, y1, x2, y2)` with control points P1/P2 (P0=(0,0), P3=(1,1)).
9    CubicBezier(f32, f32, f32, f32),
10}
11
12// Newton-Raphson iteration cap before falling back to bisection.
13const NEWTON_ITERS: u32 = 8;
14// Below this |dX/du| Newton steps are unreliable, so switch to bisection.
15const NEWTON_MIN_SLOPE: f32 = 1e-6;
16const BISECTION_ITERS: u32 = 32;
17const SUBDIVISION_EPS: f32 = 1e-7;
18
19impl Easing {
20    pub fn apply(self, t: f32) -> f32 {
21        let t = t.clamp(0.0, 1.0);
22        match self {
23            Easing::Linear => t,
24            Easing::EaseIn => t * t * t,
25            Easing::EaseOut => {
26                let u = 1.0 - t;
27                1.0 - u * u * u
28            }
29            Easing::EaseInOut => {
30                if t < 0.5 {
31                    4.0 * t * t * t
32                } else {
33                    let u = -2.0 * t + 2.0;
34                    1.0 - u * u * u / 2.0
35                }
36            }
37            Easing::CubicBezier(x1, y1, x2, y2) => {
38                let u = solve_bezier_x(t, x1, x2);
39                bezier_axis(u, y1, y2)
40            }
41        }
42    }
43}
44
45// One Bezier axis given its two control-point coordinates (endpoints are 0 and 1).
46fn bezier_axis(u: f32, p1: f32, p2: f32) -> f32 {
47    let c = 3.0 * p1;
48    let b = 3.0 * (p2 - p1) - c;
49    let a = 1.0 - c - b;
50    ((a * u + b) * u + c) * u
51}
52
53fn bezier_axis_slope(u: f32, p1: f32, p2: f32) -> f32 {
54    let c = 3.0 * p1;
55    let b = 3.0 * (p2 - p1) - c;
56    let a = 1.0 - c - b;
57    (3.0 * a * u + 2.0 * b) * u + c
58}
59
60// Find the Bezier parameter u such that X(u) == x, Newton-Raphson with a bisection fallback.
61fn solve_bezier_x(x: f32, x1: f32, x2: f32) -> f32 {
62    let mut u = x;
63    for _ in 0..NEWTON_ITERS {
64        let error = bezier_axis(u, x1, x2) - x;
65        if error.abs() < SUBDIVISION_EPS {
66            return u;
67        }
68        let slope = bezier_axis_slope(u, x1, x2);
69        if slope.abs() < NEWTON_MIN_SLOPE {
70            break;
71        }
72        u -= error / slope;
73    }
74    let (mut low, mut high, mut u) = (0.0f32, 1.0f32, x.clamp(0.0, 1.0));
75    for _ in 0..BISECTION_ITERS {
76        let value = bezier_axis(u, x1, x2);
77        if (value - x).abs() < SUBDIVISION_EPS {
78            return u;
79        }
80        if value < x {
81            low = u;
82        } else {
83            high = u;
84        }
85        u = (low + high) * 0.5;
86    }
87    u
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93
94    #[test]
95    fn linear_is_identity() {
96        assert!((Easing::Linear.apply(0.3) - 0.3).abs() < 1e-6);
97    }
98
99    #[test]
100    fn apply_clamps_out_of_range_input() {
101        assert_eq!(Easing::Linear.apply(-1.0), 0.0);
102        assert_eq!(Easing::Linear.apply(2.0), 1.0);
103    }
104
105    #[test]
106    fn cubic_easings_hit_endpoints() {
107        for easing in [Easing::EaseIn, Easing::EaseOut, Easing::EaseInOut] {
108            assert!(easing.apply(0.0).abs() < 1e-6, "{easing:?} at 0");
109            assert!((easing.apply(1.0) - 1.0).abs() < 1e-6, "{easing:?} at 1");
110        }
111    }
112
113    #[test]
114    fn ease_in_out_is_symmetric_at_midpoint() {
115        assert!((Easing::EaseInOut.apply(0.5) - 0.5).abs() < 1e-6);
116    }
117
118    #[test]
119    fn cubic_bezier_diagonal_is_linear() {
120        let curve = Easing::CubicBezier(0.0, 0.0, 1.0, 1.0);
121        for &t in &[0.0, 0.25, 0.5, 0.75, 1.0] {
122            assert!((curve.apply(t) - t).abs() < 1e-3, "t={t}");
123        }
124    }
125
126    #[test]
127    fn cubic_bezier_css_ease_accelerates_early() {
128        // CSS `ease` = cubic-bezier(0.25, 0.1, 0.25, 1.0); at t=0.5 it is well past halfway (~0.8).
129        let ease = Easing::CubicBezier(0.25, 0.1, 0.25, 1.0);
130        assert!(ease.apply(0.0).abs() < 1e-4);
131        assert!((ease.apply(1.0) - 1.0).abs() < 1e-4);
132        let mid = ease.apply(0.5);
133        assert!(mid > 0.5, "ease at 0.5 = {mid}");
134    }
135
136    #[test]
137    fn cubic_bezier_is_monotonic() {
138        let curve = Easing::CubicBezier(0.42, 0.0, 0.58, 1.0);
139        let mut prev = curve.apply(0.0);
140        for i in 1..=20 {
141            let y = curve.apply(i as f32 / 20.0);
142            assert!(y >= prev - 1e-4, "not monotonic at {i}: {y} < {prev}");
143            prev = y;
144        }
145    }
146}