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}