Skip to main content

proof_engine/tween/
easing.rs

1//! 40+ easing functions following Robert Penner's equations.
2//!
3//! Each variant describes an acceleration curve over t ∈ [0, 1] → [0, 1].
4//! Use `Easing::apply(t)` to convert normalized time to a curved value.
5
6use std::f32::consts::{PI, TAU};
7
8/// An easing function — transforms normalized time t ∈ [0, 1] → output ∈ [0, 1].
9///
10/// For typical usage, In = slow start, Out = slow end, InOut = slow at both ends.
11#[derive(Clone, Copy, Debug, PartialEq)]
12pub enum Easing {
13    // ── Polynomial ───────────────────────────────────────────────────────────
14    Linear,
15    EaseInQuad,
16    EaseOutQuad,
17    EaseInOutQuad,
18    EaseInCubic,
19    EaseOutCubic,
20    EaseInOutCubic,
21    EaseInQuart,
22    EaseOutQuart,
23    EaseInOutQuart,
24    EaseInQuint,
25    EaseOutQuint,
26    EaseInOutQuint,
27
28    // ── Trigonometric ─────────────────────────────────────────────────────────
29    EaseInSine,
30    EaseOutSine,
31    EaseInOutSine,
32
33    // ── Exponential ───────────────────────────────────────────────────────────
34    EaseInExpo,
35    EaseOutExpo,
36    EaseInOutExpo,
37
38    // ── Circular ─────────────────────────────────────────────────────────────
39    EaseInCirc,
40    EaseOutCirc,
41    EaseInOutCirc,
42
43    // ── Back (overshoot) ──────────────────────────────────────────────────────
44    EaseInBack,
45    EaseOutBack,
46    EaseInOutBack,
47
48    // ── Elastic ───────────────────────────────────────────────────────────────
49    EaseInElastic,
50    EaseOutElastic,
51    EaseInOutElastic,
52
53    // ── Bounce ────────────────────────────────────────────────────────────────
54    EaseInBounce,
55    EaseOutBounce,
56    EaseInOutBounce,
57
58    // ── Special ───────────────────────────────────────────────────────────────
59    /// Smooth step: 3t² - 2t³ (zero derivative at endpoints).
60    SmoothStep,
61    /// Smoother step: 6t⁵ - 15t⁴ + 10t³ (Perlin's improved smoothstep).
62    SmootherStep,
63    /// Step at threshold 0.5 (instantaneous jump).
64    Step,
65    /// Instant-in, linear-out.
66    EaseOutLinear,
67    /// Hermite cubic through two tangents.
68    Hermite { p0: f32, m0: f32, p1: f32, m1: f32 },
69    /// Custom power: t^n.
70    Power(f32),
71    /// Sigmoid (logistic) curve.
72    Sigmoid { k: f32 },
73    /// Spring easing (underdamped oscillation to rest).
74    Spring { stiffness: f32, damping: f32 },
75    /// Parabolic arc (projectile).
76    Parabola,
77    /// Flash: instant full, then linear decay.
78    Flash,
79}
80
81impl Easing {
82    /// Apply the easing function to normalized time `t` ∈ [0, 1].
83    pub fn apply(&self, t: f32) -> f32 {
84        let t = t.clamp(0.0, 1.0);
85        match *self {
86            // ── Linear ───────────────────────────────────────────────────────
87            Easing::Linear => t,
88
89            // ── Quadratic ────────────────────────────────────────────────────
90            Easing::EaseInQuad      => t * t,
91            Easing::EaseOutQuad     => 1.0 - (1.0 - t) * (1.0 - t),
92            Easing::EaseInOutQuad   => {
93                if t < 0.5 { 2.0 * t * t }
94                else { 1.0 - (-2.0 * t + 2.0).powi(2) / 2.0 }
95            }
96
97            // ── Cubic ────────────────────────────────────────────────────────
98            Easing::EaseInCubic     => t * t * t,
99            Easing::EaseOutCubic    => 1.0 - (1.0 - t).powi(3),
100            Easing::EaseInOutCubic  => {
101                if t < 0.5 { 4.0 * t * t * t }
102                else { 1.0 - (-2.0 * t + 2.0).powi(3) / 2.0 }
103            }
104
105            // ── Quartic ──────────────────────────────────────────────────────
106            Easing::EaseInQuart     => t * t * t * t,
107            Easing::EaseOutQuart    => 1.0 - (1.0 - t).powi(4),
108            Easing::EaseInOutQuart  => {
109                if t < 0.5 { 8.0 * t * t * t * t }
110                else { 1.0 - (-2.0 * t + 2.0).powi(4) / 2.0 }
111            }
112
113            // ── Quintic ──────────────────────────────────────────────────────
114            Easing::EaseInQuint     => t * t * t * t * t,
115            Easing::EaseOutQuint    => 1.0 - (1.0 - t).powi(5),
116            Easing::EaseInOutQuint  => {
117                if t < 0.5 { 16.0 * t * t * t * t * t }
118                else { 1.0 - (-2.0 * t + 2.0).powi(5) / 2.0 }
119            }
120
121            // ── Sine ─────────────────────────────────────────────────────────
122            Easing::EaseInSine      => 1.0 - (t * PI / 2.0).cos(),
123            Easing::EaseOutSine     => (t * PI / 2.0).sin(),
124            Easing::EaseInOutSine   => -((PI * t).cos() - 1.0) / 2.0,
125
126            // ── Exponential ──────────────────────────────────────────────────
127            Easing::EaseInExpo  => {
128                if t == 0.0 { 0.0 } else { 2.0_f32.powf(10.0 * t - 10.0) }
129            }
130            Easing::EaseOutExpo => {
131                if t == 1.0 { 1.0 } else { 1.0 - 2.0_f32.powf(-10.0 * t) }
132            }
133            Easing::EaseInOutExpo => {
134                if t == 0.0 { return 0.0; }
135                if t == 1.0 { return 1.0; }
136                if t < 0.5 { 2.0_f32.powf(20.0 * t - 10.0) / 2.0 }
137                else { (2.0 - 2.0_f32.powf(-20.0 * t + 10.0)) / 2.0 }
138            }
139
140            // ── Circular ─────────────────────────────────────────────────────
141            Easing::EaseInCirc  => 1.0 - (1.0 - t * t).sqrt(),
142            Easing::EaseOutCirc => (1.0 - (t - 1.0) * (t - 1.0)).sqrt(),
143            Easing::EaseInOutCirc => {
144                if t < 0.5 { (1.0 - (1.0 - (2.0 * t).powi(2)).sqrt()) / 2.0 }
145                else { ((1.0 - (-2.0 * t + 2.0).powi(2)).sqrt() + 1.0) / 2.0 }
146            }
147
148            // ── Back (overshoot) ─────────────────────────────────────────────
149            Easing::EaseInBack => {
150                let c1 = 1.70158_f32;
151                let c3 = c1 + 1.0;
152                c3 * t * t * t - c1 * t * t
153            }
154            Easing::EaseOutBack => {
155                let c1 = 1.70158_f32;
156                let c3 = c1 + 1.0;
157                1.0 + c3 * (t - 1.0).powi(3) + c1 * (t - 1.0).powi(2)
158            }
159            Easing::EaseInOutBack => {
160                let c1 = 1.70158_f32;
161                let c2 = c1 * 1.525;
162                if t < 0.5 {
163                    ((2.0 * t).powi(2) * ((c2 + 1.0) * 2.0 * t - c2)) / 2.0
164                } else {
165                    ((2.0 * t - 2.0).powi(2) * ((c2 + 1.0) * (2.0 * t - 2.0) + c2) + 2.0) / 2.0
166                }
167            }
168
169            // ── Elastic ──────────────────────────────────────────────────────
170            Easing::EaseInElastic => {
171                if t == 0.0 { return 0.0; }
172                if t == 1.0 { return 1.0; }
173                let c4 = TAU / 3.0;
174                -2.0_f32.powf(10.0 * t - 10.0) * ((t * 10.0 - 10.75) * c4).sin()
175            }
176            Easing::EaseOutElastic => {
177                if t == 0.0 { return 0.0; }
178                if t == 1.0 { return 1.0; }
179                let c4 = TAU / 3.0;
180                2.0_f32.powf(-10.0 * t) * ((t * 10.0 - 0.75) * c4).sin() + 1.0
181            }
182            Easing::EaseInOutElastic => {
183                if t == 0.0 { return 0.0; }
184                if t == 1.0 { return 1.0; }
185                let c5 = TAU / 4.5;
186                if t < 0.5 {
187                    -(2.0_f32.powf(20.0 * t - 10.0) * ((20.0 * t - 11.125) * c5).sin()) / 2.0
188                } else {
189                    (2.0_f32.powf(-20.0 * t + 10.0) * ((20.0 * t - 11.125) * c5).sin()) / 2.0 + 1.0
190                }
191            }
192
193            // ── Bounce ───────────────────────────────────────────────────────
194            Easing::EaseOutBounce => bounce_out(t),
195            Easing::EaseInBounce  => 1.0 - bounce_out(1.0 - t),
196            Easing::EaseInOutBounce => {
197                if t < 0.5 { (1.0 - bounce_out(1.0 - 2.0 * t)) / 2.0 }
198                else { (1.0 + bounce_out(2.0 * t - 1.0)) / 2.0 }
199            }
200
201            // ── Special ──────────────────────────────────────────────────────
202            Easing::SmoothStep    => t * t * (3.0 - 2.0 * t),
203            Easing::SmootherStep  => t * t * t * (t * (t * 6.0 - 15.0) + 10.0),
204            Easing::Step          => if t < 0.5 { 0.0 } else { 1.0 },
205            Easing::EaseOutLinear => t,
206            Easing::Parabola      => 4.0 * t * (1.0 - t),
207            Easing::Flash         => if t < f32::EPSILON { 1.0 } else { 1.0 - t },
208
209            Easing::Power(n)      => t.powf(n),
210
211            Easing::Sigmoid { k } => {
212                let s = |x: f32| 1.0 / (1.0 + (-k * x).exp());
213                (s(t - 0.5) - s(-0.5)) / (s(0.5) - s(-0.5))
214            }
215
216            Easing::Spring { stiffness, damping } => {
217                // Underdamped spring analytical solution. The raw response at
218                // t = 1 has not fully settled (1.0022 for stiffness 100,
219                // damping 10), so an animation using it ended slightly off
220                // target. Add the missing amount linearly over t so the
221                // curve still starts at 0 and now ends exactly at 1.
222                let omega = stiffness.sqrt();
223                let zeta  = damping / (2.0 * omega).max(f32::EPSILON);
224                let response = |t: f32| -> f32 {
225                    if zeta >= 1.0 {
226                        1.0 - (1.0 + omega * t) * (-omega * t).exp()
227                    } else {
228                        let omega_d = omega * (1.0 - zeta * zeta).sqrt();
229                        let decay   = (-zeta * omega * t).exp();
230                        1.0 - decay * ((omega_d * t).cos() +
231                              (zeta / (1.0 - zeta * zeta).sqrt()) * (omega_d * t).sin())
232                    }
233                };
234                response(t) + (1.0 - response(1.0)) * t
235            }
236
237            Easing::Hermite { p0, m0, p1, m1 } => {
238                let t2 = t * t;
239                let t3 = t2 * t;
240                let h00 =  2.0 * t3 - 3.0 * t2 + 1.0;
241                let h10 =        t3 - 2.0 * t2 + t;
242                let h01 = -2.0 * t3 + 3.0 * t2;
243                let h11 =        t3 -       t2;
244                h00 * p0 + h10 * m0 + h01 * p1 + h11 * m1
245            }
246        }
247    }
248
249    /// Return the approximate derivative dE/dt at `t` using central differences.
250    pub fn derivative(&self, t: f32) -> f32 {
251        let eps = 1e-4_f32;
252        let hi = self.apply((t + eps).min(1.0));
253        let lo = self.apply((t - eps).max(0.0));
254        (hi - lo) / (2.0 * eps)
255    }
256
257    /// Name for debug display.
258    pub fn name(&self) -> &'static str {
259        match self {
260            Easing::Linear          => "Linear",
261            Easing::EaseInQuad      => "EaseInQuad",
262            Easing::EaseOutQuad     => "EaseOutQuad",
263            Easing::EaseInOutQuad   => "EaseInOutQuad",
264            Easing::EaseInCubic     => "EaseInCubic",
265            Easing::EaseOutCubic    => "EaseOutCubic",
266            Easing::EaseInOutCubic  => "EaseInOutCubic",
267            Easing::EaseInQuart     => "EaseInQuart",
268            Easing::EaseOutQuart    => "EaseOutQuart",
269            Easing::EaseInOutQuart  => "EaseInOutQuart",
270            Easing::EaseInQuint     => "EaseInQuint",
271            Easing::EaseOutQuint    => "EaseOutQuint",
272            Easing::EaseInOutQuint  => "EaseInOutQuint",
273            Easing::EaseInSine      => "EaseInSine",
274            Easing::EaseOutSine     => "EaseOutSine",
275            Easing::EaseInOutSine   => "EaseInOutSine",
276            Easing::EaseInExpo      => "EaseInExpo",
277            Easing::EaseOutExpo     => "EaseOutExpo",
278            Easing::EaseInOutExpo   => "EaseInOutExpo",
279            Easing::EaseInCirc      => "EaseInCirc",
280            Easing::EaseOutCirc     => "EaseOutCirc",
281            Easing::EaseInOutCirc   => "EaseInOutCirc",
282            Easing::EaseInBack      => "EaseInBack",
283            Easing::EaseOutBack     => "EaseOutBack",
284            Easing::EaseInOutBack   => "EaseInOutBack",
285            Easing::EaseInElastic   => "EaseInElastic",
286            Easing::EaseOutElastic  => "EaseOutElastic",
287            Easing::EaseInOutElastic => "EaseInOutElastic",
288            Easing::EaseInBounce    => "EaseInBounce",
289            Easing::EaseOutBounce   => "EaseOutBounce",
290            Easing::EaseInOutBounce => "EaseInOutBounce",
291            Easing::SmoothStep      => "SmoothStep",
292            Easing::SmootherStep    => "SmootherStep",
293            Easing::Step            => "Step",
294            Easing::EaseOutLinear   => "EaseOutLinear",
295            Easing::Parabola        => "Parabola",
296            Easing::Flash           => "Flash",
297            Easing::Power(_)        => "Power",
298            Easing::Sigmoid { .. }  => "Sigmoid",
299            Easing::Spring { .. }   => "Spring",
300            Easing::Hermite { .. }  => "Hermite",
301        }
302    }
303
304    /// All named easings (excludes parameterized variants).
305    pub fn all_named() -> &'static [Easing] {
306        &[
307            Easing::Linear,
308            Easing::EaseInQuad, Easing::EaseOutQuad, Easing::EaseInOutQuad,
309            Easing::EaseInCubic, Easing::EaseOutCubic, Easing::EaseInOutCubic,
310            Easing::EaseInQuart, Easing::EaseOutQuart, Easing::EaseInOutQuart,
311            Easing::EaseInQuint, Easing::EaseOutQuint, Easing::EaseInOutQuint,
312            Easing::EaseInSine, Easing::EaseOutSine, Easing::EaseInOutSine,
313            Easing::EaseInExpo, Easing::EaseOutExpo, Easing::EaseInOutExpo,
314            Easing::EaseInCirc, Easing::EaseOutCirc, Easing::EaseInOutCirc,
315            Easing::EaseInBack, Easing::EaseOutBack, Easing::EaseInOutBack,
316            Easing::EaseInElastic, Easing::EaseOutElastic, Easing::EaseInOutElastic,
317            Easing::EaseInBounce, Easing::EaseOutBounce, Easing::EaseInOutBounce,
318            Easing::SmoothStep, Easing::SmootherStep, Easing::Step,
319            // Parabola and Flash are special-purpose easings that don't satisfy 0→1
320        ]
321    }
322}
323
324/// Shared bounce-out helper (used by In/InOut variants too).
325fn bounce_out(t: f32) -> f32 {
326    const N1: f32 = 7.5625;
327    const D1: f32 = 2.75;
328    if t < 1.0 / D1 {
329        N1 * t * t
330    } else if t < 2.0 / D1 {
331        let t2 = t - 1.5 / D1;
332        N1 * t2 * t2 + 0.75
333    } else if t < 2.5 / D1 {
334        let t2 = t - 2.25 / D1;
335        N1 * t2 * t2 + 0.9375
336    } else {
337        let t2 = t - 2.625 / D1;
338        N1 * t2 * t2 + 0.984375
339    }
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    #[test]
347    fn linear_endpoints() {
348        assert!((Easing::Linear.apply(0.0)).abs() < 1e-6);
349        assert!((Easing::Linear.apply(1.0) - 1.0).abs() < 1e-6);
350    }
351
352    #[test]
353    fn all_easings_start_at_zero_end_at_one() {
354        let parameterized = [
355            Easing::Power(2.5),
356            Easing::Sigmoid { k: 5.0 },
357            Easing::Spring { stiffness: 100.0, damping: 10.0 },
358            Easing::SmoothStep,
359            Easing::SmootherStep,
360        ];
361        for e in Easing::all_named().iter().chain(parameterized.iter()) {
362            let start = e.apply(0.0);
363            let end   = e.apply(1.0);
364            assert!((start).abs() < 1e-4, "{} start={}", e.name(), start);
365            assert!((end - 1.0).abs() < 1e-4, "{} end={}", e.name(), end);
366        }
367    }
368
369    #[test]
370    fn bounce_is_monotonic_at_end() {
371        let prev = Easing::EaseOutBounce.apply(0.99);
372        let curr = Easing::EaseOutBounce.apply(1.00);
373        assert!(curr >= prev - 1e-4);
374    }
375
376    #[test]
377    fn spring_overshoots() {
378        let e = Easing::Spring { stiffness: 200.0, damping: 5.0 };
379        let max = (0..200).map(|i| (e.apply(i as f32 / 100.0) * 1000.0) as i32).max().unwrap();
380        assert!(max > 1000, "spring should overshoot past 1.0 (max={})", max);
381    }
382
383    #[test]
384    fn hermite_through_endpoints() {
385        let e = Easing::Hermite { p0: 0.0, m0: 1.0, p1: 1.0, m1: 1.0 };
386        assert!((e.apply(0.0) - 0.0).abs() < 1e-5);
387        assert!((e.apply(1.0) - 1.0).abs() < 1e-5);
388    }
389
390    #[test]
391    fn parabola_peaks_at_half() {
392        let e = Easing::Parabola;
393        assert!((e.apply(0.5) - 1.0).abs() < 1e-5);
394        assert!(e.apply(0.0).abs() < 1e-5);
395        assert!(e.apply(1.0).abs() < 1e-5);
396    }
397}