Skip to main content

oxframe_core/
ease.rs

1use std::f64::consts::PI;
2
3/// Maps linear progress `t ∈ [0, 1]` to eased progress.
4///
5/// Implemented by [`CubicBezier`] and by any `Fn(f64) -> f64`, which is how
6/// [`elastic_out`], [`bounce_out`] and friends plug in.
7pub trait Ease {
8    /// Eased progress for `t` in 0..=1 (may overshoot for back/elastic).
9    fn ease(&self, t: f64) -> f64;
10}
11
12impl<F: Fn(f64) -> f64> Ease for F {
13    fn ease(&self, t: f64) -> f64 {
14        self(t)
15    }
16}
17
18/// CSS-style cubic Bézier easing with control points `(x1, y1)`, `(x2, y2)`;
19/// the end points are fixed at `(0, 0)` and `(1, 1)`.
20#[derive(Debug, Clone, Copy, PartialEq)]
21pub struct CubicBezier {
22    /// First control point, x (0..=1).
23    pub x1: f64,
24    /// First control point, y.
25    pub y1: f64,
26    /// Second control point, x (0..=1).
27    pub x2: f64,
28    /// Second control point, y.
29    pub y2: f64,
30}
31
32impl CubicBezier {
33    const NEWTON_ITERATIONS: usize = 8;
34    const PRECISION: f64 = 1e-7;
35
36    /// Curve with control points `(x1, y1)` and `(x2, y2)`, like CSS `cubic-bezier()`.
37    pub const fn new(x1: f64, y1: f64, x2: f64, y2: f64) -> Self {
38        Self { x1, y1, x2, y2 }
39    }
40
41    /// From `[x1, y1, x2, y2]`, the format of `meta.json`.
42    pub const fn from_array([x1, y1, x2, y2]: [f64; 4]) -> Self {
43        Self::new(x1, y1, x2, y2)
44    }
45
46    /// As `[x1, y1, x2, y2]`.
47    pub const fn to_array(self) -> [f64; 4] {
48        [self.x1, self.y1, self.x2, self.y2]
49    }
50
51    /// Polynomial coefficients `(a, b, c)` of `B(s) = ((a·s + b)·s + c)·s`.
52    fn coeffs(p1: f64, p2: f64) -> (f64, f64, f64) {
53        let c = 3.0 * p1;
54        let b = 3.0 * (p2 - p1) - c;
55        (1.0 - c - b, b, c)
56    }
57
58    #[cfg(test)]
59    fn bezier(p1: f64, p2: f64, s: f64) -> f64 {
60        let (a, b, c) = Self::coeffs(p1, p2);
61        ((a * s + b) * s + c) * s
62    }
63
64    /// Curve parameter `s` such that `x(s) == x`.
65    fn solve_s(&self, x: f64) -> f64 {
66        let (a, b, c) = Self::coeffs(self.x1, self.x2);
67        let eval = |s: f64| ((a * s + b) * s + c) * s;
68        let mut s = x;
69        for _ in 0..Self::NEWTON_ITERATIONS {
70            let err = eval(s) - x;
71            if err.abs() < Self::PRECISION {
72                return s;
73            }
74            let slope = (3.0 * a * s + 2.0 * b) * s + c;
75            if slope.abs() < 1e-6 {
76                break;
77            }
78            s = (s - err / slope).clamp(0.0, 1.0);
79        }
80        // Newton failed to converge (flat slope): x(s) is monotonic for
81        // x1, x2 ∈ [0, 1], so bisection always works.
82        let (mut lo, mut hi) = (0.0, 1.0);
83        s = x;
84        for _ in 0..40 {
85            let v = eval(s);
86            if (v - x).abs() < Self::PRECISION {
87                break;
88            }
89            if v < x {
90                lo = s;
91            } else {
92                hi = s;
93            }
94            s = 0.5 * (lo + hi);
95        }
96        s
97    }
98}
99
100impl Ease for CubicBezier {
101    fn ease(&self, t: f64) -> f64 {
102        if t <= 0.0 {
103            return 0.0;
104        }
105        if t >= 1.0 {
106            return 1.0;
107        }
108        if self.x1 == self.y1 && self.x2 == self.y2 {
109            return t;
110        }
111        let (a, b, c) = Self::coeffs(self.y1, self.y2);
112        let s = self.solve_s(t);
113        ((a * s + b) * s + c) * s
114    }
115}
116
117/// Named Bézier presets (values follow easings.net / CSS).
118pub mod presets {
119    use super::CubicBezier;
120
121    /// Constant speed.
122    pub const LINEAR: CubicBezier = CubicBezier::new(0.0, 0.0, 1.0, 1.0);
123    /// CSS `ease`.
124    pub const EASE: CubicBezier = CubicBezier::new(0.25, 0.1, 0.25, 1.0);
125    /// CSS `ease-in`.
126    pub const EASE_IN: CubicBezier = CubicBezier::new(0.42, 0.0, 1.0, 1.0);
127    /// CSS `ease-out`.
128    pub const EASE_OUT: CubicBezier = CubicBezier::new(0.0, 0.0, 0.58, 1.0);
129    /// CSS `ease-in-out`.
130    pub const EASE_IN_OUT: CubicBezier = CubicBezier::new(0.42, 0.0, 0.58, 1.0);
131
132    /// Gentle start.
133    pub const IN_SINE: CubicBezier = CubicBezier::new(0.12, 0.0, 0.39, 0.0);
134    /// Gentle stop.
135    pub const OUT_SINE: CubicBezier = CubicBezier::new(0.61, 1.0, 0.88, 1.0);
136    /// Gentle start and stop.
137    pub const IN_OUT_SINE: CubicBezier = CubicBezier::new(0.37, 0.0, 0.63, 1.0);
138    /// Accelerates (quadratic).
139    pub const IN_QUAD: CubicBezier = CubicBezier::new(0.11, 0.0, 0.5, 0.0);
140    /// Decelerates (quadratic).
141    pub const OUT_QUAD: CubicBezier = CubicBezier::new(0.5, 1.0, 0.89, 1.0);
142    /// Accelerates, then decelerates (quadratic).
143    pub const IN_OUT_QUAD: CubicBezier = CubicBezier::new(0.45, 0.0, 0.55, 1.0);
144    /// Accelerates (cubic).
145    pub const IN_CUBIC: CubicBezier = CubicBezier::new(0.32, 0.0, 0.67, 0.0);
146    /// Decelerates (cubic).
147    pub const OUT_CUBIC: CubicBezier = CubicBezier::new(0.33, 1.0, 0.68, 1.0);
148    /// Accelerates, then decelerates (cubic); the default.
149    pub const IN_OUT_CUBIC: CubicBezier = CubicBezier::new(0.65, 0.0, 0.35, 1.0);
150    /// Accelerates (quartic).
151    pub const IN_QUART: CubicBezier = CubicBezier::new(0.5, 0.0, 0.75, 0.0);
152    /// Decelerates (quartic).
153    pub const OUT_QUART: CubicBezier = CubicBezier::new(0.25, 1.0, 0.5, 1.0);
154    /// Accelerates, then decelerates (quartic).
155    pub const IN_OUT_QUART: CubicBezier = CubicBezier::new(0.76, 0.0, 0.24, 1.0);
156    /// Very slow start.
157    pub const IN_EXPO: CubicBezier = CubicBezier::new(0.7, 0.0, 0.84, 0.0);
158    /// Very fast start, long settle.
159    pub const OUT_EXPO: CubicBezier = CubicBezier::new(0.16, 1.0, 0.3, 1.0);
160    /// Very slow start and stop.
161    pub const IN_OUT_EXPO: CubicBezier = CubicBezier::new(0.87, 0.0, 0.13, 1.0);
162    /// Pulls back before starting.
163    pub const IN_BACK: CubicBezier = CubicBezier::new(0.36, 0.0, 0.66, -0.56);
164    /// Overshoots, then settles.
165    pub const OUT_BACK: CubicBezier = CubicBezier::new(0.34, 1.56, 0.64, 1.0);
166    /// Pulls back and overshoots.
167    pub const IN_OUT_BACK: CubicBezier = CubicBezier::new(0.68, -0.6, 0.32, 1.6);
168
169    /// Default easing of every tween.
170    pub const DEFAULT: CubicBezier = IN_OUT_CUBIC;
171}
172
173/// Overshoots and oscillates into place, like a spring.
174pub fn elastic_out(t: f64) -> f64 {
175    if t <= 0.0 || t >= 1.0 {
176        return t.clamp(0.0, 1.0);
177    }
178    let c4 = 2.0 * PI / 3.0;
179    2f64.powf(-10.0 * t) * ((t * 10.0 - 0.75) * c4).sin() + 1.0
180}
181
182/// Oscillates before leaving, like a spring being wound.
183pub fn elastic_in(t: f64) -> f64 {
184    1.0 - elastic_out(1.0 - t)
185}
186
187/// Spring at both ends.
188pub fn elastic_in_out(t: f64) -> f64 {
189    if t <= 0.0 || t >= 1.0 {
190        return t.clamp(0.0, 1.0);
191    }
192    let c5 = 2.0 * PI / 4.5;
193    if t < 0.5 {
194        -(2f64.powf(20.0 * t - 10.0) * ((20.0 * t - 11.125) * c5).sin()) / 2.0
195    } else {
196        2f64.powf(-20.0 * t + 10.0) * ((20.0 * t - 11.125) * c5).sin() / 2.0 + 1.0
197    }
198}
199
200/// Bounces into place, like a dropped ball.
201pub fn bounce_out(t: f64) -> f64 {
202    const N: f64 = 7.5625;
203    const D: f64 = 2.75;
204    let t = t.clamp(0.0, 1.0);
205    if t < 1.0 / D {
206        N * t * t
207    } else if t < 2.0 / D {
208        let t = t - 1.5 / D;
209        N * t * t + 0.75
210    } else if t < 2.5 / D {
211        let t = t - 2.25 / D;
212        N * t * t + 0.9375
213    } else {
214        let t = t - 2.625 / D;
215        N * t * t + 0.984_375
216    }
217}
218
219/// Bounces before leaving.
220pub fn bounce_in(t: f64) -> f64 {
221    1.0 - bounce_out(1.0 - t)
222}
223
224/// Bounces at both ends.
225pub fn bounce_in_out(t: f64) -> f64 {
226    if t < 0.5 {
227        (1.0 - bounce_out(1.0 - 2.0 * t)) / 2.0
228    } else {
229        (1.0 + bounce_out(2.0 * t - 1.0)) / 2.0
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::presets::*;
236    use super::*;
237
238    /// Reference by dense bisection, independent of the Newton solver.
239    fn reference(c: &CubicBezier, x: f64) -> f64 {
240        let (mut lo, mut hi) = (0.0f64, 1.0f64);
241        for _ in 0..200 {
242            let mid = 0.5 * (lo + hi);
243            if CubicBezier::bezier(c.x1, c.x2, mid) < x {
244                lo = mid;
245            } else {
246                hi = mid;
247            }
248        }
249        CubicBezier::bezier(c.y1, c.y2, 0.5 * (lo + hi))
250    }
251
252    #[test]
253    fn bezier_matches_reference_within_tolerance() {
254        let curves = [
255            EASE,
256            EASE_IN,
257            EASE_OUT,
258            EASE_IN_OUT,
259            IN_OUT_CUBIC,
260            IN_EXPO,
261            OUT_EXPO,
262            IN_OUT_EXPO,
263            IN_OUT_BACK,
264            OUT_BACK,
265            IN_BACK,
266            CubicBezier::new(1.0, 0.0, 0.0, 1.0),
267            CubicBezier::new(0.0, 1.0, 1.0, 0.0),
268        ];
269        for c in curves {
270            for i in 0..=1000 {
271                let x = i as f64 / 1000.0;
272                let got = c.ease(x);
273                let want = reference(&c, x);
274                assert!((got - want).abs() <= 1e-4, "{c:?} at {x}: {got} vs {want}");
275            }
276        }
277    }
278
279    #[test]
280    fn endpoints_and_linear() {
281        for c in [LINEAR, EASE, IN_OUT_BACK] {
282            assert_eq!(c.ease(0.0), 0.0);
283            assert_eq!(c.ease(1.0), 1.0);
284        }
285        assert_eq!(LINEAR.ease(0.3), 0.3);
286        for f in [
287            elastic_in,
288            elastic_out,
289            elastic_in_out,
290            bounce_in,
291            bounce_out,
292            bounce_in_out,
293        ] {
294            assert!(f.ease(0.0).abs() < 1e-9);
295            assert!((f.ease(1.0) - 1.0).abs() < 1e-9);
296        }
297    }
298}