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}
52
53impl Curve {
54    /// The web's `ease` (`cubic-bezier(0.25, 0.1, 0.25, 1.0)`).
55    pub fn ease() -> Curve {
56        Curve::Cubic {
57            x1: 0.25,
58            y1: 0.1,
59            x2: 0.25,
60            y2: 1.0,
61        }
62    }
63
64    /// `ease-in`: starts slowly.
65    pub fn ease_in() -> Curve {
66        Curve::Cubic {
67            x1: 0.42,
68            y1: 0.0,
69            x2: 1.0,
70            y2: 1.0,
71        }
72    }
73
74    /// `ease-out`: finishes slowly.
75    pub fn ease_out() -> Curve {
76        Curve::Cubic {
77            x1: 0.0,
78            y1: 0.0,
79            x2: 0.58,
80            y2: 1.0,
81        }
82    }
83
84    /// `ease-in-out`: slow at both ends.
85    pub fn ease_in_out() -> Curve {
86        Curve::Cubic {
87            x1: 0.42,
88            y1: 0.0,
89            x2: 0.58,
90            y2: 1.0,
91        }
92    }
93
94    /// The framework's default spring curve (`omega = 8`): the feel of screen and
95    /// sheet transitions, in closed form.
96    pub fn critical_spring() -> Curve {
97        Curve::CriticalSpring { omega: 8.0 }
98    }
99
100    /// Evaluates the curve at `t`, clamped to `[0,1]`.
101    pub fn transform(&self, t: f32) -> f32 {
102        let t = t.clamp(0.0, 1.0);
103        match self {
104            Curve::Linear => t,
105            Curve::Decelerate => {
106                let remaining = 1.0 - t;
107                1.0 - remaining * remaining
108            }
109            Curve::Cubic { x1, y1, x2, y2 } => cubic_bezier(*x1, *y1, *x2, *y2, t),
110            Curve::CriticalSpring { omega } => {
111                // y(tau) = 1 - e^{-omega*tau}(1 + omega*tau), renormalised by y(1).
112                let resp = |x: f32| 1.0 - (-omega * x).exp() * (1.0 + omega * x);
113                let end = resp(1.0);
114                if end == 0.0 {
115                    t
116                } else {
117                    resp(t) / end
118                }
119            }
120            Curve::Interval { begin, end, inner } => {
121                if t <= *begin {
122                    0.0
123                } else if t >= *end {
124                    1.0
125                } else {
126                    inner.transform((t - begin) / (end - begin))
127                }
128            }
129            Curve::Flipped(inner) => 1.0 - inner.transform(1.0 - t),
130        }
131    }
132}
133
134/// Evaluates the `y` coordinate of a cubic Bézier `cubic-bezier(x1, y1, x2, y2)`
135/// for an abscissa `t ∈ [0,1]`. The anchor points are `(0,0)` and `(1,1)`; `t` is
136/// the **x** coordinate wanted, so we look for the parameter `s` such that
137/// `Bx(s) = t`, then return `By(s)`.
138fn cubic_bezier(x1: f32, y1: f32, x2: f32, y2: f32, t: f32) -> f32 {
139    // A 1D cubic Bézier: anchors 0 and 1, controls p1 and p2.
140    let bezier = |p1: f32, p2: f32, s: f32| {
141        let inv = 1.0 - s;
142        // 3(1-s)^2*s*p1 + 3(1-s)*s^2*p2 + s^3
143        3.0 * inv * inv * s * p1 + 3.0 * inv * s * s * p2 + s * s * s
144    };
145    // Binary search for the parameter `s` giving `Bx(s) = t`. Bx is monotonic as
146    // long as the x control points lie in [0,1].
147    let mut lo = 0.0f32;
148    let mut hi = 1.0f32;
149    let mut s = t;
150    for _ in 0..24 {
151        s = 0.5 * (lo + hi);
152        let x = bezier(x1, x2, s);
153        if (x - t).abs() < 1e-5 {
154            break;
155        }
156        if x < t {
157            lo = s;
158        } else {
159            hi = s;
160        }
161    }
162    bezier(y1, y2, s)
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    fn endpoints(curve: &Curve) {
170        assert!(curve.transform(0.0).abs() < 1e-4, "f(0) != 0: {:?}", curve);
171        assert!(
172            (curve.transform(1.0) - 1.0).abs() < 1e-4,
173            "f(1) != 1: {:?}",
174            curve
175        );
176    }
177
178    #[test]
179    fn all_curves_hit_endpoints() {
180        endpoints(&Curve::Linear);
181        endpoints(&Curve::ease());
182        endpoints(&Curve::ease_in());
183        endpoints(&Curve::ease_out());
184        endpoints(&Curve::ease_in_out());
185        endpoints(&Curve::critical_spring());
186    }
187
188    #[test]
189    fn linear_is_identity() {
190        for i in 0..=10 {
191            let t = i as f32 / 10.0;
192            assert!((Curve::Linear.transform(t) - t).abs() < 1e-6);
193        }
194    }
195
196    #[test]
197    fn ease_in_starts_slow() {
198        // Half way through, an ease-in sits below the diagonal.
199        assert!(Curve::ease_in().transform(0.5) < 0.5);
200        // An ease-out sits above it.
201        assert!(Curve::ease_out().transform(0.5) > 0.5);
202    }
203
204    #[test]
205    fn curves_are_monotonic() {
206        for curve in [
207            Curve::ease(),
208            Curve::ease_in(),
209            Curve::ease_out(),
210            Curve::ease_in_out(),
211            Curve::critical_spring(),
212        ] {
213            let mut prev = 0.0;
214            for i in 0..=100 {
215                let v = curve.transform(i as f32 / 100.0);
216                assert!(v >= prev - 1e-4, "{:?} decreases at {i}", curve);
217                assert!(v <= 1.0 + 1e-4, "{:?} exceeds 1 at {i}", curve);
218                prev = v;
219            }
220        }
221    }
222
223    #[test]
224    fn interval_gates_the_subwindow() {
225        let staggered = Curve::Interval {
226            begin: 0.5,
227            end: 1.0,
228            inner: Box::new(Curve::Linear),
229        };
230        assert_eq!(staggered.transform(0.25), 0.0, "still before begin");
231        assert!(
232            (staggered.transform(0.75) - 0.5).abs() < 1e-4,
233            "half way at the middle of the window"
234        );
235        assert_eq!(staggered.transform(1.0), 1.0);
236    }
237
238    #[test]
239    fn flipped_mirrors() {
240        let flipped = Curve::Flipped(Box::new(Curve::ease_in()));
241        // A flipped ease-in behaves like an ease-out: above the diagonal.
242        assert!(flipped.transform(0.5) > 0.5);
243        endpoints(&flipped);
244    }
245
246    #[test]
247    fn critical_spring_matches_reference_shape() {
248        // Well advanced at the midpoint (it settles gently), bounded, no overshoot.
249        let c = Curve::critical_spring();
250        assert!(c.transform(0.5) > 0.7);
251        assert!(c.transform(1.0) <= 1.0 + 1e-6);
252    }
253}