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}