Skip to main content

ashlar_motion/
easing.rs

1//! Cubic-bezier easing, as in CSS `cubic-bezier(x1, y1, x2, y2)`.
2
3/// A cubic-bezier easing from (0, 0) to (1, 1) through two control points. The x coordinates are
4/// clamped to 0..=1 (so time only moves forward) and the y coordinates to −1..=2.
5#[derive(Clone, Copy, Debug, PartialEq)]
6pub struct Easing {
7    x1: f32,
8    y1: f32,
9    x2: f32,
10    y2: f32,
11}
12
13impl Default for Easing {
14    fn default() -> Self {
15        Easing::EASE
16    }
17}
18
19impl Easing {
20    /// Constant speed.
21    pub const LINEAR: Easing = Easing { x1: 0.0, y1: 0.0, x2: 1.0, y2: 1.0 };
22    /// CSS `ease`.
23    pub const EASE: Easing = Easing { x1: 0.25, y1: 0.1, x2: 0.25, y2: 1.0 };
24    /// CSS `ease-in`.
25    pub const EASE_IN: Easing = Easing { x1: 0.42, y1: 0.0, x2: 1.0, y2: 1.0 };
26    /// CSS `ease-out`.
27    pub const EASE_OUT: Easing = Easing { x1: 0.0, y1: 0.0, x2: 0.58, y2: 1.0 };
28    /// CSS `ease-in-out`.
29    pub const EASE_IN_OUT: Easing = Easing { x1: 0.42, y1: 0.0, x2: 0.58, y2: 1.0 };
30
31    /// `cubic-bezier(x1, y1, x2, y2)`. NaN coordinates take the linear curve's; others are clamped.
32    pub const fn cubic_bezier(x1: f32, y1: f32, x2: f32, y2: f32) -> Easing {
33        Easing { x1: coord(x1, 0.0, 0.0, 1.0), y1: coord(y1, 0.0, -1.0, 2.0), x2: coord(x2, 1.0, 0.0, 1.0), y2: coord(y2, 1.0, -1.0, 2.0) }
34    }
35
36    /// The control points `[x1, y1, x2, y2]`.
37    pub const fn points(self) -> [f32; 4] {
38        [self.x1, self.y1, self.x2, self.y2]
39    }
40
41    fn is_linear(self) -> bool {
42        self.x1 == self.y1 && self.x2 == self.y2
43    }
44
45    /// Progress at time fraction `u` (clamped to 0..=1; NaN is 0).
46    pub fn ease(self, u: f32) -> f32 {
47        let u = unit(u);
48        if self.is_linear() {
49            return u;
50        }
51        let s = self.solve(f64::from(u));
52        bez(f64::from(self.y1), f64::from(self.y2), s) as f32
53    }
54
55    /// The slope d(progress)/d(time fraction) at `u` (clamped to 0..=1), bounded to ±10⁴.
56    pub fn slope(self, u: f32) -> f32 {
57        let u = unit(u);
58        if self.is_linear() {
59            return 1.0;
60        }
61        let s = self.solve(f64::from(u));
62        let (x1, x2, y1, y2) = (f64::from(self.x1), f64::from(self.x2), f64::from(self.y1), f64::from(self.y2));
63        let dx = bez_d(x1, x2, s);
64        let slope = if dx.abs() >= 1e-9 {
65            bez_d(y1, y2, s) / dx
66        } else {
67            // x stalls (a control point on an end point): the limit is the ratio of the second
68            // derivatives (L'Hôpital)
69            let ddx = bez_dd(x1, x2, s);
70            if ddx.abs() < 1e-12 {
71                return 0.0;
72            }
73            bez_dd(y1, y2, s) / ddx
74        };
75        if slope.is_finite() { slope.clamp(-1e4, 1e4) as f32 } else { 0.0 }
76    }
77
78    /// The curve parameter whose x is `u`: Newton, then bisection when it does not converge.
79    fn solve(self, u: f64) -> f64 {
80        let (x1, x2) = (f64::from(self.x1), f64::from(self.x2));
81        let mut s = u;
82        for _ in 0..8 {
83            let err = bez(x1, x2, s) - u;
84            if err.abs() < 1e-7 {
85                return s.clamp(0.0, 1.0);
86            }
87            let d = bez_d(x1, x2, s);
88            if d.abs() < 1e-6 {
89                break;
90            }
91            s -= err / d;
92            if !(0.0..=1.0).contains(&s) {
93                break;
94            }
95        }
96        // x(s) is monotone for x1, x2 in 0..=1
97        let (mut lo, mut hi) = (0.0_f64, 1.0_f64);
98        s = u;
99        for _ in 0..40 {
100            let x = bez(x1, x2, s);
101            if (x - u).abs() < 1e-7 {
102                break;
103            }
104            if x < u {
105                lo = s;
106            } else {
107                hi = s;
108            }
109            s = (lo + hi) * 0.5;
110        }
111        s.clamp(0.0, 1.0)
112    }
113}
114
115const fn coord(v: f32, nan: f32, lo: f32, hi: f32) -> f32 {
116    if v.is_nan() { nan } else { v.clamp(lo, hi) }
117}
118
119fn unit(u: f32) -> f32 {
120    if u.is_nan() { 0.0 } else { u.clamp(0.0, 1.0) }
121}
122
123/// One coordinate of the bezier from 0 to 1 with control values `p1`, `p2`, at parameter `s`.
124fn bez(p1: f64, p2: f64, s: f64) -> f64 {
125    let c = 3.0 * p1;
126    let b = 3.0 * (p2 - p1) - c;
127    let a = 1.0 - c - b;
128    ((a * s + b) * s + c) * s
129}
130
131fn bez_d(p1: f64, p2: f64, s: f64) -> f64 {
132    let c = 3.0 * p1;
133    let b = 3.0 * (p2 - p1) - c;
134    let a = 1.0 - c - b;
135    (3.0 * a * s + 2.0 * b) * s + c
136}
137
138fn bez_dd(p1: f64, p2: f64, s: f64) -> f64 {
139    let c = 3.0 * p1;
140    let b = 3.0 * (p2 - p1) - c;
141    let a = 1.0 - c - b;
142    6.0 * a * s + 2.0 * b
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn linear_is_identity() {
151        for e in [Easing::LINEAR, Easing::cubic_bezier(0.0, 0.0, 1.0, 1.0), Easing::cubic_bezier(0.3, 0.3, 0.7, 0.7)] {
152            for i in 0..=100 {
153                let u = i as f32 / 100.0;
154                assert!((e.ease(u) - u).abs() < 1e-5, "{e:?} {u}");
155                assert!((e.slope(u) - 1.0).abs() < 1e-3, "{e:?} {u}");
156            }
157        }
158    }
159
160    #[test]
161    fn ends_and_monotone() {
162        for e in [Easing::LINEAR, Easing::EASE, Easing::EASE_IN, Easing::EASE_OUT, Easing::EASE_IN_OUT] {
163            assert!(e.ease(0.0).abs() < 1e-6);
164            assert!((e.ease(1.0) - 1.0).abs() < 1e-6);
165            let mut last = 0.0;
166            for i in 0..=1000 {
167                let y = e.ease(i as f32 / 1000.0);
168                assert!(y >= last - 1e-6);
169                last = y;
170            }
171        }
172        assert!((Easing::EASE_IN_OUT.ease(0.5) - 0.5).abs() < 1e-4);
173    }
174
175    #[test]
176    fn slope_matches_finite_difference() {
177        for e in [Easing::EASE, Easing::EASE_IN, Easing::EASE_OUT, Easing::EASE_IN_OUT, Easing::cubic_bezier(0.3, -0.5, 0.6, 1.6)] {
178            for i in 1..100 {
179                let u = i as f32 / 100.0;
180                let h = 1e-3;
181                let fd = (e.ease(u + h) - e.ease(u - h)) / (2.0 * h);
182                assert!((fd - e.slope(u)).abs() < 2e-2 * (1.0 + fd.abs()), "{e:?} {u} {fd} {}", e.slope(u));
183            }
184        }
185        // ease-out starts with a finite slope y2/x2 limit… and ease-in with 0
186        assert!(Easing::EASE_IN.slope(0.0).abs() < 1e-2);
187        assert!(Easing::EASE_OUT.slope(0.0) > 1.0);
188    }
189
190    #[test]
191    fn hostile_inputs() {
192        let e = Easing::cubic_bezier(f32::NAN, f32::INFINITY, -3.0, f32::NEG_INFINITY);
193        assert_eq!(e.points(), [0.0, 2.0, 0.0, -1.0]);
194        for u in [f32::NAN, f32::INFINITY, -1.0, 0.5, 2.0] {
195            assert!(e.ease(u).is_finite());
196            assert!(e.slope(u).is_finite());
197        }
198    }
199}