Skip to main content

oxiui_core/
anim.rs

1//! Easing curves, spring physics, and a transition animator.
2//!
3//! The unit of time throughout is **seconds** (`f32`). Progress `t` and eased
4//! output are normalised to `[0, 1]` unless a spring overshoots.
5//!
6//! - [`Easing`] evaluates the standard timing functions. The CSS `cubic-bezier`
7//!   case is the hard one: the curve is parametric in an internal variable `u`,
8//!   but we are given the *x* (time) coordinate and must solve for `u` to read
9//!   off *y* (progress). We invert with **Newton–Raphson** and fall back to
10//!   **bisection** when the derivative is near zero (degenerate control points
11//!   such as `(0,1,1,0)` would otherwise diverge to `NaN`).
12//! - [`Spring`] is a damped harmonic oscillator solved in closed form for the
13//!   under-, over-, and critically-damped regimes (no Euler stepping, so it is
14//!   stable at any frame rate).
15//! - [`Transition`] bundles duration/delay/easing; [`Animator`] tracks active
16//!   transitions and samples them at a given elapsed time.
17
18/// A timing function mapping linear progress `t ∈ [0, 1]` to eased progress.
19#[derive(Clone, Copy, Debug, PartialEq)]
20pub enum Easing {
21    /// Identity: output equals input.
22    Linear,
23    /// Slow start (`cubic-bezier(0.42, 0, 1, 1)`).
24    EaseIn,
25    /// Slow end (`cubic-bezier(0, 0, 0.58, 1)`).
26    EaseOut,
27    /// Slow start and end (`cubic-bezier(0.42, 0, 0.58, 1)`).
28    EaseInOut,
29    /// An arbitrary cubic Bézier with control points `(x1, y1)` and `(x2, y2)`;
30    /// endpoints are fixed at `(0,0)` and `(1,1)` as in CSS.
31    CubicBezier {
32        /// First control-point x.
33        x1: f32,
34        /// First control-point y.
35        y1: f32,
36        /// Second control-point x.
37        x2: f32,
38        /// Second control-point y.
39        y2: f32,
40    },
41}
42
43impl Easing {
44    /// Evaluate the curve at linear progress `t` (clamped to `[0, 1]`).
45    pub fn eval(&self, t: f32) -> f32 {
46        let t = t.clamp(0.0, 1.0);
47        match *self {
48            Easing::Linear => t,
49            Easing::EaseIn => cubic_bezier_eval(0.42, 0.0, 1.0, 1.0, t),
50            Easing::EaseOut => cubic_bezier_eval(0.0, 0.0, 0.58, 1.0, t),
51            Easing::EaseInOut => cubic_bezier_eval(0.42, 0.0, 0.58, 1.0, t),
52            Easing::CubicBezier { x1, y1, x2, y2 } => cubic_bezier_eval(x1, y1, x2, y2, t),
53        }
54    }
55}
56
57/// One coordinate of a cubic Bézier with fixed endpoints 0 and 1, given the two
58/// inner control values `c1`, `c2` and curve parameter `u ∈ [0, 1]`.
59///
60/// `B(u) = 3(1-u)²u·c1 + 3(1-u)u²·c2 + u³` (the `(1-u)³·0` term vanishes).
61#[inline]
62fn bezier_axis(c1: f32, c2: f32, u: f32) -> f32 {
63    let one_minus = 1.0 - u;
64    3.0 * one_minus * one_minus * u * c1 + 3.0 * one_minus * u * u * c2 + u * u * u
65}
66
67/// Derivative of [`bezier_axis`] with respect to `u`.
68#[inline]
69fn bezier_axis_deriv(c1: f32, c2: f32, u: f32) -> f32 {
70    let one_minus = 1.0 - u;
71    3.0 * one_minus * one_minus * c1 + 6.0 * one_minus * u * (c2 - c1) + 3.0 * u * u * (1.0 - c2)
72}
73
74/// Evaluate a CSS-style cubic Bézier easing at time `x ∈ [0, 1]`.
75///
76/// Solves `bezier_x(u) = x` for the curve parameter `u`, then returns
77/// `bezier_y(u)`. Uses Newton–Raphson with a bisection fallback for robustness
78/// when the x-derivative is tiny.
79fn cubic_bezier_eval(x1: f32, y1: f32, x2: f32, y2: f32, x: f32) -> f32 {
80    // Endpoints are exact regardless of control points.
81    if x <= 0.0 {
82        return 0.0;
83    }
84    if x >= 1.0 {
85        return 1.0;
86    }
87    let u = solve_bezier_u_for_x(x1, x2, x);
88    bezier_axis(y1, y2, u)
89}
90
91/// Solve `bezier_axis(x1, x2, u) == x` for `u ∈ [0, 1]`.
92fn solve_bezier_u_for_x(x1: f32, x2: f32, x: f32) -> f32 {
93    const NEWTON_ITERS: usize = 8;
94    const EPS: f32 = 1e-6;
95
96    // Newton–Raphson seeded at u = x (a good guess since x ∈ [0,1]).
97    let mut u = x;
98    for _ in 0..NEWTON_ITERS {
99        let fx = bezier_axis(x1, x2, u) - x;
100        if fx.abs() < EPS {
101            return u.clamp(0.0, 1.0);
102        }
103        let d = bezier_axis_deriv(x1, x2, u);
104        if d.abs() < 1e-6 {
105            // Derivative ~ 0: Newton would explode. Hand off to bisection.
106            break;
107        }
108        u -= fx / d;
109        // Keep the iterate inside the valid domain.
110        u = u.clamp(0.0, 1.0);
111    }
112
113    // Bisection fallback — guaranteed to converge because bezier_x is monotone
114    // non-decreasing in u for valid CSS control points (x1, x2 ∈ [0, 1]).
115    let mut lo = 0.0_f32;
116    let mut hi = 1.0_f32;
117    let mut mid = u.clamp(lo, hi);
118    for _ in 0..32 {
119        mid = 0.5 * (lo + hi);
120        let fx = bezier_axis(x1, x2, mid);
121        if (fx - x).abs() < EPS {
122            return mid;
123        }
124        if fx < x {
125            lo = mid;
126        } else {
127            hi = mid;
128        }
129    }
130    mid
131}
132
133/// A damped-harmonic-oscillator spring, solved in closed form.
134///
135/// Parameters follow the common "physical" convention: `mass`, `stiffness` (k)
136/// and `damping` (c). The angular frequency is `ω₀ = √(k/m)` and the damping
137/// ratio is `ζ = c / (2√(k·m))`.
138#[derive(Clone, Copy, Debug, PartialEq)]
139pub struct Spring {
140    /// Oscillator mass (`> 0`).
141    pub mass: f32,
142    /// Spring stiffness `k` (`> 0`).
143    pub stiffness: f32,
144    /// Damping coefficient `c` (`>= 0`).
145    pub damping: f32,
146}
147
148impl Default for Spring {
149    /// A snappy, slightly-underdamped UI spring.
150    fn default() -> Self {
151        Self {
152            mass: 1.0,
153            stiffness: 170.0,
154            damping: 26.0,
155        }
156    }
157}
158
159impl Spring {
160    /// Construct a spring from physical parameters.
161    pub fn new(mass: f32, stiffness: f32, damping: f32) -> Self {
162        Self {
163            mass,
164            stiffness,
165            damping,
166        }
167    }
168
169    /// Build a spring with the given natural frequency `ω₀` (rad/s) and damping
170    /// ratio `ζ` (unit mass). `ζ = 1` is critically damped.
171    pub fn from_frequency(omega0: f32, zeta: f32) -> Self {
172        let mass = 1.0;
173        let stiffness = omega0 * omega0 * mass;
174        let damping = 2.0 * zeta * omega0 * mass;
175        Self {
176            mass,
177            stiffness,
178            damping,
179        }
180    }
181
182    /// The undamped natural angular frequency `ω₀ = √(k/m)`.
183    pub fn natural_frequency(&self) -> f32 {
184        (self.stiffness / self.mass.max(f32::EPSILON)).sqrt()
185    }
186
187    /// The damping ratio `ζ`. `< 1` under-, `== 1` critically-, `> 1` over-damped.
188    pub fn damping_ratio(&self) -> f32 {
189        let denom = 2.0 * (self.stiffness * self.mass).max(f32::EPSILON).sqrt();
190        self.damping / denom
191    }
192
193    /// Position at time `t` (seconds) for a spring released from displacement
194    /// `from - to` with initial velocity `v0`, settling towards `to`.
195    ///
196    /// Returns the absolute position (already offset by `to`). The solution is
197    /// the exact analytic response of `m·x'' + c·x' + k·x = 0`, so it is
198    /// numerically stable at any `t` and frame rate.
199    pub fn position(&self, from: f32, to: f32, v0: f32, t: f32) -> f32 {
200        if t <= 0.0 {
201            return from;
202        }
203        let x0 = from - to; // displacement from the rest position
204        let omega0 = self.natural_frequency();
205        if omega0 <= f32::EPSILON {
206            return to + x0; // no restoring force: stays put
207        }
208        let zeta = self.damping_ratio();
209
210        let offset = if (zeta - 1.0).abs() < 1e-4 {
211            // Critically damped: x(t) = (x0 + (v0 + ω₀·x0)·t)·e^(−ω₀·t).
212            let c2 = v0 + omega0 * x0;
213            (x0 + c2 * t) * (-omega0 * t).exp()
214        } else if zeta < 1.0 {
215            // Under-damped: decaying oscillation.
216            let omega_d = omega0 * (1.0 - zeta * zeta).sqrt();
217            let decay = (-zeta * omega0 * t).exp();
218            let a = x0;
219            let b = (v0 + zeta * omega0 * x0) / omega_d;
220            decay * (a * (omega_d * t).cos() + b * (omega_d * t).sin())
221        } else {
222            // Over-damped: sum of two real exponentials.
223            let disc = (zeta * zeta - 1.0).sqrt();
224            let r1 = -omega0 * (zeta - disc);
225            let r2 = -omega0 * (zeta + disc);
226            let c1 = (v0 - r2 * x0) / (r1 - r2);
227            let c2 = x0 - c1;
228            c1 * (r1 * t).exp() + c2 * (r2 * t).exp()
229        };
230        to + offset
231    }
232
233    /// Whether the spring has effectively settled at `to` by time `t`, within
234    /// `tolerance` of the rest position.
235    pub fn is_settled(&self, from: f32, to: f32, v0: f32, t: f32, tolerance: f32) -> bool {
236        (self.position(from, to, v0, t) - to).abs() <= tolerance
237    }
238}
239
240/// A property transition: an [`Easing`] applied over `duration` after `delay`.
241#[derive(Clone, Copy, Debug, PartialEq)]
242pub struct Transition {
243    /// Animation duration in seconds (`> 0`).
244    pub duration: f32,
245    /// Delay before the animation begins, in seconds (`>= 0`).
246    pub delay: f32,
247    /// The timing function.
248    pub easing: Easing,
249}
250
251impl Transition {
252    /// A transition over `duration` seconds with the given easing and no delay.
253    pub fn new(duration: f32, easing: Easing) -> Self {
254        Self {
255            duration,
256            delay: 0.0,
257            easing,
258        }
259    }
260
261    /// Builder: set the start delay.
262    pub fn with_delay(mut self, delay: f32) -> Self {
263        self.delay = delay;
264        self
265    }
266
267    /// The eased progress `[0, 1]` at `elapsed` seconds since the transition was
268    /// scheduled (accounts for `delay`). Returns `0` during the delay and `1`
269    /// once `delay + duration` has passed.
270    pub fn progress(&self, elapsed: f32) -> f32 {
271        let active = elapsed - self.delay;
272        if active <= 0.0 {
273            return 0.0;
274        }
275        if self.duration <= 0.0 || active >= self.duration {
276            return 1.0;
277        }
278        self.easing.eval(active / self.duration)
279    }
280
281    /// Interpolate a scalar from `start` to `end` at `elapsed` seconds.
282    pub fn sample(&self, start: f32, end: f32, elapsed: f32) -> f32 {
283        let p = self.progress(elapsed);
284        start + (end - start) * p
285    }
286
287    /// Returns `true` once the transition (delay + duration) has completed.
288    pub fn is_finished(&self, elapsed: f32) -> bool {
289        elapsed >= self.delay + self.duration
290    }
291}
292
293/// A single tracked animation from `start` to `end` over a [`Transition`].
294#[derive(Clone, Copy, Debug)]
295struct ActiveTransition {
296    key: u64,
297    start: f32,
298    end: f32,
299    transition: Transition,
300    /// Total elapsed time the animation has been advanced.
301    elapsed: f32,
302}
303
304/// Tracks a set of keyed transitions and advances them frame by frame.
305///
306/// Each animation is identified by a caller-chosen `u64` key; starting a new
307/// animation with an existing key replaces it (so a re-triggered hover doesn't
308/// stack). [`Animator::advance`] adds `dt` seconds to every active animation and
309/// drops the finished ones.
310#[derive(Debug, Default)]
311pub struct Animator {
312    active: Vec<ActiveTransition>,
313}
314
315impl Animator {
316    /// Create an empty animator.
317    pub fn new() -> Self {
318        Self::default()
319    }
320
321    /// Number of currently-active animations.
322    pub fn active_count(&self) -> usize {
323        self.active.len()
324    }
325
326    /// Returns `true` if any animation is in flight.
327    pub fn is_animating(&self) -> bool {
328        !self.active.is_empty()
329    }
330
331    /// Start (or restart) the animation under `key`, interpolating
332    /// `start → end` over `transition`. Any existing animation with the same
333    /// key is replaced and its elapsed time reset.
334    pub fn start(&mut self, key: u64, start: f32, end: f32, transition: Transition) {
335        let entry = ActiveTransition {
336            key,
337            start,
338            end,
339            transition,
340            elapsed: 0.0,
341        };
342        if let Some(slot) = self.active.iter_mut().find(|a| a.key == key) {
343            *slot = entry;
344        } else {
345            self.active.push(entry);
346        }
347    }
348
349    /// The current value of the animation under `key`, or `None` if no such
350    /// animation is active.
351    pub fn value(&self, key: u64) -> Option<f32> {
352        self.active
353            .iter()
354            .find(|a| a.key == key)
355            .map(|a| a.transition.sample(a.start, a.end, a.elapsed))
356    }
357
358    /// Advance every active animation by `dt` seconds, removing any that have
359    /// finished. Returns the number still active afterwards.
360    pub fn advance(&mut self, dt: f32) -> usize {
361        for a in &mut self.active {
362            a.elapsed += dt;
363        }
364        self.active.retain(|a| !a.transition.is_finished(a.elapsed));
365        self.active.len()
366    }
367
368    /// Cancel the animation under `key`. Returns `true` if one was removed.
369    pub fn cancel(&mut self, key: u64) -> bool {
370        let before = self.active.len();
371        self.active.retain(|a| a.key != key);
372        self.active.len() != before
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379
380    fn close(a: f32, b: f32, eps: f32) -> bool {
381        (a - b).abs() <= eps
382    }
383
384    #[test]
385    fn linear_easing_endpoints_and_midpoint() {
386        assert_eq!(Easing::Linear.eval(0.0), 0.0);
387        assert_eq!(Easing::Linear.eval(1.0), 1.0);
388        assert!(close(Easing::Linear.eval(0.5), 0.5, 1e-6));
389    }
390
391    #[test]
392    fn ease_in_out_is_symmetric_about_half() {
393        let e = Easing::EaseInOut;
394        // Endpoints exact.
395        assert!(close(e.eval(0.0), 0.0, 1e-6));
396        assert!(close(e.eval(1.0), 1.0, 1e-6));
397        // Midpoint of a symmetric ease should be ~0.5.
398        assert!(close(e.eval(0.5), 0.5, 1e-3), "got {}", e.eval(0.5));
399        // Symmetry: f(t) + f(1-t) ≈ 1.
400        for t in [0.1f32, 0.25, 0.4] {
401            assert!(close(e.eval(t) + e.eval(1.0 - t), 1.0, 2e-3), "t={t}");
402        }
403    }
404
405    #[test]
406    fn ease_in_starts_slow() {
407        // EaseIn output should lag behind linear in the first half.
408        let e = Easing::EaseIn;
409        assert!(
410            e.eval(0.25) < 0.25,
411            "ease-in should be below the diagonal early"
412        );
413        assert!(close(e.eval(1.0), 1.0, 1e-6));
414    }
415
416    #[test]
417    fn cubic_bezier_recovers_linear() {
418        // A bezier with collinear controls on the diagonal is the identity.
419        let lin = Easing::CubicBezier {
420            x1: 0.25,
421            y1: 0.25,
422            x2: 0.75,
423            y2: 0.75,
424        };
425        for t in [0.0f32, 0.2, 0.5, 0.8, 1.0] {
426            assert!(close(lin.eval(t), t, 2e-3), "t={t} got {}", lin.eval(t));
427        }
428    }
429
430    #[test]
431    fn cubic_bezier_degenerate_does_not_nan() {
432        // (0,1,1,0): zero x-derivative at both ends — the Newton fallback path.
433        let e = Easing::CubicBezier {
434            x1: 0.0,
435            y1: 1.0,
436            x2: 1.0,
437            y2: 0.0,
438        };
439        for i in 0..=10 {
440            let t = i as f32 / 10.0;
441            let v = e.eval(t);
442            assert!(v.is_finite(), "value at t={t} must be finite, got {v}");
443            assert!((0.0..=1.0).contains(&v) || close(v, 0.0, 1e-3) || close(v, 1.0, 1e-3));
444        }
445    }
446
447    #[test]
448    fn spring_critically_damped_converges_without_overshoot() {
449        let s = Spring::from_frequency(20.0, 1.0); // zeta = 1
450        assert!(close(s.damping_ratio(), 1.0, 1e-3));
451        // From 0 to 1, no initial velocity.
452        let mut prev = s.position(0.0, 1.0, 0.0, 0.0);
453        assert!(close(prev, 0.0, 1e-4));
454        // Monotone approach (critically damped never overshoots).
455        for i in 1..=60 {
456            let t = i as f32 / 60.0;
457            let p = s.position(0.0, 1.0, 0.0, t);
458            assert!(p <= 1.0 + 1e-3, "overshoot at t={t}: {p}");
459            assert!(p >= prev - 1e-4, "should be monotone increasing at t={t}");
460            prev = p;
461        }
462        assert!(s.is_settled(0.0, 1.0, 0.0, 1.5, 1e-2));
463    }
464
465    #[test]
466    fn spring_underdamped_overshoots_then_settles() {
467        let s = Spring::from_frequency(30.0, 0.3); // lightly damped
468        assert!(s.damping_ratio() < 1.0);
469        let mut max = f32::MIN;
470        for i in 0..=200 {
471            let t = i as f32 / 100.0;
472            max = max.max(s.position(0.0, 1.0, 0.0, t));
473        }
474        assert!(
475            max > 1.0,
476            "underdamped spring should overshoot the target, max={max}"
477        );
478        // And it should be settled after enough time.
479        assert!(s.is_settled(0.0, 1.0, 0.0, 5.0, 2e-2));
480    }
481
482    #[test]
483    fn spring_overdamped_no_overshoot() {
484        let s = Spring::from_frequency(10.0, 2.0); // zeta = 2
485        assert!(s.damping_ratio() > 1.0);
486        for i in 0..=100 {
487            let t = i as f32 / 50.0;
488            let p = s.position(0.0, 1.0, 0.0, t);
489            assert!(
490                p <= 1.0 + 1e-3,
491                "overdamped must not overshoot, t={t} p={p}"
492            );
493        }
494    }
495
496    #[test]
497    fn transition_progress_respects_delay_and_duration() {
498        let tr = Transition::new(2.0, Easing::Linear).with_delay(1.0);
499        assert_eq!(tr.progress(0.5), 0.0); // still in delay
500        assert!(close(tr.progress(2.0), 0.5, 1e-6)); // 1s into a 2s anim
501        assert_eq!(tr.progress(3.0), 1.0); // finished
502        assert!(tr.is_finished(3.0));
503        assert!(!tr.is_finished(2.5));
504        assert!(close(tr.sample(10.0, 20.0, 2.0), 15.0, 1e-4));
505    }
506
507    #[test]
508    fn animator_tracks_and_drops_finished() {
509        let mut anim = Animator::new();
510        anim.start(1, 0.0, 100.0, Transition::new(1.0, Easing::Linear));
511        assert!(anim.is_animating());
512        assert!(close(anim.value(1).expect("active"), 0.0, 1e-4));
513        anim.advance(0.5);
514        assert!(close(anim.value(1).expect("active"), 50.0, 1e-3));
515        // Restart with same key resets elapsed.
516        anim.start(1, 0.0, 100.0, Transition::new(1.0, Easing::Linear));
517        assert!(close(anim.value(1).expect("active"), 0.0, 1e-4));
518        // Advance past the end -> dropped.
519        anim.advance(1.5);
520        assert_eq!(anim.active_count(), 0);
521        assert!(anim.value(1).is_none());
522    }
523
524    #[test]
525    fn animator_cancel() {
526        let mut anim = Animator::new();
527        anim.start(7, 0.0, 1.0, Transition::new(1.0, Easing::Linear));
528        assert!(anim.cancel(7));
529        assert!(!anim.cancel(7));
530    }
531}