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::{cos, powf, powi, sin, sqrt};
26use core::f32::consts::PI;
27
28/// All 31 classic easing variants plus an escape-hatch `Custom` function pointer.
29///
30/// # `PartialEq` behaviour
31///
32/// `Custom(_)` never equals anything (including itself) because function
33/// pointers aren't meaningfully comparable by identity in this context.
34/// All other variants use structural equality.
35///
36/// # Serialization
37///
38/// With the `serde` feature, all variants except `Custom` are serializable.
39/// `Custom` is skipped — function pointers cannot be serialized.
40#[derive(Clone, Debug)]
41#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
42pub enum Easing {
43    /// Constant velocity — no acceleration.
44    Linear,
45
46    // ── Polynomial ────────────────────────────────────────────────────────────
47    /// Quadratic ease-in (accelerates from zero velocity).
48    EaseInQuad,
49    /// Quadratic ease-out (decelerates to zero velocity).
50    EaseOutQuad,
51    /// Quadratic ease-in-out (accelerates then decelerates).
52    EaseInOutQuad,
53
54    /// Cubic ease-in.
55    EaseInCubic,
56    /// Cubic ease-out.
57    EaseOutCubic,
58    /// Cubic ease-in-out.
59    EaseInOutCubic,
60
61    /// Quartic ease-in.
62    EaseInQuart,
63    /// Quartic ease-out.
64    EaseOutQuart,
65    /// Quartic ease-in-out.
66    EaseInOutQuart,
67
68    /// Quintic ease-in.
69    EaseInQuint,
70    /// Quintic ease-out.
71    EaseOutQuint,
72    /// Quintic ease-in-out.
73    EaseInOutQuint,
74
75    // ── Sinusoidal ────────────────────────────────────────────────────────────
76    /// Sinusoidal ease-in.
77    EaseInSine,
78    /// Sinusoidal ease-out.
79    EaseOutSine,
80    /// Sinusoidal ease-in-out.
81    EaseInOutSine,
82
83    // ── Exponential ───────────────────────────────────────────────────────────
84    /// Exponential ease-in.
85    EaseInExpo,
86    /// Exponential ease-out.
87    EaseOutExpo,
88    /// Exponential ease-in-out.
89    EaseInOutExpo,
90
91    // ── Circular ──────────────────────────────────────────────────────────────
92    /// Circular ease-in.
93    EaseInCirc,
94    /// Circular ease-out.
95    EaseOutCirc,
96    /// Circular ease-in-out.
97    EaseInOutCirc,
98
99    // ── Back (overshoot) ──────────────────────────────────────────────────────
100    /// Back ease-in — slight overshoot at the start.
101    EaseInBack,
102    /// Back ease-out — slight overshoot at the end.
103    EaseOutBack,
104    /// Back ease-in-out.
105    EaseInOutBack,
106
107    // ── Elastic ───────────────────────────────────────────────────────────────
108    /// Elastic ease-in — spring-like oscillation at the start.
109    EaseInElastic,
110    /// Elastic ease-out — spring-like oscillation at the end.
111    EaseOutElastic,
112    /// Elastic ease-in-out.
113    EaseInOutElastic,
114
115    // ── Bounce ────────────────────────────────────────────────────────────────
116    /// Bounce ease-in — ball bounce at the start.
117    EaseInBounce,
118    /// Bounce ease-out — ball bounces to rest at the end.
119    EaseOutBounce,
120    /// Bounce ease-in-out.
121    EaseInOutBounce,
122
123    // ── Escape hatch ──────────────────────────────────────────────────────────
124    /// Custom function pointer — zero overhead, no allocation.
125    ///
126    /// Not serializable and does not participate in `PartialEq` (always `false`).
127    ///
128    /// ```rust
129    /// use animato_core::Easing;
130    /// let e = Easing::Custom(|t| t * t * t);
131    /// assert_eq!(e.apply(0.5), 0.125);
132    /// ```
133    #[cfg_attr(feature = "serde", serde(skip))]
134    Custom(fn(f32) -> f32),
135}
136
137// Manual PartialEq: Custom never equals anything; all others use discriminant.
138impl PartialEq for Easing {
139    fn eq(&self, other: &Self) -> bool {
140        match (self, other) {
141            (Easing::Custom(_), _) | (_, Easing::Custom(_)) => false,
142            _ => core::mem::discriminant(self) == core::mem::discriminant(other),
143        }
144    }
145}
146
147impl Easing {
148    /// Evaluate the easing function at `t`.
149    ///
150    /// `t` is clamped to `[0.0, 1.0]` before evaluation — out-of-range
151    /// values never panic and never extrapolate for named variants.
152    ///
153    /// # Example
154    ///
155    /// ```rust
156    /// use animato_core::Easing;
157    /// assert_eq!(Easing::Linear.apply(0.5), 0.5);
158    /// assert_eq!(Easing::EaseInQuad.apply(0.0), 0.0);
159    /// assert_eq!(Easing::EaseInQuad.apply(1.0), 1.0);
160    /// // Out-of-range — no panic:
161    /// let _ = Easing::EaseOutCubic.apply(-1.0);
162    /// let _ = Easing::EaseOutCubic.apply(2.0);
163    /// ```
164    #[inline]
165    pub fn apply(&self, t: f32) -> f32 {
166        match self {
167            Easing::Custom(f) => f(t),
168            _ => {
169                let t = t.clamp(0.0, 1.0);
170                match self {
171                    Easing::Linear => t,
172                    Easing::EaseInQuad => ease_in_quad(t),
173                    Easing::EaseOutQuad => ease_out_quad(t),
174                    Easing::EaseInOutQuad => ease_in_out_quad(t),
175                    Easing::EaseInCubic => ease_in_cubic(t),
176                    Easing::EaseOutCubic => ease_out_cubic(t),
177                    Easing::EaseInOutCubic => ease_in_out_cubic(t),
178                    Easing::EaseInQuart => ease_in_quart(t),
179                    Easing::EaseOutQuart => ease_out_quart(t),
180                    Easing::EaseInOutQuart => ease_in_out_quart(t),
181                    Easing::EaseInQuint => ease_in_quint(t),
182                    Easing::EaseOutQuint => ease_out_quint(t),
183                    Easing::EaseInOutQuint => ease_in_out_quint(t),
184                    Easing::EaseInSine => ease_in_sine(t),
185                    Easing::EaseOutSine => ease_out_sine(t),
186                    Easing::EaseInOutSine => ease_in_out_sine(t),
187                    Easing::EaseInExpo => ease_in_expo(t),
188                    Easing::EaseOutExpo => ease_out_expo(t),
189                    Easing::EaseInOutExpo => ease_in_out_expo(t),
190                    Easing::EaseInCirc => ease_in_circ(t),
191                    Easing::EaseOutCirc => ease_out_circ(t),
192                    Easing::EaseInOutCirc => ease_in_out_circ(t),
193                    Easing::EaseInBack => ease_in_back(t),
194                    Easing::EaseOutBack => ease_out_back(t),
195                    Easing::EaseInOutBack => ease_in_out_back(t),
196                    Easing::EaseInElastic => ease_in_elastic(t),
197                    Easing::EaseOutElastic => ease_out_elastic(t),
198                    Easing::EaseInOutElastic => ease_in_out_elastic(t),
199                    Easing::EaseInBounce => ease_in_bounce(t),
200                    Easing::EaseOutBounce => ease_out_bounce(t),
201                    Easing::EaseInOutBounce => ease_in_out_bounce(t),
202                    Easing::Custom(_) => unreachable!(),
203                }
204            }
205        }
206    }
207
208    /// Returns a slice of all named (non-`Custom`) variants.
209    ///
210    /// Useful for picker UIs and exhaustive test sweeps.
211    ///
212    /// ```rust
213    /// use animato_core::Easing;
214    /// for e in Easing::all_named() {
215    ///     assert_eq!(e.apply(0.0), 0.0);
216    ///     assert_eq!(e.apply(1.0), 1.0);
217    /// }
218    /// ```
219    pub fn all_named() -> &'static [Easing] {
220        &[
221            Easing::Linear,
222            Easing::EaseInQuad,
223            Easing::EaseOutQuad,
224            Easing::EaseInOutQuad,
225            Easing::EaseInCubic,
226            Easing::EaseOutCubic,
227            Easing::EaseInOutCubic,
228            Easing::EaseInQuart,
229            Easing::EaseOutQuart,
230            Easing::EaseInOutQuart,
231            Easing::EaseInQuint,
232            Easing::EaseOutQuint,
233            Easing::EaseInOutQuint,
234            Easing::EaseInSine,
235            Easing::EaseOutSine,
236            Easing::EaseInOutSine,
237            Easing::EaseInExpo,
238            Easing::EaseOutExpo,
239            Easing::EaseInOutExpo,
240            Easing::EaseInCirc,
241            Easing::EaseOutCirc,
242            Easing::EaseInOutCirc,
243            Easing::EaseInBack,
244            Easing::EaseOutBack,
245            Easing::EaseInOutBack,
246            Easing::EaseInElastic,
247            Easing::EaseOutElastic,
248            Easing::EaseInOutElastic,
249            Easing::EaseInBounce,
250            Easing::EaseOutBounce,
251            Easing::EaseInOutBounce,
252        ]
253    }
254}
255
256// ──────────────────────────────────────────────────────────────────────────────
257// Free easing functions — #[inline] for zero-overhead direct calls
258// ──────────────────────────────────────────────────────────────────────────────
259
260/// Quadratic ease-in: `t²`
261#[inline]
262pub fn ease_in_quad(t: f32) -> f32 {
263    t * t
264}
265
266/// Quadratic ease-out: `1 - (1-t)²`
267#[inline]
268pub fn ease_out_quad(t: f32) -> f32 {
269    1.0 - (1.0 - t) * (1.0 - t)
270}
271
272/// Quadratic ease-in-out.
273#[inline]
274pub fn ease_in_out_quad(t: f32) -> f32 {
275    if t < 0.5 {
276        2.0 * t * t
277    } else {
278        1.0 - powi(-2.0 * t + 2.0, 2) / 2.0
279    }
280}
281
282/// Cubic ease-in: `t³`
283#[inline]
284pub fn ease_in_cubic(t: f32) -> f32 {
285    t * t * t
286}
287
288/// Cubic ease-out: `1 - (1-t)³`
289#[inline]
290pub fn ease_out_cubic(t: f32) -> f32 {
291    1.0 - powi(1.0 - t, 3)
292}
293
294/// Cubic ease-in-out.
295#[inline]
296pub fn ease_in_out_cubic(t: f32) -> f32 {
297    if t < 0.5 {
298        4.0 * t * t * t
299    } else {
300        1.0 - powi(-2.0 * t + 2.0, 3) / 2.0
301    }
302}
303
304/// Quartic ease-in: `t⁴`
305#[inline]
306pub fn ease_in_quart(t: f32) -> f32 {
307    t * t * t * t
308}
309
310/// Quartic ease-out: `1 - (1-t)⁴`
311#[inline]
312pub fn ease_out_quart(t: f32) -> f32 {
313    1.0 - powi(1.0 - t, 4)
314}
315
316/// Quartic ease-in-out.
317#[inline]
318pub fn ease_in_out_quart(t: f32) -> f32 {
319    if t < 0.5 {
320        8.0 * t * t * t * t
321    } else {
322        1.0 - powi(-2.0 * t + 2.0, 4) / 2.0
323    }
324}
325
326/// Quintic ease-in: `t⁵`
327#[inline]
328pub fn ease_in_quint(t: f32) -> f32 {
329    t * t * t * t * t
330}
331
332/// Quintic ease-out: `1 - (1-t)⁵`
333#[inline]
334pub fn ease_out_quint(t: f32) -> f32 {
335    1.0 - powi(1.0 - t, 5)
336}
337
338/// Quintic ease-in-out.
339#[inline]
340pub fn ease_in_out_quint(t: f32) -> f32 {
341    if t < 0.5 {
342        16.0 * t * t * t * t * t
343    } else {
344        1.0 - powi(-2.0 * t + 2.0, 5) / 2.0
345    }
346}
347
348/// Sinusoidal ease-in.
349#[inline]
350pub fn ease_in_sine(t: f32) -> f32 {
351    1.0 - cos(t * PI / 2.0)
352}
353/// Sinusoidal ease-out.
354#[inline]
355pub fn ease_out_sine(t: f32) -> f32 {
356    sin(t * PI / 2.0)
357}
358/// Sinusoidal ease-in-out.
359#[inline]
360pub fn ease_in_out_sine(t: f32) -> f32 {
361    -(cos(t * PI) - 1.0) / 2.0
362}
363
364/// Exponential ease-in.
365#[inline]
366pub fn ease_in_expo(t: f32) -> f32 {
367    if t == 0.0 {
368        0.0
369    } else {
370        powf(2.0, 10.0 * t - 10.0)
371    }
372}
373
374/// Exponential ease-out.
375#[inline]
376pub fn ease_out_expo(t: f32) -> f32 {
377    if t == 1.0 {
378        1.0
379    } else {
380        1.0 - powf(2.0, -10.0 * t)
381    }
382}
383
384/// Exponential ease-in-out.
385#[inline]
386pub fn ease_in_out_expo(t: f32) -> f32 {
387    if t == 0.0 {
388        return 0.0;
389    }
390    if t == 1.0 {
391        return 1.0;
392    }
393    if t < 0.5 {
394        powf(2.0, 20.0 * t - 10.0) / 2.0
395    } else {
396        (2.0 - powf(2.0, -20.0 * t + 10.0)) / 2.0
397    }
398}
399
400/// Circular ease-in.
401#[inline]
402pub fn ease_in_circ(t: f32) -> f32 {
403    1.0 - sqrt(1.0 - t * t)
404}
405/// Circular ease-out.
406#[inline]
407pub fn ease_out_circ(t: f32) -> f32 {
408    sqrt(1.0 - (t - 1.0) * (t - 1.0))
409}
410
411/// Circular ease-in-out.
412#[inline]
413pub fn ease_in_out_circ(t: f32) -> f32 {
414    if t < 0.5 {
415        (1.0 - sqrt(1.0 - powi(2.0 * t, 2))) / 2.0
416    } else {
417        (sqrt(1.0 - powi(-2.0 * t + 2.0, 2)) + 1.0) / 2.0
418    }
419}
420
421const BACK_C1: f32 = 1.701_58;
422const BACK_C2: f32 = BACK_C1 * 1.525;
423const BACK_C3: f32 = BACK_C1 + 1.0;
424
425/// Back ease-in — overshoots slightly then pulls back.
426#[inline]
427pub fn ease_in_back(t: f32) -> f32 {
428    BACK_C3 * t * t * t - BACK_C1 * t * t
429}
430
431/// Back ease-out — overshoots the target then settles.
432#[inline]
433pub fn ease_out_back(t: f32) -> f32 {
434    let t = t - 1.0;
435    1.0 + BACK_C3 * t * t * t + BACK_C1 * t * t
436}
437
438/// Back ease-in-out.
439#[inline]
440pub fn ease_in_out_back(t: f32) -> f32 {
441    if t < 0.5 {
442        (powi(2.0 * t, 2) * ((BACK_C2 + 1.0) * 2.0 * t - BACK_C2)) / 2.0
443    } else {
444        (powi(2.0 * t - 2.0, 2) * ((BACK_C2 + 1.0) * (2.0 * t - 2.0) + BACK_C2) + 2.0) / 2.0
445    }
446}
447
448const ELASTIC_C4: f32 = (2.0 * PI) / 3.0;
449const ELASTIC_C5: f32 = (2.0 * PI) / 4.5;
450
451/// Elastic ease-in — spring-like oscillation at the beginning.
452#[inline]
453pub fn ease_in_elastic(t: f32) -> f32 {
454    if t == 0.0 {
455        return 0.0;
456    }
457    if t == 1.0 {
458        return 1.0;
459    }
460    -powf(2.0, 10.0 * t - 10.0) * sin((10.0 * t - 10.75) * ELASTIC_C4)
461}
462
463/// Elastic ease-out — spring-like oscillation at the end.
464#[inline]
465pub fn ease_out_elastic(t: f32) -> f32 {
466    if t == 0.0 {
467        return 0.0;
468    }
469    if t == 1.0 {
470        return 1.0;
471    }
472    powf(2.0, -10.0 * t) * sin((10.0 * t - 0.75) * ELASTIC_C4) + 1.0
473}
474
475/// Elastic ease-in-out.
476#[inline]
477pub fn ease_in_out_elastic(t: f32) -> f32 {
478    if t == 0.0 {
479        return 0.0;
480    }
481    if t == 1.0 {
482        return 1.0;
483    }
484    if t < 0.5 {
485        -(powf(2.0, 20.0 * t - 10.0) * sin((20.0 * t - 11.125) * ELASTIC_C5)) / 2.0
486    } else {
487        (powf(2.0, -20.0 * t + 10.0) * sin((20.0 * t - 11.125) * ELASTIC_C5)) / 2.0 + 1.0
488    }
489}
490
491/// Bounce ease-out — ball bouncing to rest.
492#[inline]
493pub fn ease_out_bounce(t: f32) -> f32 {
494    const N1: f32 = 7.5625;
495    const D1: f32 = 2.75;
496    let t = &mut { t };
497    if *t < 1.0 / D1 {
498        N1 * *t * *t
499    } else if *t < 2.0 / D1 {
500        *t -= 1.5 / D1;
501        N1 * *t * *t + 0.75
502    } else if *t < 2.5 / D1 {
503        *t -= 2.25 / D1;
504        N1 * *t * *t + 0.9375
505    } else {
506        *t -= 2.625 / D1;
507        N1 * *t * *t + 0.984_375
508    }
509}
510
511/// Bounce ease-in.
512#[inline]
513pub fn ease_in_bounce(t: f32) -> f32 {
514    1.0 - ease_out_bounce(1.0 - t)
515}
516
517/// Bounce ease-in-out.
518#[inline]
519pub fn ease_in_out_bounce(t: f32) -> f32 {
520    if t < 0.5 {
521        (1.0 - ease_out_bounce(1.0 - 2.0 * t)) / 2.0
522    } else {
523        (1.0 + ease_out_bounce(2.0 * t - 1.0)) / 2.0
524    }
525}
526
527// ──────────────────────────────────────────────────────────────────────────────
528// Tests
529// ──────────────────────────────────────────────────────────────────────────────
530
531#[cfg(test)]
532mod tests {
533    use super::*;
534
535    const EPSILON: f32 = 1e-5;
536
537    fn approx_eq(a: f32, b: f32) -> bool {
538        (a - b).abs() < EPSILON
539    }
540
541    /// Every named variant must satisfy apply(0) == 0 and apply(1) == 1.
542    #[test]
543    fn all_named_endpoints() {
544        for easing in Easing::all_named() {
545            let v0 = easing.apply(0.0);
546            let v1 = easing.apply(1.0);
547            assert!(
548                approx_eq(v0, 0.0),
549                "{:?}.apply(0.0) = {} (expected 0.0)",
550                easing,
551                v0
552            );
553            assert!(
554                approx_eq(v1, 1.0),
555                "{:?}.apply(1.0) = {} (expected 1.0)",
556                easing,
557                v1
558            );
559        }
560    }
561
562    /// Out-of-range t must not panic for any named variant.
563    #[test]
564    fn no_panic_out_of_range() {
565        for easing in Easing::all_named() {
566            let _ = easing.apply(-0.5);
567            let _ = easing.apply(1.5);
568            let _ = easing.apply(f32::INFINITY);
569            let _ = easing.apply(f32::NEG_INFINITY);
570            // Note: NaN input is outside the defined contract (t ∈ [0,1]).
571            // f32::NAN.clamp(0.0, 1.0) == NaN per IEEE 754 — acceptable.
572        }
573    }
574
575    /// Custom function pointer works and is not equal to anything.
576    #[test]
577    fn custom_variant() {
578        let e = Easing::Custom(|t| t * t);
579        assert_eq!(e.apply(0.5), 0.25);
580    }
581
582    #[test]
583    fn custom_never_equals() {
584        let a = Easing::Custom(|t| t);
585        let b = Easing::Custom(|t| t);
586        let c = Easing::Linear;
587        assert!(a != b);
588        assert!(a != c);
589        assert!(c != a);
590    }
591
592    /// Named variants with same discriminant are equal.
593    #[test]
594    fn named_equality() {
595        assert_eq!(Easing::Linear, Easing::Linear);
596        assert_eq!(Easing::EaseOutCubic, Easing::EaseOutCubic);
597        assert_ne!(Easing::EaseInQuad, Easing::EaseOutQuad);
598    }
599
600    /// Free functions match Easing::apply output.
601    #[test]
602    fn free_functions_match_enum() {
603        let cases: &[(Easing, fn(f32) -> f32)] = &[
604            (Easing::EaseInQuad, ease_in_quad),
605            (Easing::EaseOutQuad, ease_out_quad),
606            (Easing::EaseInCubic, ease_in_cubic),
607            (Easing::EaseOutCubic, ease_out_cubic),
608            (Easing::EaseInOutCubic, ease_in_out_cubic),
609            (Easing::EaseOutBounce, ease_out_bounce),
610            (Easing::EaseOutElastic, ease_out_elastic),
611            (Easing::EaseOutBack, ease_out_back),
612        ];
613        for t in [0.1, 0.25, 0.5, 0.75, 0.9] {
614            for (easing, f) in cases {
615                let a = easing.apply(t);
616                let b = f(t);
617                assert!(
618                    approx_eq(a, b),
619                    "{:?} at t={}: enum={} free_fn={}",
620                    easing,
621                    t,
622                    a,
623                    b
624                );
625            }
626        }
627    }
628
629    /// EaseOut variants produce values > t for t in (0, 1) — front-loaded motion.
630    #[test]
631    fn ease_out_frontloaded() {
632        for t in [0.1_f32, 0.3, 0.5, 0.7] {
633            assert!(
634                Easing::EaseOutCubic.apply(t) > t,
635                "EaseOutCubic at t={} should be > t",
636                t
637            );
638        }
639    }
640
641    /// Linear is exactly t.
642    #[test]
643    fn linear_is_identity() {
644        for t in [0.0, 0.25, 0.5, 0.75, 1.0] {
645            assert_eq!(Easing::Linear.apply(t), t);
646        }
647    }
648
649    /// all_named() has exactly 31 entries.
650    #[test]
651    fn all_named_count() {
652        assert_eq!(Easing::all_named().len(), 31);
653    }
654}