Skip to main content

animato_core/
easing.rs

1//! All easing functions for the Animato animation library.
2//!
3//! Two ways to use easing:
4//!
5//! 1. **`Easing` enum** — storable, passable, optionally serializable:
6//!    ```rust
7//!    use animato_core::Easing;
8//!    let e = Easing::EaseOutCubic;
9//!    let v = e.apply(0.5);
10//!    ```
11//!
12//! 2. **Free functions** — zero-overhead, inlined at call site:
13//!    ```rust
14//!    use animato_core::easing::ease_out_cubic;
15//!    let v = ease_out_cubic(0.5);
16//!    ```
17//!
18//! ## Invariants
19//!
20//! All named variants satisfy:
21//! - `apply(0.0) == 0.0`
22//! - `apply(1.0) == 1.0`
23//! - `t` outside `[0.0, 1.0]` is clamped — no panic
24
25use crate::math::{ceil, cos, powf, powi, sin, sqrt};
26use core::f32::consts::PI;
27
28/// All 31 classic easing variants, CSS-compatible parameterized variants,
29/// and an escape-hatch `Custom` function pointer.
30///
31/// # `PartialEq` behaviour
32///
33/// `Custom(_)` never equals anything (including itself) because function
34/// pointers aren't meaningfully comparable by identity in this context.
35/// All other variants use structural equality, including parameter values for
36/// `CubicBezier` and `Steps`.
37///
38/// # Serialization
39///
40/// With the `serde` feature, all variants except `Custom` are serializable.
41/// `Custom` is skipped — function pointers cannot be serialized.
42#[derive(Clone, Debug)]
43#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
44pub enum Easing {
45    /// Constant velocity — no acceleration.
46    Linear,
47
48    // ── Polynomial ────────────────────────────────────────────────────────────
49    /// Quadratic ease-in (accelerates from zero velocity).
50    EaseInQuad,
51    /// Quadratic ease-out (decelerates to zero velocity).
52    EaseOutQuad,
53    /// Quadratic ease-in-out (accelerates then decelerates).
54    EaseInOutQuad,
55
56    /// Cubic ease-in.
57    EaseInCubic,
58    /// Cubic ease-out.
59    EaseOutCubic,
60    /// Cubic ease-in-out.
61    EaseInOutCubic,
62
63    /// Quartic ease-in.
64    EaseInQuart,
65    /// Quartic ease-out.
66    EaseOutQuart,
67    /// Quartic ease-in-out.
68    EaseInOutQuart,
69
70    /// Quintic ease-in.
71    EaseInQuint,
72    /// Quintic ease-out.
73    EaseOutQuint,
74    /// Quintic ease-in-out.
75    EaseInOutQuint,
76
77    // ── Sinusoidal ────────────────────────────────────────────────────────────
78    /// Sinusoidal ease-in.
79    EaseInSine,
80    /// Sinusoidal ease-out.
81    EaseOutSine,
82    /// Sinusoidal ease-in-out.
83    EaseInOutSine,
84
85    // ── Exponential ───────────────────────────────────────────────────────────
86    /// Exponential ease-in.
87    EaseInExpo,
88    /// Exponential ease-out.
89    EaseOutExpo,
90    /// Exponential ease-in-out.
91    EaseInOutExpo,
92
93    // ── Circular ──────────────────────────────────────────────────────────────
94    /// Circular ease-in.
95    EaseInCirc,
96    /// Circular ease-out.
97    EaseOutCirc,
98    /// Circular ease-in-out.
99    EaseInOutCirc,
100
101    // ── Back (overshoot) ──────────────────────────────────────────────────────
102    /// Back ease-in — slight overshoot at the start.
103    EaseInBack,
104    /// Back ease-out — slight overshoot at the end.
105    EaseOutBack,
106    /// Back ease-in-out.
107    EaseInOutBack,
108
109    // ── Elastic ───────────────────────────────────────────────────────────────
110    /// Elastic ease-in — spring-like oscillation at the start.
111    EaseInElastic,
112    /// Elastic ease-out — spring-like oscillation at the end.
113    EaseOutElastic,
114    /// Elastic ease-in-out.
115    EaseInOutElastic,
116
117    // ── Bounce ────────────────────────────────────────────────────────────────
118    /// Bounce ease-in — ball bounce at the start.
119    EaseInBounce,
120    /// Bounce ease-out — ball bounces to rest at the end.
121    EaseOutBounce,
122    /// Bounce ease-in-out.
123    EaseInOutBounce,
124
125    // ── CSS-compatible ──────────────────────────────────────────────────────
126    /// CSS-compatible cubic Bezier easing: `(x1, y1, x2, y2)`.
127    ///
128    /// The x control points are clamped to `[0.0, 1.0]` before evaluation,
129    /// matching the valid CSS timing-function domain. The y control points may
130    /// overshoot to support anticipation and bounce-like curves.
131    CubicBezier(f32, f32, f32, f32),
132
133    /// CSS `steps(n, jump-end)` easing.
134    ///
135    /// `0` is treated as `1` step so invalid input remains safe.
136    Steps(u32),
137
138    // ── Escape hatch ──────────────────────────────────────────────────────────
139    /// Custom function pointer — zero overhead, no allocation.
140    ///
141    /// Not serializable and does not participate in `PartialEq` (always `false`).
142    ///
143    /// ```rust
144    /// use animato_core::Easing;
145    /// let e = Easing::Custom(|t| t * t * t);
146    /// assert_eq!(e.apply(0.5), 0.125);
147    /// ```
148    #[cfg_attr(feature = "serde", serde(skip))]
149    Custom(fn(f32) -> f32),
150}
151
152// Manual PartialEq: Custom never equals anything; parameterized variants compare data.
153impl PartialEq for Easing {
154    fn eq(&self, other: &Self) -> bool {
155        match (self, other) {
156            (Easing::Custom(_), _) | (_, Easing::Custom(_)) => false,
157            (Easing::CubicBezier(ax1, ay1, ax2, ay2), Easing::CubicBezier(bx1, by1, bx2, by2)) => {
158                ax1 == bx1 && ay1 == by1 && ax2 == bx2 && ay2 == by2
159            }
160            (Easing::Steps(a), Easing::Steps(b)) => a == b,
161            _ => core::mem::discriminant(self) == core::mem::discriminant(other),
162        }
163    }
164}
165
166impl Easing {
167    /// Evaluate the easing function at `t`.
168    ///
169    /// `t` is clamped to `[0.0, 1.0]` before evaluation — out-of-range
170    /// values never panic and never extrapolate for named variants.
171    ///
172    /// # Example
173    ///
174    /// ```rust
175    /// use animato_core::Easing;
176    /// assert_eq!(Easing::Linear.apply(0.5), 0.5);
177    /// assert_eq!(Easing::EaseInQuad.apply(0.0), 0.0);
178    /// assert_eq!(Easing::EaseInQuad.apply(1.0), 1.0);
179    /// // Out-of-range — no panic:
180    /// let _ = Easing::EaseOutCubic.apply(-1.0);
181    /// let _ = Easing::EaseOutCubic.apply(2.0);
182    /// ```
183    #[inline]
184    pub fn apply(&self, t: f32) -> f32 {
185        match self {
186            Easing::Custom(f) => f(t),
187            _ => {
188                let t = t.clamp(0.0, 1.0);
189                match self {
190                    Easing::Linear => t,
191                    Easing::EaseInQuad => ease_in_quad(t),
192                    Easing::EaseOutQuad => ease_out_quad(t),
193                    Easing::EaseInOutQuad => ease_in_out_quad(t),
194                    Easing::EaseInCubic => ease_in_cubic(t),
195                    Easing::EaseOutCubic => ease_out_cubic(t),
196                    Easing::EaseInOutCubic => ease_in_out_cubic(t),
197                    Easing::EaseInQuart => ease_in_quart(t),
198                    Easing::EaseOutQuart => ease_out_quart(t),
199                    Easing::EaseInOutQuart => ease_in_out_quart(t),
200                    Easing::EaseInQuint => ease_in_quint(t),
201                    Easing::EaseOutQuint => ease_out_quint(t),
202                    Easing::EaseInOutQuint => ease_in_out_quint(t),
203                    Easing::EaseInSine => ease_in_sine(t),
204                    Easing::EaseOutSine => ease_out_sine(t),
205                    Easing::EaseInOutSine => ease_in_out_sine(t),
206                    Easing::EaseInExpo => ease_in_expo(t),
207                    Easing::EaseOutExpo => ease_out_expo(t),
208                    Easing::EaseInOutExpo => ease_in_out_expo(t),
209                    Easing::EaseInCirc => ease_in_circ(t),
210                    Easing::EaseOutCirc => ease_out_circ(t),
211                    Easing::EaseInOutCirc => ease_in_out_circ(t),
212                    Easing::EaseInBack => ease_in_back(t),
213                    Easing::EaseOutBack => ease_out_back(t),
214                    Easing::EaseInOutBack => ease_in_out_back(t),
215                    Easing::EaseInElastic => ease_in_elastic(t),
216                    Easing::EaseOutElastic => ease_out_elastic(t),
217                    Easing::EaseInOutElastic => ease_in_out_elastic(t),
218                    Easing::EaseInBounce => ease_in_bounce(t),
219                    Easing::EaseOutBounce => ease_out_bounce(t),
220                    Easing::EaseInOutBounce => ease_in_out_bounce(t),
221                    Easing::CubicBezier(x1, y1, x2, y2) => cubic_bezier(t, *x1, *y1, *x2, *y2),
222                    Easing::Steps(count) => steps(t, *count),
223                    Easing::Custom(_) => unreachable!(),
224                }
225            }
226        }
227    }
228
229    /// Returns a slice of all named (non-`Custom`) variants.
230    ///
231    /// Useful for picker UIs and exhaustive test sweeps.
232    ///
233    /// ```rust
234    /// use animato_core::Easing;
235    /// for e in Easing::all_named() {
236    ///     assert_eq!(e.apply(0.0), 0.0);
237    ///     assert_eq!(e.apply(1.0), 1.0);
238    /// }
239    /// ```
240    pub fn all_named() -> &'static [Easing] {
241        &[
242            Easing::Linear,
243            Easing::EaseInQuad,
244            Easing::EaseOutQuad,
245            Easing::EaseInOutQuad,
246            Easing::EaseInCubic,
247            Easing::EaseOutCubic,
248            Easing::EaseInOutCubic,
249            Easing::EaseInQuart,
250            Easing::EaseOutQuart,
251            Easing::EaseInOutQuart,
252            Easing::EaseInQuint,
253            Easing::EaseOutQuint,
254            Easing::EaseInOutQuint,
255            Easing::EaseInSine,
256            Easing::EaseOutSine,
257            Easing::EaseInOutSine,
258            Easing::EaseInExpo,
259            Easing::EaseOutExpo,
260            Easing::EaseInOutExpo,
261            Easing::EaseInCirc,
262            Easing::EaseOutCirc,
263            Easing::EaseInOutCirc,
264            Easing::EaseInBack,
265            Easing::EaseOutBack,
266            Easing::EaseInOutBack,
267            Easing::EaseInElastic,
268            Easing::EaseOutElastic,
269            Easing::EaseInOutElastic,
270            Easing::EaseInBounce,
271            Easing::EaseOutBounce,
272            Easing::EaseInOutBounce,
273            Easing::CubicBezier(0.25, 0.1, 0.25, 1.0),
274            Easing::Steps(1),
275        ]
276    }
277}
278
279// ──────────────────────────────────────────────────────────────────────────────
280// Free easing functions — #[inline] for zero-overhead direct calls
281// ──────────────────────────────────────────────────────────────────────────────
282
283/// Quadratic ease-in: `t²`
284#[inline]
285pub fn ease_in_quad(t: f32) -> f32 {
286    t * t
287}
288
289/// Quadratic ease-out: `1 - (1-t)²`
290#[inline]
291pub fn ease_out_quad(t: f32) -> f32 {
292    1.0 - (1.0 - t) * (1.0 - t)
293}
294
295/// Quadratic ease-in-out.
296#[inline]
297pub fn ease_in_out_quad(t: f32) -> f32 {
298    if t < 0.5 {
299        2.0 * t * t
300    } else {
301        1.0 - powi(-2.0 * t + 2.0, 2) / 2.0
302    }
303}
304
305/// Cubic ease-in: `t³`
306#[inline]
307pub fn ease_in_cubic(t: f32) -> f32 {
308    t * t * t
309}
310
311/// Cubic ease-out: `1 - (1-t)³`
312#[inline]
313pub fn ease_out_cubic(t: f32) -> f32 {
314    1.0 - powi(1.0 - t, 3)
315}
316
317/// Cubic ease-in-out.
318#[inline]
319pub fn ease_in_out_cubic(t: f32) -> f32 {
320    if t < 0.5 {
321        4.0 * t * t * t
322    } else {
323        1.0 - powi(-2.0 * t + 2.0, 3) / 2.0
324    }
325}
326
327/// Quartic ease-in: `t⁴`
328#[inline]
329pub fn ease_in_quart(t: f32) -> f32 {
330    t * t * t * t
331}
332
333/// Quartic ease-out: `1 - (1-t)⁴`
334#[inline]
335pub fn ease_out_quart(t: f32) -> f32 {
336    1.0 - powi(1.0 - t, 4)
337}
338
339/// Quartic ease-in-out.
340#[inline]
341pub fn ease_in_out_quart(t: f32) -> f32 {
342    if t < 0.5 {
343        8.0 * t * t * t * t
344    } else {
345        1.0 - powi(-2.0 * t + 2.0, 4) / 2.0
346    }
347}
348
349/// Quintic ease-in: `t⁵`
350#[inline]
351pub fn ease_in_quint(t: f32) -> f32 {
352    t * t * t * t * t
353}
354
355/// Quintic ease-out: `1 - (1-t)⁵`
356#[inline]
357pub fn ease_out_quint(t: f32) -> f32 {
358    1.0 - powi(1.0 - t, 5)
359}
360
361/// Quintic ease-in-out.
362#[inline]
363pub fn ease_in_out_quint(t: f32) -> f32 {
364    if t < 0.5 {
365        16.0 * t * t * t * t * t
366    } else {
367        1.0 - powi(-2.0 * t + 2.0, 5) / 2.0
368    }
369}
370
371/// Sinusoidal ease-in.
372#[inline]
373pub fn ease_in_sine(t: f32) -> f32 {
374    1.0 - cos(t * PI / 2.0)
375}
376/// Sinusoidal ease-out.
377#[inline]
378pub fn ease_out_sine(t: f32) -> f32 {
379    sin(t * PI / 2.0)
380}
381/// Sinusoidal ease-in-out.
382#[inline]
383pub fn ease_in_out_sine(t: f32) -> f32 {
384    -(cos(t * PI) - 1.0) / 2.0
385}
386
387/// Exponential ease-in.
388#[inline]
389pub fn ease_in_expo(t: f32) -> f32 {
390    if t == 0.0 {
391        0.0
392    } else {
393        powf(2.0, 10.0 * t - 10.0)
394    }
395}
396
397/// Exponential ease-out.
398#[inline]
399pub fn ease_out_expo(t: f32) -> f32 {
400    if t == 1.0 {
401        1.0
402    } else {
403        1.0 - powf(2.0, -10.0 * t)
404    }
405}
406
407/// Exponential ease-in-out.
408#[inline]
409pub fn ease_in_out_expo(t: f32) -> f32 {
410    if t == 0.0 {
411        return 0.0;
412    }
413    if t == 1.0 {
414        return 1.0;
415    }
416    if t < 0.5 {
417        powf(2.0, 20.0 * t - 10.0) / 2.0
418    } else {
419        (2.0 - powf(2.0, -20.0 * t + 10.0)) / 2.0
420    }
421}
422
423/// Circular ease-in.
424#[inline]
425pub fn ease_in_circ(t: f32) -> f32 {
426    1.0 - sqrt(1.0 - t * t)
427}
428/// Circular ease-out.
429#[inline]
430pub fn ease_out_circ(t: f32) -> f32 {
431    sqrt(1.0 - (t - 1.0) * (t - 1.0))
432}
433
434/// Circular ease-in-out.
435#[inline]
436pub fn ease_in_out_circ(t: f32) -> f32 {
437    if t < 0.5 {
438        (1.0 - sqrt(1.0 - powi(2.0 * t, 2))) / 2.0
439    } else {
440        (sqrt(1.0 - powi(-2.0 * t + 2.0, 2)) + 1.0) / 2.0
441    }
442}
443
444const BACK_C1: f32 = 1.701_58;
445const BACK_C2: f32 = BACK_C1 * 1.525;
446const BACK_C3: f32 = BACK_C1 + 1.0;
447
448/// Back ease-in — overshoots slightly then pulls back.
449#[inline]
450pub fn ease_in_back(t: f32) -> f32 {
451    BACK_C3 * t * t * t - BACK_C1 * t * t
452}
453
454/// Back ease-out — overshoots the target then settles.
455#[inline]
456pub fn ease_out_back(t: f32) -> f32 {
457    let t = t - 1.0;
458    1.0 + BACK_C3 * t * t * t + BACK_C1 * t * t
459}
460
461/// Back ease-in-out.
462#[inline]
463pub fn ease_in_out_back(t: f32) -> f32 {
464    if t < 0.5 {
465        (powi(2.0 * t, 2) * ((BACK_C2 + 1.0) * 2.0 * t - BACK_C2)) / 2.0
466    } else {
467        (powi(2.0 * t - 2.0, 2) * ((BACK_C2 + 1.0) * (2.0 * t - 2.0) + BACK_C2) + 2.0) / 2.0
468    }
469}
470
471const ELASTIC_C4: f32 = (2.0 * PI) / 3.0;
472const ELASTIC_C5: f32 = (2.0 * PI) / 4.5;
473
474/// Elastic ease-in — spring-like oscillation at the beginning.
475#[inline]
476pub fn ease_in_elastic(t: f32) -> f32 {
477    if t == 0.0 {
478        return 0.0;
479    }
480    if t == 1.0 {
481        return 1.0;
482    }
483    -powf(2.0, 10.0 * t - 10.0) * sin((10.0 * t - 10.75) * ELASTIC_C4)
484}
485
486/// Elastic ease-out — spring-like oscillation at the end.
487#[inline]
488pub fn ease_out_elastic(t: f32) -> f32 {
489    if t == 0.0 {
490        return 0.0;
491    }
492    if t == 1.0 {
493        return 1.0;
494    }
495    powf(2.0, -10.0 * t) * sin((10.0 * t - 0.75) * ELASTIC_C4) + 1.0
496}
497
498/// Elastic ease-in-out.
499#[inline]
500pub fn ease_in_out_elastic(t: f32) -> f32 {
501    if t == 0.0 {
502        return 0.0;
503    }
504    if t == 1.0 {
505        return 1.0;
506    }
507    if t < 0.5 {
508        -(powf(2.0, 20.0 * t - 10.0) * sin((20.0 * t - 11.125) * ELASTIC_C5)) / 2.0
509    } else {
510        (powf(2.0, -20.0 * t + 10.0) * sin((20.0 * t - 11.125) * ELASTIC_C5)) / 2.0 + 1.0
511    }
512}
513
514/// Bounce ease-out — ball bouncing to rest.
515#[inline]
516pub fn ease_out_bounce(t: f32) -> f32 {
517    const N1: f32 = 7.5625;
518    const D1: f32 = 2.75;
519    let t = &mut { t };
520    if *t < 1.0 / D1 {
521        N1 * *t * *t
522    } else if *t < 2.0 / D1 {
523        *t -= 1.5 / D1;
524        N1 * *t * *t + 0.75
525    } else if *t < 2.5 / D1 {
526        *t -= 2.25 / D1;
527        N1 * *t * *t + 0.9375
528    } else {
529        *t -= 2.625 / D1;
530        N1 * *t * *t + 0.984_375
531    }
532}
533
534/// Bounce ease-in.
535#[inline]
536pub fn ease_in_bounce(t: f32) -> f32 {
537    1.0 - ease_out_bounce(1.0 - t)
538}
539
540/// Bounce ease-in-out.
541#[inline]
542pub fn ease_in_out_bounce(t: f32) -> f32 {
543    if t < 0.5 {
544        (1.0 - ease_out_bounce(1.0 - 2.0 * t)) / 2.0
545    } else {
546        (1.0 + ease_out_bounce(2.0 * t - 1.0)) / 2.0
547    }
548}
549
550/// CSS-compatible cubic Bezier easing.
551///
552/// `x1` and `x2` are clamped to `[0.0, 1.0]` because CSS timing functions
553/// require monotonic x control points. `y1` and `y2` are left unconstrained so
554/// curves can overshoot.
555#[inline]
556pub fn cubic_bezier(t: f32, x1: f32, y1: f32, x2: f32, y2: f32) -> f32 {
557    let t = t.clamp(0.0, 1.0);
558    if t == 0.0 || t == 1.0 {
559        return t;
560    }
561
562    let x1 = x1.clamp(0.0, 1.0);
563    let x2 = x2.clamp(0.0, 1.0);
564    let mut u = t;
565
566    for _ in 0..6 {
567        let x = sample_cubic(x1, x2, u) - t;
568        if x.abs() < 1e-6 {
569            return sample_cubic(y1, y2, u);
570        }
571        let derivative = sample_cubic_derivative(x1, x2, u);
572        if derivative.abs() < 1e-6 {
573            break;
574        }
575        u = (u - x / derivative).clamp(0.0, 1.0);
576    }
577
578    let mut low = 0.0;
579    let mut high = 1.0;
580    u = t;
581    for _ in 0..10 {
582        let x = sample_cubic(x1, x2, u);
583        if (x - t).abs() < 1e-6 {
584            break;
585        }
586        if x < t {
587            low = u;
588        } else {
589            high = u;
590        }
591        u = (low + high) * 0.5;
592    }
593
594    sample_cubic(y1, y2, u)
595}
596
597/// CSS `steps(n, jump-end)` easing.
598///
599/// `count = 0` is treated as one step.
600#[inline]
601pub fn steps(t: f32, count: u32) -> f32 {
602    let t = t.clamp(0.0, 1.0);
603    if t == 0.0 {
604        return 0.0;
605    }
606    let count = count.max(1) as f32;
607    (ceil(t * count) / count).clamp(0.0, 1.0)
608}
609
610#[inline]
611fn sample_cubic(a1: f32, a2: f32, t: f32) -> f32 {
612    let c = 3.0 * a1;
613    let b = 3.0 * (a2 - a1) - c;
614    let a = 1.0 - c - b;
615    ((a * t + b) * t + c) * t
616}
617
618#[inline]
619fn sample_cubic_derivative(a1: f32, a2: f32, t: f32) -> f32 {
620    let c = 3.0 * a1;
621    let b = 3.0 * (a2 - a1) - c;
622    let a = 1.0 - c - b;
623    (3.0 * a * t + 2.0 * b) * t + c
624}
625
626// ──────────────────────────────────────────────────────────────────────────────
627// Tests
628// ──────────────────────────────────────────────────────────────────────────────
629
630#[cfg(test)]
631mod tests {
632    use super::*;
633
634    const EPSILON: f32 = 1e-5;
635
636    fn approx_eq(a: f32, b: f32) -> bool {
637        (a - b).abs() < EPSILON
638    }
639
640    /// Every named variant must satisfy apply(0) == 0 and apply(1) == 1.
641    #[test]
642    fn all_named_endpoints() {
643        for easing in Easing::all_named() {
644            let v0 = easing.apply(0.0);
645            let v1 = easing.apply(1.0);
646            assert!(
647                approx_eq(v0, 0.0),
648                "{:?}.apply(0.0) = {} (expected 0.0)",
649                easing,
650                v0
651            );
652            assert!(
653                approx_eq(v1, 1.0),
654                "{:?}.apply(1.0) = {} (expected 1.0)",
655                easing,
656                v1
657            );
658        }
659    }
660
661    /// Out-of-range t must not panic for any named variant.
662    #[test]
663    fn no_panic_out_of_range() {
664        for easing in Easing::all_named() {
665            let _ = easing.apply(-0.5);
666            let _ = easing.apply(1.5);
667            let _ = easing.apply(f32::INFINITY);
668            let _ = easing.apply(f32::NEG_INFINITY);
669            // Note: NaN input is outside the defined contract (t ∈ [0,1]).
670            // f32::NAN.clamp(0.0, 1.0) == NaN per IEEE 754 — acceptable.
671        }
672    }
673
674    /// Custom function pointer works and is not equal to anything.
675    #[test]
676    fn custom_variant() {
677        let e = Easing::Custom(|t| t * t);
678        assert_eq!(e.apply(0.5), 0.25);
679    }
680
681    #[test]
682    fn custom_never_equals() {
683        let a = Easing::Custom(|t| t);
684        let b = Easing::Custom(|t| t);
685        let c = Easing::Linear;
686        assert!(a != b);
687        assert!(a != c);
688        assert!(c != a);
689    }
690
691    /// Named variants with same discriminant are equal.
692    #[test]
693    fn named_equality() {
694        assert_eq!(Easing::Linear, Easing::Linear);
695        assert_eq!(Easing::EaseOutCubic, Easing::EaseOutCubic);
696        assert_eq!(
697            Easing::CubicBezier(0.25, 0.1, 0.25, 1.0),
698            Easing::CubicBezier(0.25, 0.1, 0.25, 1.0)
699        );
700        assert_eq!(Easing::Steps(4), Easing::Steps(4));
701        assert_ne!(Easing::EaseInQuad, Easing::EaseOutQuad);
702        assert_ne!(Easing::Steps(3), Easing::Steps(4));
703    }
704
705    /// Free functions match Easing::apply output.
706    #[test]
707    fn free_functions_match_enum() {
708        let cases: &[(Easing, fn(f32) -> f32)] = &[
709            (Easing::EaseInQuad, ease_in_quad),
710            (Easing::EaseOutQuad, ease_out_quad),
711            (Easing::EaseInCubic, ease_in_cubic),
712            (Easing::EaseOutCubic, ease_out_cubic),
713            (Easing::EaseInOutCubic, ease_in_out_cubic),
714            (Easing::EaseOutBounce, ease_out_bounce),
715            (Easing::EaseOutElastic, ease_out_elastic),
716            (Easing::EaseOutBack, ease_out_back),
717        ];
718        for t in [0.1, 0.25, 0.5, 0.75, 0.9] {
719            for (easing, f) in cases {
720                let a = easing.apply(t);
721                let b = f(t);
722                assert!(
723                    approx_eq(a, b),
724                    "{:?} at t={}: enum={} free_fn={}",
725                    easing,
726                    t,
727                    a,
728                    b
729                );
730            }
731        }
732    }
733
734    /// EaseOut variants produce values > t for t in (0, 1) — front-loaded motion.
735    #[test]
736    fn ease_out_frontloaded() {
737        for t in [0.1_f32, 0.3, 0.5, 0.7] {
738            assert!(
739                Easing::EaseOutCubic.apply(t) > t,
740                "EaseOutCubic at t={} should be > t",
741                t
742            );
743        }
744    }
745
746    /// Linear is exactly t.
747    #[test]
748    fn linear_is_identity() {
749        for t in [0.0, 0.25, 0.5, 0.75, 1.0] {
750            assert_eq!(Easing::Linear.apply(t), t);
751        }
752    }
753
754    #[test]
755    fn cubic_bezier_linear_control_points_are_identity() {
756        let easing = Easing::CubicBezier(0.0, 0.0, 1.0, 1.0);
757        for t in [0.0, 0.1, 0.25, 0.5, 0.75, 0.9, 1.0] {
758            assert!(
759                approx_eq(easing.apply(t), t),
760                "linear cubic-bezier at t={} was {}",
761                t,
762                easing.apply(t)
763            );
764        }
765    }
766
767    #[test]
768    fn cubic_bezier_css_ease_shape_is_frontloaded() {
769        let ease = Easing::CubicBezier(0.25, 0.1, 0.25, 1.0);
770        assert_eq!(ease.apply(0.0), 0.0);
771        assert_eq!(ease.apply(1.0), 1.0);
772        assert!(ease.apply(0.5) > 0.5);
773    }
774
775    #[test]
776    fn cubic_bezier_clamps_invalid_x_control_points() {
777        let invalid = Easing::CubicBezier(-2.0, 0.0, 4.0, 1.0);
778        let clamped = Easing::CubicBezier(0.0, 0.0, 1.0, 1.0);
779        assert!(approx_eq(invalid.apply(0.5), clamped.apply(0.5)));
780    }
781
782    #[test]
783    fn steps_jump_end_behavior() {
784        let easing = Easing::Steps(4);
785        assert_eq!(easing.apply(0.0), 0.0);
786        assert_eq!(easing.apply(0.01), 0.25);
787        assert_eq!(easing.apply(0.25), 0.25);
788        assert_eq!(easing.apply(0.26), 0.5);
789        assert_eq!(easing.apply(1.0), 1.0);
790    }
791
792    #[test]
793    fn steps_zero_count_is_one_step() {
794        assert_eq!(Easing::Steps(0).apply(0.5), 1.0);
795    }
796
797    /// all_named() has exactly 33 entries.
798    #[test]
799    fn all_named_count() {
800        assert_eq!(Easing::all_named().len(), 33);
801    }
802}