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, log, 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    // ── Advanced parameterised (v0.8.0) ───────────────────────────────────────
139    /// Rough, organic-feeling easing with deterministic sine-based noise.
140    ///
141    /// `strength` in `[0.0, 1.0]` controls how rough the motion is.
142    /// `points` controls the number of noise harmonics (2–20).
143    RoughEase {
144        /// Noise amplitude: `0.0` = linear, `1.0` = very rough.
145        strength: f32,
146        /// Number of noise harmonics: more points = more complex noise.
147        points: u32,
148    },
149
150    /// Motion that accelerates at the edges and crawls through the middle.
151    ///
152    /// `linear_ratio` (`0.0`–`1.0`) is the fraction of time spent slow.
153    /// `power` controls how sharply the edges accelerate.
154    SlowMo {
155        /// Fraction of time spent slow: `0.0` = no slow-down, `1.0` = fully slow.
156        linear_ratio: f32,
157        /// Controls how sharply the edges accelerate: higher values = sharper acceleration.
158        power: f32,
159    },
160
161    /// Wiggling oscillation that fades in and out around the linear trend.
162    ///
163    /// `wiggles` is the number of complete oscillations.
164    Wiggle {
165        /// Number of complete oscillations.
166        wiggles: u32,
167    },
168
169    /// Configurable bounce easing.
170    ///
171    /// `strength` in `[0.0, 1.0]`: `0.0` = no bounce (linear), `1.0` = full EaseOutBounce.
172    CustomBounce {
173        /// Bounce strength: `0.0` = no bounce (linear), `1.0` = full EaseOutBounce.
174        strength: f32,
175    },
176
177    /// Exponential progression from `start` scale to `end` scale.
178    ///
179    /// Both `start` and `end` must be positive. Values near `1.0` approach linear.
180    ExpoScale {
181        /// Starting scale: must be positive.
182        start: f32,
183        /// Ending scale: must be positive.
184        end: f32,
185    },
186
187    // ── Escape hatch ──────────────────────────────────────────────────────────
188    /// Custom function pointer — zero overhead, no allocation.
189    ///
190    /// Not serializable and does not participate in `PartialEq` (always `false`).
191    ///
192    /// ```rust
193    /// use animato_core::Easing;
194    /// let e = Easing::Custom(|t| t * t * t);
195    /// assert_eq!(e.apply(0.5), 0.125);
196    /// ```
197    #[cfg_attr(feature = "serde", serde(skip))]
198    Custom(fn(f32) -> f32),
199}
200
201// Manual PartialEq: Custom never equals anything; parameterized variants compare data.
202impl PartialEq for Easing {
203    fn eq(&self, other: &Self) -> bool {
204        use Easing::*;
205        match (self, other) {
206            (Custom(_), _) | (_, Custom(_)) => false,
207            (CubicBezier(ax1, ay1, ax2, ay2), CubicBezier(bx1, by1, bx2, by2)) => {
208                ax1 == bx1 && ay1 == by1 && ax2 == bx2 && ay2 == by2
209            }
210            (Steps(a), Steps(b)) => a == b,
211            (
212                RoughEase {
213                    strength: sa,
214                    points: pa,
215                },
216                RoughEase {
217                    strength: sb,
218                    points: pb,
219                },
220            ) => sa == sb && pa == pb,
221            (
222                SlowMo {
223                    linear_ratio: la,
224                    power: pa,
225                },
226                SlowMo {
227                    linear_ratio: lb,
228                    power: pb,
229                },
230            ) => la == lb && pa == pb,
231            (Wiggle { wiggles: a }, Wiggle { wiggles: b }) => a == b,
232            (CustomBounce { strength: a }, CustomBounce { strength: b }) => a == b,
233            (ExpoScale { start: sa, end: ea }, ExpoScale { start: sb, end: eb }) => {
234                sa == sb && ea == eb
235            }
236            _ => core::mem::discriminant(self) == core::mem::discriminant(other),
237        }
238    }
239}
240
241impl Easing {
242    /// Evaluate the easing function at `t`.
243    ///
244    /// `t` is clamped to `[0.0, 1.0]` before evaluation — out-of-range
245    /// values never panic and never extrapolate for named variants.
246    ///
247    /// # Example
248    ///
249    /// ```rust
250    /// use animato_core::Easing;
251    /// assert_eq!(Easing::Linear.apply(0.5), 0.5);
252    /// assert_eq!(Easing::EaseInQuad.apply(0.0), 0.0);
253    /// assert_eq!(Easing::EaseInQuad.apply(1.0), 1.0);
254    /// // Out-of-range — no panic:
255    /// let _ = Easing::EaseOutCubic.apply(-1.0);
256    /// let _ = Easing::EaseOutCubic.apply(2.0);
257    /// ```
258    #[inline]
259    pub fn apply(&self, t: f32) -> f32 {
260        match self {
261            Easing::Custom(f) => f(t),
262            _ => {
263                let t = t.clamp(0.0, 1.0);
264                match self {
265                    Easing::Linear => t,
266                    Easing::EaseInQuad => ease_in_quad(t),
267                    Easing::EaseOutQuad => ease_out_quad(t),
268                    Easing::EaseInOutQuad => ease_in_out_quad(t),
269                    Easing::EaseInCubic => ease_in_cubic(t),
270                    Easing::EaseOutCubic => ease_out_cubic(t),
271                    Easing::EaseInOutCubic => ease_in_out_cubic(t),
272                    Easing::EaseInQuart => ease_in_quart(t),
273                    Easing::EaseOutQuart => ease_out_quart(t),
274                    Easing::EaseInOutQuart => ease_in_out_quart(t),
275                    Easing::EaseInQuint => ease_in_quint(t),
276                    Easing::EaseOutQuint => ease_out_quint(t),
277                    Easing::EaseInOutQuint => ease_in_out_quint(t),
278                    Easing::EaseInSine => ease_in_sine(t),
279                    Easing::EaseOutSine => ease_out_sine(t),
280                    Easing::EaseInOutSine => ease_in_out_sine(t),
281                    Easing::EaseInExpo => ease_in_expo(t),
282                    Easing::EaseOutExpo => ease_out_expo(t),
283                    Easing::EaseInOutExpo => ease_in_out_expo(t),
284                    Easing::EaseInCirc => ease_in_circ(t),
285                    Easing::EaseOutCirc => ease_out_circ(t),
286                    Easing::EaseInOutCirc => ease_in_out_circ(t),
287                    Easing::EaseInBack => ease_in_back(t),
288                    Easing::EaseOutBack => ease_out_back(t),
289                    Easing::EaseInOutBack => ease_in_out_back(t),
290                    Easing::EaseInElastic => ease_in_elastic(t),
291                    Easing::EaseOutElastic => ease_out_elastic(t),
292                    Easing::EaseInOutElastic => ease_in_out_elastic(t),
293                    Easing::EaseInBounce => ease_in_bounce(t),
294                    Easing::EaseOutBounce => ease_out_bounce(t),
295                    Easing::EaseInOutBounce => ease_in_out_bounce(t),
296                    Easing::CubicBezier(x1, y1, x2, y2) => cubic_bezier(t, *x1, *y1, *x2, *y2),
297                    Easing::Steps(count) => steps(t, *count),
298                    Easing::RoughEase { strength, points } => rough_ease(t, *strength, *points),
299                    Easing::SlowMo {
300                        linear_ratio,
301                        power,
302                    } => slow_mo(t, *linear_ratio, *power),
303                    Easing::Wiggle { wiggles } => wiggle(t, *wiggles),
304                    Easing::CustomBounce { strength } => custom_bounce(t, *strength),
305                    Easing::ExpoScale { start, end } => expo_scale(t, *start, *end),
306                    Easing::Custom(_) => unreachable!(),
307                }
308            }
309        }
310    }
311
312    /// Returns a slice of all named (non-`Custom`) variants.
313    ///
314    /// Useful for picker UIs and exhaustive test sweeps.
315    ///
316    /// ```rust
317    /// use animato_core::Easing;
318    /// for e in Easing::all_named() {
319    ///     assert_eq!(e.apply(0.0), 0.0);
320    ///     assert_eq!(e.apply(1.0), 1.0);
321    /// }
322    /// ```
323    pub fn all_named() -> &'static [Easing] {
324        &[
325            Easing::Linear,
326            Easing::EaseInQuad,
327            Easing::EaseOutQuad,
328            Easing::EaseInOutQuad,
329            Easing::EaseInCubic,
330            Easing::EaseOutCubic,
331            Easing::EaseInOutCubic,
332            Easing::EaseInQuart,
333            Easing::EaseOutQuart,
334            Easing::EaseInOutQuart,
335            Easing::EaseInQuint,
336            Easing::EaseOutQuint,
337            Easing::EaseInOutQuint,
338            Easing::EaseInSine,
339            Easing::EaseOutSine,
340            Easing::EaseInOutSine,
341            Easing::EaseInExpo,
342            Easing::EaseOutExpo,
343            Easing::EaseInOutExpo,
344            Easing::EaseInCirc,
345            Easing::EaseOutCirc,
346            Easing::EaseInOutCirc,
347            Easing::EaseInBack,
348            Easing::EaseOutBack,
349            Easing::EaseInOutBack,
350            Easing::EaseInElastic,
351            Easing::EaseOutElastic,
352            Easing::EaseInOutElastic,
353            Easing::EaseInBounce,
354            Easing::EaseOutBounce,
355            Easing::EaseInOutBounce,
356            Easing::CubicBezier(0.25, 0.1, 0.25, 1.0),
357            Easing::Steps(1),
358            // ── v0.8.0 advanced ─────────────────────────────────────
359            Easing::RoughEase {
360                strength: 0.5,
361                points: 8,
362            },
363            Easing::SlowMo {
364                linear_ratio: 0.5,
365                power: 0.7,
366            },
367            Easing::Wiggle { wiggles: 5 },
368            Easing::CustomBounce { strength: 0.7 },
369            Easing::ExpoScale {
370                start: 0.5,
371                end: 2.0,
372            },
373        ]
374    }
375}
376
377// ──────────────────────────────────────────────────────────────────────────────
378// Free easing functions — #[inline] for zero-overhead direct calls
379// ──────────────────────────────────────────────────────────────────────────────
380
381/// Quadratic ease-in: `t²`
382#[inline]
383pub fn ease_in_quad(t: f32) -> f32 {
384    t * t
385}
386
387/// Quadratic ease-out: `1 - (1-t)²`
388#[inline]
389pub fn ease_out_quad(t: f32) -> f32 {
390    1.0 - (1.0 - t) * (1.0 - t)
391}
392
393/// Quadratic ease-in-out.
394#[inline]
395pub fn ease_in_out_quad(t: f32) -> f32 {
396    if t < 0.5 {
397        2.0 * t * t
398    } else {
399        1.0 - powi(-2.0 * t + 2.0, 2) / 2.0
400    }
401}
402
403/// Cubic ease-in: `t³`
404#[inline]
405pub fn ease_in_cubic(t: f32) -> f32 {
406    t * t * t
407}
408
409/// Cubic ease-out: `1 - (1-t)³`
410#[inline]
411pub fn ease_out_cubic(t: f32) -> f32 {
412    1.0 - powi(1.0 - t, 3)
413}
414
415/// Cubic ease-in-out.
416#[inline]
417pub fn ease_in_out_cubic(t: f32) -> f32 {
418    if t < 0.5 {
419        4.0 * t * t * t
420    } else {
421        1.0 - powi(-2.0 * t + 2.0, 3) / 2.0
422    }
423}
424
425/// Quartic ease-in: `t⁴`
426#[inline]
427pub fn ease_in_quart(t: f32) -> f32 {
428    t * t * t * t
429}
430
431/// Quartic ease-out: `1 - (1-t)⁴`
432#[inline]
433pub fn ease_out_quart(t: f32) -> f32 {
434    1.0 - powi(1.0 - t, 4)
435}
436
437/// Quartic ease-in-out.
438#[inline]
439pub fn ease_in_out_quart(t: f32) -> f32 {
440    if t < 0.5 {
441        8.0 * t * t * t * t
442    } else {
443        1.0 - powi(-2.0 * t + 2.0, 4) / 2.0
444    }
445}
446
447/// Quintic ease-in: `t⁵`
448#[inline]
449pub fn ease_in_quint(t: f32) -> f32 {
450    t * t * t * t * t
451}
452
453/// Quintic ease-out: `1 - (1-t)⁵`
454#[inline]
455pub fn ease_out_quint(t: f32) -> f32 {
456    1.0 - powi(1.0 - t, 5)
457}
458
459/// Quintic ease-in-out.
460#[inline]
461pub fn ease_in_out_quint(t: f32) -> f32 {
462    if t < 0.5 {
463        16.0 * t * t * t * t * t
464    } else {
465        1.0 - powi(-2.0 * t + 2.0, 5) / 2.0
466    }
467}
468
469/// Sinusoidal ease-in.
470#[inline]
471pub fn ease_in_sine(t: f32) -> f32 {
472    1.0 - cos(t * PI / 2.0)
473}
474/// Sinusoidal ease-out.
475#[inline]
476pub fn ease_out_sine(t: f32) -> f32 {
477    sin(t * PI / 2.0)
478}
479/// Sinusoidal ease-in-out.
480#[inline]
481pub fn ease_in_out_sine(t: f32) -> f32 {
482    -(cos(t * PI) - 1.0) / 2.0
483}
484
485/// Exponential ease-in.
486#[inline]
487pub fn ease_in_expo(t: f32) -> f32 {
488    if t == 0.0 {
489        0.0
490    } else {
491        powf(2.0, 10.0 * t - 10.0)
492    }
493}
494
495/// Exponential ease-out.
496#[inline]
497pub fn ease_out_expo(t: f32) -> f32 {
498    if t == 1.0 {
499        1.0
500    } else {
501        1.0 - powf(2.0, -10.0 * t)
502    }
503}
504
505/// Exponential ease-in-out.
506#[inline]
507pub fn ease_in_out_expo(t: f32) -> f32 {
508    if t == 0.0 {
509        return 0.0;
510    }
511    if t == 1.0 {
512        return 1.0;
513    }
514    if t < 0.5 {
515        powf(2.0, 20.0 * t - 10.0) / 2.0
516    } else {
517        (2.0 - powf(2.0, -20.0 * t + 10.0)) / 2.0
518    }
519}
520
521/// Circular ease-in.
522#[inline]
523pub fn ease_in_circ(t: f32) -> f32 {
524    1.0 - sqrt(1.0 - t * t)
525}
526/// Circular ease-out.
527#[inline]
528pub fn ease_out_circ(t: f32) -> f32 {
529    sqrt(1.0 - (t - 1.0) * (t - 1.0))
530}
531
532/// Circular ease-in-out.
533#[inline]
534pub fn ease_in_out_circ(t: f32) -> f32 {
535    if t < 0.5 {
536        (1.0 - sqrt(1.0 - powi(2.0 * t, 2))) / 2.0
537    } else {
538        (sqrt(1.0 - powi(-2.0 * t + 2.0, 2)) + 1.0) / 2.0
539    }
540}
541
542const BACK_C1: f32 = 1.701_58;
543const BACK_C2: f32 = BACK_C1 * 1.525;
544const BACK_C3: f32 = BACK_C1 + 1.0;
545
546/// Back ease-in — overshoots slightly then pulls back.
547#[inline]
548pub fn ease_in_back(t: f32) -> f32 {
549    BACK_C3 * t * t * t - BACK_C1 * t * t
550}
551
552/// Back ease-out — overshoots the target then settles.
553#[inline]
554pub fn ease_out_back(t: f32) -> f32 {
555    let t = t - 1.0;
556    1.0 + BACK_C3 * t * t * t + BACK_C1 * t * t
557}
558
559/// Back ease-in-out.
560#[inline]
561pub fn ease_in_out_back(t: f32) -> f32 {
562    if t < 0.5 {
563        (powi(2.0 * t, 2) * ((BACK_C2 + 1.0) * 2.0 * t - BACK_C2)) / 2.0
564    } else {
565        (powi(2.0 * t - 2.0, 2) * ((BACK_C2 + 1.0) * (2.0 * t - 2.0) + BACK_C2) + 2.0) / 2.0
566    }
567}
568
569const ELASTIC_C4: f32 = (2.0 * PI) / 3.0;
570const ELASTIC_C5: f32 = (2.0 * PI) / 4.5;
571
572/// Elastic ease-in — spring-like oscillation at the beginning.
573#[inline]
574pub fn ease_in_elastic(t: f32) -> f32 {
575    if t == 0.0 {
576        return 0.0;
577    }
578    if t == 1.0 {
579        return 1.0;
580    }
581    -powf(2.0, 10.0 * t - 10.0) * sin((10.0 * t - 10.75) * ELASTIC_C4)
582}
583
584/// Elastic ease-out — spring-like oscillation at the end.
585#[inline]
586pub fn ease_out_elastic(t: f32) -> f32 {
587    if t == 0.0 {
588        return 0.0;
589    }
590    if t == 1.0 {
591        return 1.0;
592    }
593    powf(2.0, -10.0 * t) * sin((10.0 * t - 0.75) * ELASTIC_C4) + 1.0
594}
595
596/// Elastic ease-in-out.
597#[inline]
598pub fn ease_in_out_elastic(t: f32) -> f32 {
599    if t == 0.0 {
600        return 0.0;
601    }
602    if t == 1.0 {
603        return 1.0;
604    }
605    if t < 0.5 {
606        -(powf(2.0, 20.0 * t - 10.0) * sin((20.0 * t - 11.125) * ELASTIC_C5)) / 2.0
607    } else {
608        (powf(2.0, -20.0 * t + 10.0) * sin((20.0 * t - 11.125) * ELASTIC_C5)) / 2.0 + 1.0
609    }
610}
611
612/// Bounce ease-out — ball bouncing to rest.
613#[inline]
614pub fn ease_out_bounce(t: f32) -> f32 {
615    const N1: f32 = 7.5625;
616    const D1: f32 = 2.75;
617    let t = &mut { t };
618    if *t < 1.0 / D1 {
619        N1 * *t * *t
620    } else if *t < 2.0 / D1 {
621        *t -= 1.5 / D1;
622        N1 * *t * *t + 0.75
623    } else if *t < 2.5 / D1 {
624        *t -= 2.25 / D1;
625        N1 * *t * *t + 0.9375
626    } else {
627        *t -= 2.625 / D1;
628        N1 * *t * *t + 0.984_375
629    }
630}
631
632/// Bounce ease-in.
633#[inline]
634pub fn ease_in_bounce(t: f32) -> f32 {
635    1.0 - ease_out_bounce(1.0 - t)
636}
637
638/// Bounce ease-in-out.
639#[inline]
640pub fn ease_in_out_bounce(t: f32) -> f32 {
641    if t < 0.5 {
642        (1.0 - ease_out_bounce(1.0 - 2.0 * t)) / 2.0
643    } else {
644        (1.0 + ease_out_bounce(2.0 * t - 1.0)) / 2.0
645    }
646}
647
648/// CSS-compatible cubic Bezier easing.
649///
650/// `x1` and `x2` are clamped to `[0.0, 1.0]` because CSS timing functions
651/// require monotonic x control points. `y1` and `y2` are left unconstrained so
652/// curves can overshoot.
653#[inline]
654pub fn cubic_bezier(t: f32, x1: f32, y1: f32, x2: f32, y2: f32) -> f32 {
655    let t = t.clamp(0.0, 1.0);
656    if t == 0.0 || t == 1.0 {
657        return t;
658    }
659
660    let x1 = x1.clamp(0.0, 1.0);
661    let x2 = x2.clamp(0.0, 1.0);
662    let mut u = t;
663
664    for _ in 0..6 {
665        let x = sample_cubic(x1, x2, u) - t;
666        if x.abs() < 1e-6 {
667            return sample_cubic(y1, y2, u);
668        }
669        let derivative = sample_cubic_derivative(x1, x2, u);
670        if derivative.abs() < 1e-6 {
671            break;
672        }
673        u = (u - x / derivative).clamp(0.0, 1.0);
674    }
675
676    let mut low = 0.0;
677    let mut high = 1.0;
678    u = t;
679    for _ in 0..10 {
680        let x = sample_cubic(x1, x2, u);
681        if (x - t).abs() < 1e-6 {
682            break;
683        }
684        if x < t {
685            low = u;
686        } else {
687            high = u;
688        }
689        u = (low + high) * 0.5;
690    }
691
692    sample_cubic(y1, y2, u)
693}
694
695/// CSS `steps(n, jump-end)` easing.
696///
697/// `count = 0` is treated as one step.
698#[inline]
699pub fn steps(t: f32, count: u32) -> f32 {
700    let t = t.clamp(0.0, 1.0);
701    if t == 0.0 {
702        return 0.0;
703    }
704    let count = count.max(1) as f32;
705    (ceil(t * count) / count).clamp(0.0, 1.0)
706}
707
708// ── v0.8.0 Advanced free functions ───────────────────────────────────────────
709
710/// Rough, organic easing using deterministic sine harmonics.
711///
712/// Adds noise that is zero at `t = 0.0` and `t = 1.0`, preserving endpoints.
713/// `strength` controls amplitude (`0.0` = linear), `points` controls harmonics.
714///
715/// ```rust
716/// use animato_core::easing::rough_ease;
717/// assert_eq!(rough_ease(0.0, 0.5, 8), 0.0);
718/// assert_eq!(rough_ease(1.0, 0.5, 8), 1.0);
719/// ```
720#[inline]
721pub fn rough_ease(t: f32, strength: f32, points: u32) -> f32 {
722    if t <= 0.0 {
723        return 0.0;
724    }
725    if t >= 1.0 {
726        return 1.0;
727    }
728
729    let n = points.clamp(2, 20);
730    // Boundary envelope: zero at t=0 and t=1, peaks at midpoint.
731    let boundary = 4.0 * t * (1.0 - t);
732    let mut noise = 0.0_f32;
733    for i in 1..=n {
734        let freq = i as f32 * PI;
735        noise += sin(freq * t) / i as f32;
736    }
737    // Normalise amplitude so it's roughly in [-1, 1] regardless of `n`.
738    let norm = log(n as f32).max(1.0);
739    noise /= norm;
740
741    t + boundary * strength.clamp(0.0, 2.0) * noise
742}
743
744/// Slow-motion easing: fast at edges, slow in the middle.
745///
746/// `linear_ratio` ∈ `[0.0, 1.0]` is the fraction of the animation spent slow.
747/// `power` ≥ `0.0` controls how sharply edges accelerate (`0.0` = linear edges).
748///
749/// ```rust
750/// use animato_core::easing::slow_mo;
751/// assert_eq!(slow_mo(0.0, 0.5, 0.7), 0.0);
752/// assert_eq!(slow_mo(1.0, 0.5, 0.7), 1.0);
753/// ```
754#[inline]
755pub fn slow_mo(t: f32, linear_ratio: f32, power: f32) -> f32 {
756    if t <= 0.0 {
757        return 0.0;
758    }
759    if t >= 1.0 {
760        return 1.0;
761    }
762
763    let lr = linear_ratio.clamp(0.0, 1.0);
764    let p = power.max(0.0);
765
766    if lr >= 1.0 {
767        return t;
768    }
769
770    // Time-domain split: middle lr-fraction of time, edges (1-lr)/2 each.
771    let t_mid_start = (1.0 - lr) * 0.5;
772    let t_mid_end = t_mid_start + lr;
773
774    // Speed ratio: edges are (p+1)x faster than the middle.
775    // Derive slopes so that the output integrates to 1.0 over [0,1]:
776    //   s_mid * lr + s_edge * (1 - lr) = 1
777    //   s_edge = (p + 1) * s_mid
778    // => s_mid = 1 / (1 + p * (1 - lr))
779    let s_mid = 1.0 / (1.0 + p * (1.0 - lr));
780    let s_edge = if (1.0 - lr) > f32::EPSILON {
781        (1.0 - s_mid * lr) / (1.0 - lr)
782    } else {
783        1.0
784    };
785
786    if t < t_mid_start {
787        // Fast leading edge (linear ramp)
788        t * s_edge
789    } else if t > t_mid_end {
790        // Fast trailing edge (linear ramp)
791        let out_at_mid_end = t_mid_start * s_edge + lr * s_mid;
792        out_at_mid_end + (t - t_mid_end) * s_edge
793    } else {
794        // Slow middle (compressed linear progress)
795        t_mid_start * s_edge + (t - t_mid_start) * s_mid
796    }
797}
798
799/// Wiggle easing: oscillates around the linear trend with a sine envelope.
800///
801/// `wiggles` is the number of full oscillation cycles. The trend still goes
802/// from `0.0` to `1.0` with overshoot in between.
803///
804/// ```rust
805/// use animato_core::easing::wiggle;
806/// assert_eq!(wiggle(0.0, 5), 0.0);
807/// assert_eq!(wiggle(1.0, 5), 1.0);
808/// ```
809#[inline]
810pub fn wiggle(t: f32, wiggles: u32) -> f32 {
811    if t <= 0.0 {
812        return 0.0;
813    }
814    if t >= 1.0 {
815        return 1.0;
816    }
817    let n = wiggles.max(1) as f32;
818    // Envelope peaks at the midpoint and is zero at both ends.
819    let envelope = sin(t * PI);
820    let oscillation = sin(t * n * PI * 2.0) * envelope;
821    t + oscillation * 0.25
822}
823
824/// Configurable bounce easing.
825///
826/// `strength` ∈ `[0.0, 1.0]`: blends between linear (`0.0`) and `EaseOutBounce` (`1.0`).
827///
828/// ```rust
829/// use animato_core::easing::custom_bounce;
830/// assert_eq!(custom_bounce(0.0, 0.7), 0.0);
831/// assert_eq!(custom_bounce(1.0, 0.7), 1.0);
832/// ```
833#[inline]
834pub fn custom_bounce(t: f32, strength: f32) -> f32 {
835    if t <= 0.0 {
836        return 0.0;
837    }
838    if t >= 1.0 {
839        return 1.0;
840    }
841    let s = strength.clamp(0.0, 1.0);
842    // Blend between linear and ease_out_bounce.
843    t * (1.0 - s) + ease_out_bounce(t) * s
844}
845
846/// Exponential scale easing: warps time according to an exponential curve.
847///
848/// At `start = 1.0, end = 1.0` the result is linear.
849/// `start` and `end` must be positive; values below `0.001` are clamped.
850///
851/// ```rust
852/// use animato_core::easing::expo_scale;
853/// assert_eq!(expo_scale(0.0, 0.5, 2.0), 0.0);
854/// assert_eq!(expo_scale(1.0, 0.5, 2.0), 1.0);
855/// ```
856#[inline]
857pub fn expo_scale(t: f32, start: f32, end: f32) -> f32 {
858    if t <= 0.0 {
859        return 0.0;
860    }
861    if t >= 1.0 {
862        return 1.0;
863    }
864
865    let s = start.max(0.001_f32);
866    let e = end.max(0.001_f32);
867
868    // If start ≈ end the curve degenerates to linear.
869    if (s - e).abs() < 0.001 {
870        return t;
871    }
872
873    let k = e / s;
874    if (k - 1.0).abs() < 0.001 {
875        return t;
876    }
877
878    // Exponential interpolation: f(t) = (k^t - 1) / (k - 1).
879    // f(0) = 0 ✓   f(1) = (k-1)/(k-1) = 1 ✓
880    (powf(k, t) - 1.0) / (k - 1.0)
881}
882
883#[inline]
884fn sample_cubic(a1: f32, a2: f32, t: f32) -> f32 {
885    let c = 3.0 * a1;
886    let b = 3.0 * (a2 - a1) - c;
887    let a = 1.0 - c - b;
888    ((a * t + b) * t + c) * t
889}
890
891#[inline]
892fn sample_cubic_derivative(a1: f32, a2: f32, t: f32) -> f32 {
893    let c = 3.0 * a1;
894    let b = 3.0 * (a2 - a1) - c;
895    let a = 1.0 - c - b;
896    (3.0 * a * t + 2.0 * b) * t + c
897}
898
899// ──────────────────────────────────────────────────────────────────────────────
900// Tests
901// ──────────────────────────────────────────────────────────────────────────────
902
903#[cfg(test)]
904mod tests {
905    use super::*;
906
907    const EPSILON: f32 = 1e-5;
908
909    fn approx_eq(a: f32, b: f32) -> bool {
910        (a - b).abs() < EPSILON
911    }
912
913    /// Every named variant must satisfy apply(0) == 0 and apply(1) == 1.
914    #[test]
915    fn all_named_endpoints() {
916        for easing in Easing::all_named() {
917            let v0 = easing.apply(0.0);
918            let v1 = easing.apply(1.0);
919            assert!(
920                approx_eq(v0, 0.0),
921                "{:?}.apply(0.0) = {} (expected 0.0)",
922                easing,
923                v0
924            );
925            assert!(
926                approx_eq(v1, 1.0),
927                "{:?}.apply(1.0) = {} (expected 1.0)",
928                easing,
929                v1
930            );
931        }
932    }
933
934    /// Out-of-range t must not panic for any named variant.
935    #[test]
936    fn no_panic_out_of_range() {
937        for easing in Easing::all_named() {
938            let _ = easing.apply(-0.5);
939            let _ = easing.apply(1.5);
940            let _ = easing.apply(f32::INFINITY);
941            let _ = easing.apply(f32::NEG_INFINITY);
942            // Note: NaN input is outside the defined contract (t ∈ [0,1]).
943            // f32::NAN.clamp(0.0, 1.0) == NaN per IEEE 754 — acceptable.
944        }
945    }
946
947    #[test]
948    fn all_named_count() {
949        assert_eq!(Easing::all_named().len(), 38);
950    }
951
952    #[test]
953    fn custom_variant_applies_fn() {
954        let e = Easing::Custom(|t| t * t);
955        assert_eq!(e.apply(0.5), 0.25);
956    }
957
958    #[test]
959    fn custom_never_equals() {
960        let a = Easing::Custom(|t| t);
961        let b = Easing::Custom(|t| t);
962        assert!(a != b);
963        assert!(a != Easing::Linear);
964    }
965
966    #[test]
967    fn named_equality() {
968        assert_eq!(Easing::Linear, Easing::Linear);
969        assert_eq!(Easing::EaseOutCubic, Easing::EaseOutCubic);
970        assert_eq!(
971            Easing::CubicBezier(0.25, 0.1, 0.25, 1.0),
972            Easing::CubicBezier(0.25, 0.1, 0.25, 1.0)
973        );
974        assert_eq!(Easing::Steps(4), Easing::Steps(4));
975        assert_ne!(Easing::EaseInQuad, Easing::EaseOutQuad);
976        // Advanced variants
977        assert_eq!(
978            Easing::RoughEase {
979                strength: 0.5,
980                points: 8
981            },
982            Easing::RoughEase {
983                strength: 0.5,
984                points: 8
985            }
986        );
987        assert_ne!(
988            Easing::RoughEase {
989                strength: 0.5,
990                points: 8
991            },
992            Easing::RoughEase {
993                strength: 0.5,
994                points: 4
995            }
996        );
997        assert_eq!(Easing::Wiggle { wiggles: 5 }, Easing::Wiggle { wiggles: 5 });
998        assert_eq!(
999            Easing::CustomBounce { strength: 0.7 },
1000            Easing::CustomBounce { strength: 0.7 }
1001        );
1002        assert_eq!(
1003            Easing::ExpoScale {
1004                start: 0.5,
1005                end: 2.0
1006            },
1007            Easing::ExpoScale {
1008                start: 0.5,
1009                end: 2.0
1010            }
1011        );
1012    }
1013
1014    #[test]
1015    fn rough_ease_monotonic_bias() {
1016        // Should be a "roughly" increasing function on average.
1017        let sum: f32 = (1..10).map(|i| rough_ease(i as f32 / 10.0, 0.3, 6)).sum();
1018        assert!(sum > 0.0, "rough ease should have positive trend");
1019    }
1020
1021    #[test]
1022    fn slow_mo_middle_is_slow() {
1023        // The derivative at the midpoint should be smaller than at the edges.
1024        let dt = 0.01_f32;
1025        let mid_vel = (slow_mo(0.5 + dt, 0.5, 1.0) - slow_mo(0.5, 0.5, 1.0)) / dt;
1026        let edge_vel = (slow_mo(0.05 + dt, 0.5, 1.0) - slow_mo(0.05, 0.5, 1.0)) / dt;
1027        assert!(
1028            mid_vel < edge_vel,
1029            "middle should be slower than edges: mid={mid_vel}, edge={edge_vel}"
1030        );
1031    }
1032
1033    #[test]
1034    fn slow_mo_zero_linear_ratio() {
1035        // With lr=0.0 there is no linear portion, should still satisfy endpoints.
1036        assert!(approx_eq(slow_mo(0.0, 0.0, 1.0), 0.0));
1037        assert!(approx_eq(slow_mo(1.0, 0.0, 1.0), 1.0));
1038    }
1039
1040    #[test]
1041    fn wiggle_stays_finite() {
1042        for i in 0..=100 {
1043            let t = i as f32 / 100.0;
1044            let v = wiggle(t, 5);
1045            assert!(v.is_finite(), "wiggle at t={t} produced non-finite {v}");
1046        }
1047    }
1048
1049    #[test]
1050    fn custom_bounce_blends_correctly() {
1051        // At strength=0.0 it should be linear.
1052        for i in 1..10 {
1053            let t = i as f32 / 10.0;
1054            assert!(
1055                approx_eq(custom_bounce(t, 0.0), t),
1056                "strength=0 should be linear at t={t}"
1057            );
1058        }
1059        // At strength=1.0 it should equal ease_out_bounce.
1060        for i in 1..10 {
1061            let t = i as f32 / 10.0;
1062            assert!(
1063                approx_eq(custom_bounce(t, 1.0), ease_out_bounce(t)),
1064                "strength=1 should equal ease_out_bounce at t={t}"
1065            );
1066        }
1067    }
1068
1069    #[test]
1070    fn expo_scale_is_monotonic() {
1071        let mut prev = 0.0_f32;
1072        for i in 1..=20 {
1073            let t = i as f32 / 20.0;
1074            let v = expo_scale(t, 0.5, 2.0);
1075            assert!(v >= prev - 1e-5, "expo_scale should be monotonic at t={t}");
1076            prev = v;
1077        }
1078    }
1079
1080    #[test]
1081    fn expo_scale_equal_start_end_is_linear() {
1082        for i in 1..10 {
1083            let t = i as f32 / 10.0;
1084            assert!(approx_eq(expo_scale(t, 1.0, 1.0), t));
1085        }
1086    }
1087
1088    #[test]
1089    fn free_functions_match_enum() {
1090        type EasingCase = (Easing, fn(f32) -> f32);
1091
1092        let cases: &[EasingCase] = &[
1093            (Easing::EaseInQuad, ease_in_quad),
1094            (Easing::EaseOutCubic, ease_out_cubic),
1095            (Easing::EaseOutBounce, ease_out_bounce),
1096        ];
1097        for t in [0.1, 0.5, 0.9] {
1098            for (easing, f) in cases {
1099                let a = easing.apply(t);
1100                let b = f(t);
1101                assert!(
1102                    approx_eq(a, b),
1103                    "{:?} at t={}: enum={} free_fn={}",
1104                    easing,
1105                    t,
1106                    a,
1107                    b
1108                );
1109            }
1110        }
1111    }
1112
1113    #[test]
1114    fn cubic_bezier_linear_is_identity() {
1115        let easing = Easing::CubicBezier(0.0, 0.0, 1.0, 1.0);
1116        for t in [0.0, 0.25, 0.5, 0.75, 1.0] {
1117            assert!(
1118                approx_eq(easing.apply(t), t),
1119                "linear cubic-bezier at t={t} was {}",
1120                easing.apply(t)
1121            );
1122        }
1123    }
1124
1125    #[test]
1126    fn steps_jump_end_behavior() {
1127        let easing = Easing::Steps(4);
1128        assert_eq!(easing.apply(0.0), 0.0);
1129        assert_eq!(easing.apply(0.01), 0.25);
1130        assert_eq!(easing.apply(1.0), 1.0);
1131    }
1132
1133    #[test]
1134    fn linear_is_identity() {
1135        for t in [0.0, 0.25, 0.5, 0.75, 1.0] {
1136            assert_eq!(Easing::Linear.apply(t), t);
1137        }
1138    }
1139}