Skip to main content

ff_filter/animation/
easing.rs

1/// Easing function applied to a keyframe interval.
2///
3/// Controls the shape of interpolation from one [`super::Keyframe`] to the
4/// next.  Each keyframe carries the easing used for the transition *from that
5/// keyframe to the subsequent one*; the last keyframe's easing is unused.
6///
7/// Individual easing functions are implemented across issues #352–#357.
8#[derive(Debug, Clone)]
9#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
10pub enum Easing {
11    /// Hold: the value snaps to the next keyframe without interpolation.
12    Hold,
13    /// Linear: constant-rate interpolation (`y = t`).
14    Linear,
15    /// Cubic ease-in: slow start, fast end (`y = t³`).
16    EaseIn,
17    /// Cubic ease-out: fast start, slow end (`y = 1 − (1−t)³`).
18    EaseOut,
19    /// Cubic ease-in-out: slow at both ends, fast middle (`y = 3t² − 2t³`).
20    EaseInOut,
21    /// CSS-compatible cubic Bézier with two user-defined control points.
22    ///
23    /// P0 = (0, 0) and P3 = (1, 1) are fixed; `p1` and `p2` define the curve
24    /// shape.  Equivalent to the CSS `cubic-bezier()` function.
25    Bezier {
26        /// First control point `(x, y)`, x clamped to `[0, 1]`.
27        p1: (f64, f64),
28        /// Second control point `(x, y)`, x clamped to `[0, 1]`.
29        p2: (f64, f64),
30    },
31}
32
33impl Easing {
34    /// Applies the easing function to a normalised progress value `t ∈ [0, 1]`.
35    ///
36    /// Returns a remapped progress value `u ∈ [0, 1]` that is then used to
37    /// drive `T::lerp`.  Full per-variant implementations are added in issues
38    /// #352–#357; variants not yet implemented fall back to linear.
39    pub(crate) fn apply(&self, t: f64) -> f64 {
40        match self {
41            // Hold: snap — stay at the start value until t reaches 1.0,
42            // then jump to the end value.
43            Easing::Hold => {
44                if t >= 1.0 {
45                    1.0
46                } else {
47                    0.0
48                }
49            }
50            Easing::Linear => t,
51            // Cubic ease-in: slow start, fast end (y = t³).
52            Easing::EaseIn => t * t * t,
53            // Cubic ease-out: fast start, slow end (y = 1 − (1−t)³).
54            Easing::EaseOut => 1.0 - (1.0 - t).powi(3),
55            // Cubic ease-in-out: slow at both ends, fast middle (y = 3t² − 2t³).
56            // Equivalent to Ken Perlin's smoothstep; symmetric about t = 0.5.
57            Easing::EaseInOut => 3.0 * t * t - 2.0 * t * t * t,
58            // CSS cubic-bezier: find t via Newton–Raphson, return By(t).
59            // P0=(0,0) and P3=(1,1) are fixed; P1=p1, P2=p2.
60            // P1.x and P2.x are clamped to [0, 1] to preserve monotonicity.
61            Easing::Bezier { p1, p2 } => {
62                let p1x = p1.0.clamp(0.0, 1.0);
63                let p2x = p2.0.clamp(0.0, 1.0);
64
65                // Solve Bx(nt) = t via Newton–Raphson (4 iterations).
66                let mut nt = t;
67                for _ in 0..4 {
68                    let bx_prime = bez_x_prime(nt, p1x, p2x);
69                    if bx_prime.abs() < 1e-10 {
70                        break;
71                    }
72                    nt -= (bez_x(nt, p1x, p2x) - t) / bx_prime;
73                    nt = nt.clamp(0.0, 1.0);
74                }
75
76                bez_y(nt, p1.1, p2.1)
77            }
78        }
79    }
80}
81
82// ── Cubic Bézier helpers (P0=0, P3=1) ────────────────────────────────────────
83
84/// X position on the Bézier curve at parameter `t`.
85fn bez_x(t: f64, p1x: f64, p2x: f64) -> f64 {
86    let u = 1.0 - t;
87    3.0 * p1x * t * u * u + 3.0 * p2x * t * t * u + t * t * t
88}
89
90/// Derivative of `bez_x` with respect to `t`.
91fn bez_x_prime(t: f64, p1x: f64, p2x: f64) -> f64 {
92    let u = 1.0 - t;
93    3.0 * p1x * u * u + 6.0 * (p2x - p1x) * t * u + 3.0 * (1.0 - p2x) * t * t
94}
95
96/// Y position on the Bézier curve at parameter `t`.
97fn bez_y(t: f64, p1y: f64, p2y: f64) -> f64 {
98    let u = 1.0 - t;
99    3.0 * p1y * t * u * u + 3.0 * p2y * t * t * u + t * t * t
100}
101
102// ── Tests ─────────────────────────────────────────────────────────────────────
103
104#[cfg(test)]
105mod tests {
106    use std::time::Duration;
107
108    use super::*;
109    use crate::animation::{AnimationTrack, Keyframe};
110
111    #[test]
112    fn bezier_ease_css_preset_should_match_reference_values() {
113        // CSS `ease` = cubic-bezier(0.25, 0.1, 0.25, 1.0).
114        // At x=0.5 the CSS reference value is ~0.8029 (t ≈ 0.7 solves Bx(t)=0.5).
115        let ease = Easing::Bezier {
116            p1: (0.25, 0.1),
117            p2: (0.25, 1.0),
118        };
119        let v = ease.apply(0.5);
120        assert!(
121            (v - 0.8029_f64).abs() < 0.01,
122            "expected ~0.8029 for CSS ease at x=0.5, got {v}"
123        );
124
125        // Boundary conditions: apply(0.0) = 0.0 and apply(1.0) = 1.0.
126        assert!(
127            ease.apply(0.0).abs() < f64::EPSILON,
128            "apply(0.0) must be 0.0"
129        );
130        assert!(
131            (ease.apply(1.0) - 1.0).abs() < f64::EPSILON,
132            "apply(1.0) must be 1.0"
133        );
134    }
135
136    #[test]
137    fn linear_easing_should_return_half_at_midpoint() {
138        // Build a [0 s → 0.0, 1 s → 1.0] track with Linear easing.
139        let track = AnimationTrack::new()
140            .push(Keyframe::new(Duration::ZERO, 0.0_f64, Easing::Linear))
141            .push(Keyframe::new(
142                Duration::from_secs(1),
143                1.0_f64,
144                Easing::Linear,
145            ));
146
147        let v = track.value_at(Duration::from_millis(500));
148        assert!((v - 0.5).abs() < 0.001, "expected 0.5 at midpoint, got {v}");
149    }
150
151    #[test]
152    fn ease_in_out_should_return_half_at_midpoint() {
153        // 3(0.5)² − 2(0.5)³ = 0.75 − 0.25 = 0.5 exactly.
154        let u = Easing::EaseInOut.apply(0.5);
155        assert!((u - 0.5).abs() < 0.001, "expected 0.5 at midpoint, got {u}");
156    }
157
158    #[test]
159    fn ease_in_out_should_be_below_linear_at_quarter() {
160        // Slow start: eased value at t=0.1 should be below 0.1.
161        let u = Easing::EaseInOut.apply(0.1);
162        assert!(u < 0.1, "ease-in-out at t=0.1 should be below 0.1, got {u}");
163    }
164
165    #[test]
166    fn ease_in_out_should_be_above_linear_at_three_quarters() {
167        // Slow end: eased value at t=0.9 should be above 0.9.
168        let u = Easing::EaseInOut.apply(0.9);
169        assert!(u > 0.9, "ease-in-out at t=0.9 should be above 0.9, got {u}");
170    }
171
172    #[test]
173    fn ease_out_should_be_above_linear_at_midpoint() {
174        // 1 − (1−0.5)³ = 1 − 0.125 = 0.875, well above the linear 0.5.
175        let u = Easing::EaseOut.apply(0.5);
176        assert!(u > 0.5, "ease-out at t=0.5 should be above 0.5, got {u}");
177        assert!((u - 0.875).abs() < f64::EPSILON, "expected 0.875, got {u}");
178    }
179
180    #[test]
181    fn ease_in_should_be_below_linear_at_midpoint() {
182        // t³ at t=0.5 → 0.125, well below the linear 0.5.
183        let u = Easing::EaseIn.apply(0.5);
184        assert!(u < 0.5, "ease-in at t=0.5 should be below 0.5, got {u}");
185        assert!((u - 0.125).abs() < f64::EPSILON, "expected 0.125, got {u}");
186    }
187
188    #[test]
189    fn hold_easing_should_return_start_value_at_midpoint() {
190        // t = 0.5: still holding at the start — must return 0.0.
191        let u = Easing::Hold.apply(0.5);
192        assert!(
193            (u - 0.0).abs() < f64::EPSILON,
194            "expected 0.0 at t=0.5, got {u}"
195        );
196    }
197
198    #[test]
199    fn hold_easing_should_snap_at_keyframe_boundary() {
200        // t = 1.0: exactly at the next keyframe — must snap to 1.0.
201        let u = Easing::Hold.apply(1.0);
202        assert!(
203            (u - 1.0).abs() < f64::EPSILON,
204            "expected 1.0 at t=1.0, got {u}"
205        );
206
207        // t slightly above 1.0 also returns 1.0.
208        let u2 = Easing::Hold.apply(1.5);
209        assert!(
210            (u2 - 1.0).abs() < f64::EPSILON,
211            "expected 1.0 at t=1.5, got {u2}"
212        );
213    }
214
215    // ── Full-track easing tests (issue #366) ─────────────────────────────────
216    //
217    // Each test builds a two-keyframe AnimationTrack<f64>:
218    //   keyframe 0: t=0 s  → value 0.0  (with the easing under test)
219    //   keyframe 1: t=1 s  → value 1.0  (easing unused — no subsequent keyframe)
220    // and asserts value_at at 0 %, 25 %, 50 %, 75 %, and 100 % of the interval.
221    // All assertions use ±0.001 tolerance to be frame-accurate without brittleness.
222
223    fn track_with_easing(easing: Easing) -> AnimationTrack<f64> {
224        AnimationTrack::new()
225            .push(Keyframe::new(Duration::ZERO, 0.0_f64, easing))
226            .push(Keyframe::new(
227                Duration::from_secs(1),
228                1.0_f64,
229                Easing::Linear,
230            ))
231    }
232
233    #[test]
234    fn hold_easing_should_hold_at_start_value() {
235        let track = track_with_easing(Easing::Hold);
236
237        // At t=0 s the value is 0.0 (before first interval starts being consumed).
238        let v0 = track.value_at(Duration::ZERO);
239        assert!((v0 - 0.0).abs() < 0.001, "t=0: expected 0.0, got {v0}");
240
241        // At 25 %, 50 %, 75 % the Hold easing keeps the value at 0.0.
242        let v25 = track.value_at(Duration::from_millis(250));
243        assert!(
244            (v25 - 0.0).abs() < 0.001,
245            "t=250ms: expected 0.0, got {v25}"
246        );
247
248        let v50 = track.value_at(Duration::from_millis(500));
249        assert!(
250            (v50 - 0.0).abs() < 0.001,
251            "t=500ms: expected 0.0, got {v50}"
252        );
253
254        let v75 = track.value_at(Duration::from_millis(750));
255        assert!(
256            (v75 - 0.0).abs() < 0.001,
257            "t=750ms: expected 0.0, got {v75}"
258        );
259
260        // At t=1 s the AnimationTrack "after last keyframe" branch returns 1.0.
261        let v100 = track.value_at(Duration::from_secs(1));
262        assert!((v100 - 1.0).abs() < 0.001, "t=1s: expected 1.0, got {v100}");
263    }
264
265    #[test]
266    fn linear_easing_should_interpolate_uniformly() {
267        let track = track_with_easing(Easing::Linear);
268
269        let v0 = track.value_at(Duration::ZERO);
270        assert!((v0 - 0.0).abs() < 0.001, "t=0: expected 0.0, got {v0}");
271
272        let v25 = track.value_at(Duration::from_millis(250));
273        assert!(
274            (v25 - 0.25).abs() < 0.001,
275            "t=250ms: expected 0.25, got {v25}"
276        );
277
278        let v50 = track.value_at(Duration::from_millis(500));
279        assert!(
280            (v50 - 0.5).abs() < 0.001,
281            "t=500ms: expected 0.5, got {v50}"
282        );
283
284        let v75 = track.value_at(Duration::from_millis(750));
285        assert!(
286            (v75 - 0.75).abs() < 0.001,
287            "t=750ms: expected 0.75, got {v75}"
288        );
289
290        let v100 = track.value_at(Duration::from_secs(1));
291        assert!((v100 - 1.0).abs() < 0.001, "t=1s: expected 1.0, got {v100}");
292    }
293
294    #[test]
295    fn ease_in_should_be_slow_at_start() {
296        // EaseIn: y = t³ — slow start, accelerates toward the end.
297        let track = track_with_easing(Easing::EaseIn);
298
299        let v0 = track.value_at(Duration::ZERO);
300        assert!((v0 - 0.0).abs() < 0.001, "t=0: expected 0.0, got {v0}");
301
302        // At 25 % (t=0.25): 0.25³ ≈ 0.015625 — well below linear 0.25.
303        let v25 = track.value_at(Duration::from_millis(250));
304        assert!(
305            (v25 - 0.015_625).abs() < 0.001,
306            "t=250ms: expected ~0.016, got {v25}"
307        );
308        assert!(
309            v25 < 0.25,
310            "ease-in at 25% must be below linear ({v25} >= 0.25)"
311        );
312
313        // At 50 % (t=0.5): 0.5³ = 0.125 — below linear 0.5.
314        let v50 = track.value_at(Duration::from_millis(500));
315        assert!(
316            (v50 - 0.125).abs() < 0.001,
317            "t=500ms: expected 0.125, got {v50}"
318        );
319        assert!(
320            v50 < 0.5,
321            "ease-in at 50% must be below linear ({v50} >= 0.5)"
322        );
323
324        // At 75 % (t=0.75): 0.75³ ≈ 0.421875 — below linear 0.75.
325        let v75 = track.value_at(Duration::from_millis(750));
326        assert!(
327            (v75 - 0.421_875).abs() < 0.001,
328            "t=750ms: expected ~0.422, got {v75}"
329        );
330        assert!(
331            v75 < 0.75,
332            "ease-in at 75% must be below linear ({v75} >= 0.75)"
333        );
334
335        let v100 = track.value_at(Duration::from_secs(1));
336        assert!((v100 - 1.0).abs() < 0.001, "t=1s: expected 1.0, got {v100}");
337    }
338
339    #[test]
340    fn ease_out_should_be_fast_at_start() {
341        // EaseOut: y = 1 − (1−t)³ — fast start, decelerates toward the end.
342        let track = track_with_easing(Easing::EaseOut);
343
344        let v0 = track.value_at(Duration::ZERO);
345        assert!((v0 - 0.0).abs() < 0.001, "t=0: expected 0.0, got {v0}");
346
347        // At 25 %: 1−(0.75)³ ≈ 0.578125 — well above linear 0.25.
348        let v25 = track.value_at(Duration::from_millis(250));
349        assert!(
350            (v25 - 0.578_125).abs() < 0.001,
351            "t=250ms: expected ~0.578, got {v25}"
352        );
353        assert!(
354            v25 > 0.25,
355            "ease-out at 25% must be above linear ({v25} <= 0.25)"
356        );
357
358        // At 50 %: 1−(0.5)³ = 0.875 — above linear 0.5.
359        let v50 = track.value_at(Duration::from_millis(500));
360        assert!(
361            (v50 - 0.875).abs() < 0.001,
362            "t=500ms: expected 0.875, got {v50}"
363        );
364        assert!(
365            v50 > 0.5,
366            "ease-out at 50% must be above linear ({v50} <= 0.5)"
367        );
368
369        // At 75 %: 1−(0.25)³ ≈ 0.984375 — above linear 0.75.
370        let v75 = track.value_at(Duration::from_millis(750));
371        assert!(
372            (v75 - 0.984_375).abs() < 0.001,
373            "t=750ms: expected ~0.984, got {v75}"
374        );
375        assert!(
376            v75 > 0.75,
377            "ease-out at 75% must be above linear ({v75} <= 0.75)"
378        );
379
380        let v100 = track.value_at(Duration::from_secs(1));
381        assert!((v100 - 1.0).abs() < 0.001, "t=1s: expected 1.0, got {v100}");
382    }
383
384    #[test]
385    fn ease_in_out_should_be_symmetric_at_midpoint() {
386        // EaseInOut: y = 3t² − 2t³ — slow at both ends, fast in the middle.
387        let track = track_with_easing(Easing::EaseInOut);
388
389        let v0 = track.value_at(Duration::ZERO);
390        assert!((v0 - 0.0).abs() < 0.001, "t=0: expected 0.0, got {v0}");
391
392        // At 25 %: 3(0.25)²−2(0.25)³ = 0.15625 — below linear (slow start).
393        let v25 = track.value_at(Duration::from_millis(250));
394        assert!(
395            (v25 - 0.15625).abs() < 0.001,
396            "t=250ms: expected ~0.156, got {v25}"
397        );
398        assert!(
399            v25 < 0.25,
400            "ease-in-out at 25% must be below linear ({v25} >= 0.25)"
401        );
402
403        // At 50 % the function is symmetric: 3(0.5)²−2(0.5)³ = 0.5.
404        let v50 = track.value_at(Duration::from_millis(500));
405        assert!(
406            (v50 - 0.5).abs() < 0.001,
407            "t=500ms: expected 0.5, got {v50}"
408        );
409
410        // At 75 %: 3(0.75)²−2(0.75)³ = 0.84375 — above linear (slow end).
411        let v75 = track.value_at(Duration::from_millis(750));
412        assert!(
413            (v75 - 0.84375).abs() < 0.001,
414            "t=750ms: expected ~0.844, got {v75}"
415        );
416        assert!(
417            v75 > 0.75,
418            "ease-in-out at 75% must be above linear ({v75} <= 0.75)"
419        );
420
421        let v100 = track.value_at(Duration::from_secs(1));
422        assert!((v100 - 1.0).abs() < 0.001, "t=1s: expected 1.0, got {v100}");
423    }
424
425    #[test]
426    fn bezier_ease_preset_should_match_css_reference() {
427        // CSS `ease` = cubic-bezier(0.25, 0.1, 0.25, 1.0).
428        // Reference value at t=0.5 from the CSS spec: ≈ 0.8029.
429        let track = track_with_easing(Easing::Bezier {
430            p1: (0.25, 0.1),
431            p2: (0.25, 1.0),
432        });
433
434        let v0 = track.value_at(Duration::ZERO);
435        assert!((v0 - 0.0).abs() < 0.001, "t=0: expected 0.0, got {v0}");
436
437        // At 50 % the CSS `ease` curve produces ≈ 0.8029 (fast initially).
438        let v50 = track.value_at(Duration::from_millis(500));
439        assert!(
440            (v50 - 0.8029_f64).abs() < 0.01,
441            "t=500ms: expected ~0.803 (CSS ease midpoint), got {v50}"
442        );
443        assert!(
444            v50 > 0.5,
445            "CSS ease at 50% must be above linear ({v50} <= 0.5)"
446        );
447
448        let v100 = track.value_at(Duration::from_secs(1));
449        assert!((v100 - 1.0).abs() < 0.001, "t=1s: expected 1.0, got {v100}");
450    }
451}