Skip to main content

frus_core/animation/
curve.rs

1//! Easing curves: pure `[0,1] → [0,1]` functions that **shape** a linear progress
2//! value — the "how" of an animation, kept separate from the "how long".
3//!
4//! A single linear progress `t` can drive several values, each through its own
5//! curve and — thanks to [`Curve::Interval`] — over its own sub-window of time,
6//! which makes staggered animations free.
7
8/// An easing curve remapping `[0,1] → [0,1]`.
9///
10/// `f(0) = 0` and `f(1) = 1` hold for every variant.
11#[derive(Clone, Debug, PartialEq)]
12pub enum Curve {
13    /// The identity: `f(t) = t`.
14    Linear,
15    /// A quadratic ease-out, `f(t) = 1 − (1 − t)²`: fast at once, then coasting to
16    /// a stop. It is the shape of something *arriving* — a glow flaring at an edge,
17    /// a value settling — where the interesting part is the start.
18    Decelerate,
19    /// A cubic Bézier defined by its two control points `(x1,y1)` and `(x2,y2)` —
20    /// the same parameterisation as CSS's `cubic-bezier()`.
21    Cubic {
22        /// The first control point's x coordinate.
23        x1: f32,
24        /// The first control point's y coordinate.
25        y1: f32,
26        /// The second control point's x coordinate.
27        x2: f32,
28        /// The second control point's y coordinate.
29        y2: f32,
30    },
31    /// The step response of a **critically** damped spring: starts at rest, settles
32    /// gently with no overshoot, renormalised so that `f(1) = 1`. `omega` sets how
33    /// lively it feels.
34    CriticalSpring {
35        /// The spring's natural frequency, in radians per second: higher settles faster.
36        omega: f32,
37    },
38    /// Applies `inner` only over the sub-window `[begin, end]`: `0` before, `1`
39    /// after. This is what unlocks staggered animations.
40    Interval {
41        /// The progress at which `inner` starts, as a fraction of `[0,1]`.
42        begin: f32,
43        /// The progress at which `inner` reaches `1`, as a fraction of `[0,1]`.
44        end: f32,
45        /// The curve applied within `[begin, end]`.
46        inner: Box<Curve>,
47    },
48    /// Mirrored: `f(t) = 1 − inner(1 − t)`, which turns an *ease-in* into an
49    /// *ease-out*.
50    Flipped(Box<Curve>),
51    /// Two cubic Béziers joined at `midpoint`, each scaled into its own part of the
52    /// unit square: the first runs from `(0, 0)` to `midpoint` with control points `a1`
53    /// and `b1`, the second from `midpoint` to `(1, 1)` with `a2` and `b2` (milestone
54    /// 619). The shape of the reference's emphasized motion, which one cubic cannot
55    /// draw: a slow start, a fast middle and a long settle.
56    ThreePointCubic {
57        /// The first segment's first control point.
58        a1: [f32; 2],
59        /// The first segment's second control point.
60        b1: [f32; 2],
61        /// Where the two segments meet.
62        midpoint: [f32; 2],
63        /// The second segment's first control point.
64        a2: [f32; 2],
65        /// The second segment's second control point.
66        b2: [f32; 2],
67    },
68}
69
70impl Curve {
71    /// The web's `ease` (`cubic-bezier(0.25, 0.1, 0.25, 1.0)`).
72    pub fn ease() -> Curve {
73        Curve::Cubic {
74            x1: 0.25,
75            y1: 0.1,
76            x2: 0.25,
77            y2: 1.0,
78        }
79    }
80
81    /// `ease-in`: starts slowly.
82    pub fn ease_in() -> Curve {
83        Curve::Cubic {
84            x1: 0.42,
85            y1: 0.0,
86            x2: 1.0,
87            y2: 1.0,
88        }
89    }
90
91    /// `ease-out`: finishes slowly.
92    pub fn ease_out() -> Curve {
93        Curve::Cubic {
94            x1: 0.0,
95            y1: 0.0,
96            x2: 0.58,
97            y2: 1.0,
98        }
99    }
100
101    /// `ease-in-out`: slow at both ends.
102    pub fn ease_in_out() -> Curve {
103        Curve::Cubic {
104            x1: 0.42,
105            y1: 0.0,
106            x2: 0.58,
107            y2: 1.0,
108        }
109    }
110
111    /// The reference's **emphasized** easing, `easeInOutCubicEmphasized`: Material 3's
112    /// motion for things that move across the screen — a page arriving, a sheet rising.
113    pub fn ease_in_out_cubic_emphasized() -> Curve {
114        Curve::ThreePointCubic {
115            a1: [0.05, 0.0],
116            b1: [0.133333, 0.06],
117            midpoint: [0.166666, 0.4],
118            a2: [0.208333, 0.82],
119            b2: [0.25, 1.0],
120        }
121    }
122
123    /// The reference's `fastEaseInToSlowEaseOut`: an iOS page sliding in.
124    pub fn fast_ease_in_to_slow_ease_out() -> Curve {
125        Curve::ThreePointCubic {
126            a1: [0.056, 0.024],
127            b1: [0.108, 0.3085],
128            midpoint: [0.198, 0.541],
129            a2: [0.3655, 1.0],
130            b2: [0.5465, 0.989],
131        }
132    }
133
134    /// The reference's `linearToEaseOut`, `cubic-bezier(0.35, 0.91, 0.33, 0.97)`: an
135    /// iOS page leaving under the one that arrives.
136    pub fn linear_to_ease_out() -> Curve {
137        Curve::Cubic {
138            x1: 0.35,
139            y1: 0.91,
140            x2: 0.33,
141            y2: 0.97,
142        }
143    }
144
145    /// The reference's `easeInToLinear`, `cubic-bezier(0.67, 0.03, 0.65, 0.09)`: the
146    /// same page coming back.
147    pub fn ease_in_to_linear() -> Curve {
148        Curve::Cubic {
149            x1: 0.67,
150            y1: 0.03,
151            x2: 0.65,
152            y2: 0.09,
153        }
154    }
155
156    /// The framework's default spring curve (`omega = 8`): the feel of screen and
157    /// sheet transitions, in closed form.
158    pub fn critical_spring() -> Curve {
159        Curve::CriticalSpring { omega: 8.0 }
160    }
161
162    /// Evaluates the curve at `t`, clamped to `[0,1]`.
163    pub fn transform(&self, t: f32) -> f32 {
164        let t = t.clamp(0.0, 1.0);
165        match self {
166            Curve::Linear => t,
167            Curve::Decelerate => {
168                let remaining = 1.0 - t;
169                1.0 - remaining * remaining
170            }
171            Curve::Cubic { x1, y1, x2, y2 } => cubic_bezier(*x1, *y1, *x2, *y2, t),
172            Curve::CriticalSpring { omega } => {
173                // y(tau) = 1 - e^{-omega*tau}(1 + omega*tau), renormalised by y(1).
174                let resp = |x: f32| 1.0 - (-omega * x).exp() * (1.0 + omega * x);
175                let end = resp(1.0);
176                if end == 0.0 {
177                    t
178                } else {
179                    resp(t) / end
180                }
181            }
182            Curve::Interval { begin, end, inner } => {
183                if t <= *begin {
184                    0.0
185                } else if t >= *end {
186                    1.0
187                } else {
188                    inner.transform((t - begin) / (end - begin))
189                }
190            }
191            Curve::Flipped(inner) => 1.0 - inner.transform(1.0 - t),
192            Curve::ThreePointCubic {
193                a1,
194                b1,
195                midpoint,
196                a2,
197                b2,
198            } => {
199                // Exactly as the reference scales its two segments
200                // (`curves.dart:496`): each one is a unit cubic stretched over its own
201                // part of the square.
202                // The ends are the ends, as every curve's are in the reference
203                // (`Curve.transform`); it also keeps a midpoint on the square's edge
204                // from dividing by nothing.
205                if t <= 0.0 || t >= 1.0 {
206                    return t;
207                }
208                let [mx, my] = *midpoint;
209                if t < mx {
210                    cubic_bezier(a1[0] / mx, a1[1] / my, b1[0] / mx, b1[1] / my, t / mx) * my
211                } else {
212                    let (sx, sy) = (1.0 - mx, 1.0 - my);
213                    cubic_bezier(
214                        (a2[0] - mx) / sx,
215                        (a2[1] - my) / sy,
216                        (b2[0] - mx) / sx,
217                        (b2[1] - my) / sy,
218                        (t - mx) / sx,
219                    ) * sy
220                        + my
221                }
222            }
223        }
224    }
225}
226
227/// Evaluates the `y` coordinate of a cubic Bézier `cubic-bezier(x1, y1, x2, y2)`
228/// for an abscissa `t ∈ [0,1]`. The anchor points are `(0,0)` and `(1,1)`; `t` is
229/// the **x** coordinate wanted, so we look for the parameter `s` such that
230/// `Bx(s) = t`, then return `By(s)`.
231fn cubic_bezier(x1: f32, y1: f32, x2: f32, y2: f32, t: f32) -> f32 {
232    // A 1D cubic Bézier: anchors 0 and 1, controls p1 and p2.
233    let bezier = |p1: f32, p2: f32, s: f32| {
234        let inv = 1.0 - s;
235        // 3(1-s)^2*s*p1 + 3(1-s)*s^2*p2 + s^3
236        3.0 * inv * inv * s * p1 + 3.0 * inv * s * s * p2 + s * s * s
237    };
238    // Binary search for the parameter `s` giving `Bx(s) = t`. Bx is monotonic as
239    // long as the x control points lie in [0,1].
240    let mut lo = 0.0f32;
241    let mut hi = 1.0f32;
242    let mut s = t;
243    for _ in 0..24 {
244        s = 0.5 * (lo + hi);
245        let x = bezier(x1, x2, s);
246        if (x - t).abs() < 1e-5 {
247            break;
248        }
249        if x < t {
250            lo = s;
251        } else {
252            hi = s;
253        }
254    }
255    bezier(y1, y2, s)
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    /// **The reference's three-point curves** (milestone 619): each passes through its
263    /// midpoint exactly, keeps its ends, never runs backwards, and gives the reference's
264    /// values along the way.
265    #[test]
266    fn the_three_point_curves_meet_at_their_midpoints() {
267        let emphasized = Curve::ease_in_out_cubic_emphasized();
268        let ios = Curve::fast_ease_in_to_slow_ease_out();
269        for (curve, mx, my) in [(&emphasized, 0.166666, 0.4), (&ios, 0.198, 0.541)] {
270            endpoints(curve);
271            assert!((curve.transform(mx) - my).abs() < 1e-3, "{curve:?} at {mx}");
272            let mut last = 0.0;
273            for i in 0..=200 {
274                let y = curve.transform(i as f32 / 200.0);
275                assert!(y + 1e-4 >= last, "{curve:?} runs back at {i}");
276                last = y;
277            }
278        }
279        // And the reference's values, computed by its own bisection, which stops within a
280        // thousandth.
281        for (curve, table) in [
282            (&emphasized, [0.0937, 0.7715, 0.9508, 0.9914]),
283            (&ios, [0.2386, 0.6668, 0.9423, 0.9916]),
284        ] {
285            for (t, want) in [0.1, 0.25, 0.5, 0.75].into_iter().zip(table) {
286                let got = curve.transform(t);
287                assert!(
288                    (got - want).abs() < 5e-3,
289                    "{curve:?} at {t}: {got} vs {want}"
290                );
291            }
292        }
293    }
294
295    /// **The iOS pair**: `linearToEaseOut` is ahead of linear, `easeInToLinear` behind it,
296    /// each by the reference's margin.
297    #[test]
298    fn the_ios_cubics_lead_and_lag() {
299        let lead = Curve::linear_to_ease_out();
300        let lag = Curve::ease_in_to_linear();
301        endpoints(&lead);
302        endpoints(&lag);
303        for t in [0.25, 0.5, 0.75] {
304            assert!(lead.transform(t) > t && lag.transform(t) < t, "at {t}");
305        }
306        // And by as much as the reference's: its own evaluation, within a few thousandths.
307        for (curve, table) in [
308            (&lead, [0.6237, 0.9198, 0.9826]),
309            (&lag, [0.0174, 0.0802, 0.3763]),
310        ] {
311            for (t, want) in [0.25, 0.5, 0.75].into_iter().zip(table) {
312                let got = curve.transform(t);
313                assert!(
314                    (got - want).abs() < 5e-3,
315                    "{curve:?} at {t}: {got} vs {want}"
316                );
317            }
318        }
319    }
320
321    fn endpoints(curve: &Curve) {
322        assert!(curve.transform(0.0).abs() < 1e-4, "f(0) != 0: {:?}", curve);
323        assert!(
324            (curve.transform(1.0) - 1.0).abs() < 1e-4,
325            "f(1) != 1: {:?}",
326            curve
327        );
328    }
329
330    #[test]
331    fn all_curves_hit_endpoints() {
332        endpoints(&Curve::Linear);
333        endpoints(&Curve::ease());
334        endpoints(&Curve::ease_in());
335        endpoints(&Curve::ease_out());
336        endpoints(&Curve::ease_in_out());
337        endpoints(&Curve::critical_spring());
338    }
339
340    #[test]
341    fn linear_is_identity() {
342        for i in 0..=10 {
343            let t = i as f32 / 10.0;
344            assert!((Curve::Linear.transform(t) - t).abs() < 1e-6);
345        }
346    }
347
348    #[test]
349    fn ease_in_starts_slow() {
350        // Half way through, an ease-in sits below the diagonal.
351        assert!(Curve::ease_in().transform(0.5) < 0.5);
352        // An ease-out sits above it.
353        assert!(Curve::ease_out().transform(0.5) > 0.5);
354    }
355
356    #[test]
357    fn curves_are_monotonic() {
358        for curve in [
359            Curve::ease(),
360            Curve::ease_in(),
361            Curve::ease_out(),
362            Curve::ease_in_out(),
363            Curve::critical_spring(),
364        ] {
365            let mut prev = 0.0;
366            for i in 0..=100 {
367                let v = curve.transform(i as f32 / 100.0);
368                assert!(v >= prev - 1e-4, "{:?} decreases at {i}", curve);
369                assert!(v <= 1.0 + 1e-4, "{:?} exceeds 1 at {i}", curve);
370                prev = v;
371            }
372        }
373    }
374
375    #[test]
376    fn interval_gates_the_subwindow() {
377        let staggered = Curve::Interval {
378            begin: 0.5,
379            end: 1.0,
380            inner: Box::new(Curve::Linear),
381        };
382        assert_eq!(staggered.transform(0.25), 0.0, "still before begin");
383        assert!(
384            (staggered.transform(0.75) - 0.5).abs() < 1e-4,
385            "half way at the middle of the window"
386        );
387        assert_eq!(staggered.transform(1.0), 1.0);
388    }
389
390    #[test]
391    fn flipped_mirrors() {
392        let flipped = Curve::Flipped(Box::new(Curve::ease_in()));
393        // A flipped ease-in behaves like an ease-out: above the diagonal.
394        assert!(flipped.transform(0.5) > 0.5);
395        endpoints(&flipped);
396    }
397
398    #[test]
399    fn critical_spring_matches_reference_shape() {
400        // Well advanced at the midpoint (it settles gently), bounded, no overshoot.
401        let c = Curve::critical_spring();
402        assert!(c.transform(0.5) > 0.7);
403        assert!(c.transform(1.0) <= 1.0 + 1e-6);
404    }
405}