Skip to main content

rdi_core/
procedural.rs

1//! Ready-made procedural curves.
2//!
3//! These are thin wrappers over [`crate::curve::KeyframeCurve::from_curve_fn`]
4//! that ship a handful of well-tuned non-easing shapes (spring, bounce,
5//! elastic, overshoot / "back"). Users who need something more custom can
6//! call [`crate::Curve::from_curve_fn`] / [`crate::Curve::from_motion_fn`]
7//! directly.
8//!
9//! All functions return a fully validated [`crate::Curve`]; construction may
10//! fail with [`crate::CurveError`] if the parameters yield an unsafe curve
11//! (NaN, exploding values, ...).
12
13use core::f32::consts::PI;
14
15use crate::curve::{Curve, KeyframeInterp};
16use crate::CurveError;
17
18/// Damped-cosine "spring" curve.
19///
20/// * `damping` — fraction of critical damping in `[0, 1]`. `0.0` = pure
21///   oscillation, `1.0` = critically damped.
22/// * `stiffness`, `mass` — control the natural angular frequency
23///   `ω = sqrt(stiffness / mass)`.
24///
25/// The curve starts at `0`, overshoots `1` (unless critically damped) and
26/// settles near `1` at `t = 1`. It is normalised so the shape doesn't
27/// depend on the animation's wall-clock duration.
28pub fn spring(damping: f32, stiffness: f32, mass: f32) -> Result<Curve, CurveError> {
29    let mass = mass.max(1e-3);
30    let stiffness = stiffness.max(1e-3);
31    let zeta = damping.clamp(0.0, 1.0);
32    let omega = (stiffness / mass).sqrt();
33    let omega_d = omega * (1.0 - zeta * zeta).max(0.0).sqrt();
34
35    // Scale the closure's time so ~3 oscillations at zeta = 0 are visible
36    // over t in [0, 1]. This is a heuristic default: the user still gets
37    // full physics control via damping/stiffness/mass.
38    let scale = 3.0 * PI;
39
40    Curve::from_curve_fn(
41        move |t| {
42            let x = t * scale;
43            1.0 - (-zeta * omega * x).exp() * (omega_d * x).cos()
44        },
45        128,
46        KeyframeInterp::Linear,
47    )
48}
49
50/// Bouncing curve — several damped rebounds converging on `1`.
51///
52/// * `bounces` — number of rebounds (≥ 1).
53/// * `decay` — per-bounce amplitude loss in `(0, 1]` (higher = faster
54///   settle). Values ≤ 0 are coerced to a small positive number so the
55///   curve still lands exactly on `1` at `t = 1`.
56pub fn bounce(bounces: u32, decay: f32) -> Result<Curve, CurveError> {
57    let b = bounces.max(1) as f32;
58    let decay = decay.clamp(0.0, 1.0).max(1e-2);
59    // Envelope exponent — always positive so `(1-t)^k` hits 0 at t = 1
60    // and the curve reaches exactly 1 there regardless of `bounces`.
61    let k = 0.5 + 2.0 * decay;
62
63    Curve::from_curve_fn(
64        move |t| {
65            let envelope = (1.0 - t).max(0.0).powf(k);
66            1.0 - (b * PI * t).cos().abs() * envelope
67        },
68        128,
69        KeyframeInterp::Linear,
70    )
71}
72
73/// Elastic ease-out — sinusoidal overshoot damped by `2^(-10t)`.
74///
75/// * `period` — normalized oscillation period (default around `0.3`).
76/// * `amplitude` — overshoot amplitude (≥ 1).
77pub fn elastic(period: f32, amplitude: f32) -> Result<Curve, CurveError> {
78    let p = period.max(0.05);
79    let a = amplitude.max(1.0);
80    // s shifts the sine so the curve starts at 0. Standard Robert Penner form.
81    let s = p / (2.0 * PI) * (1.0f32 / a).asin();
82
83    Curve::from_curve_fn(
84        move |t| {
85            if t <= 0.0 {
86                return 0.0;
87            }
88            if t >= 1.0 {
89                return 1.0;
90            }
91            a * (2.0f32).powf(-10.0 * t) * ((t - s) * 2.0 * PI / p).sin() + 1.0
92        },
93        128,
94        KeyframeInterp::Linear,
95    )
96}
97
98/// Back / overshoot ease-in-out (Robert Penner's "back" family).
99///
100/// * `strength` ≥ 0. `0.0` = no overshoot, `1.7` ≈ default CSS `back`.
101pub fn overshoot(strength: f32) -> Result<Curve, CurveError> {
102    let s = strength.max(0.0);
103    let c1 = s + 1.0;
104
105    Curve::from_curve_fn(
106        move |t| {
107            let u = t - 1.0;
108            u * u * (c1 * u + s) + 1.0
109        },
110        96,
111        KeyframeInterp::Linear,
112    )
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use crate::AnimationCurve;
119
120    const EPS: f32 = 1e-3;
121
122    fn endpoints_reach_zero_and_one(curve: &Curve) {
123        assert!(curve.eval(0.0).abs() < EPS, "start != 0: {}", curve.eval(0.0));
124        assert!(
125            (curve.eval(1.0) - 1.0).abs() < EPS,
126            "end != 1: {}",
127            curve.eval(1.0)
128        );
129    }
130
131    #[test]
132    fn spring_endpoints_and_bounded() {
133        let c = spring(0.5, 180.0, 1.0).unwrap();
134        endpoints_reach_zero_and_one(&c);
135        // Values must stay within the runtime clamp so linear-value mapping is sane.
136        for i in 0..=50 {
137            let t = i as f32 / 50.0;
138            let v = c.eval(t);
139            assert!(v.abs() < 5.0, "spring blew up at t={t}: {v}");
140        }
141    }
142
143    #[test]
144    fn spring_critical_damping_no_overshoot() {
145        let c = spring(1.0, 180.0, 1.0).unwrap();
146        for i in 0..=50 {
147            let t = i as f32 / 50.0;
148            let v = c.eval(t);
149            assert!(v <= 1.01, "critical spring overshot at t={t}: {v}");
150        }
151    }
152
153    #[test]
154    fn bounce_endpoints() {
155        let c = bounce(3, 0.5).unwrap();
156        endpoints_reach_zero_and_one(&c);
157    }
158
159    #[test]
160    fn elastic_endpoints() {
161        let c = elastic(0.3, 1.0).unwrap();
162        endpoints_reach_zero_and_one(&c);
163    }
164
165    #[test]
166    fn overshoot_zero_strength_is_smooth_ease_out_cubic() {
167        let c = overshoot(0.0).unwrap();
168        // With s=0 the polynomial u²·(1·u) + 1 = (t−1)³ + 1 = cubic-out.
169        for i in 0..=10 {
170            let t = i as f32 / 10.0;
171            let expected = {
172                let u = 1.0 - t;
173                1.0 - u * u * u
174            };
175            assert!(
176                (c.eval(t) - expected).abs() < 5e-3,
177                "at t={t}: got {}, want {expected}",
178                c.eval(t)
179            );
180        }
181    }
182
183    #[test]
184    fn overshoot_positive_strength_overshoots_one() {
185        let c = overshoot(1.7).unwrap();
186        // Somewhere in the middle it should exceed 1.
187        let peak = (0..=100)
188            .map(|i| c.eval(i as f32 / 100.0))
189            .fold(f32::MIN, f32::max);
190        assert!(peak > 1.05, "no overshoot detected, peak = {peak}");
191    }
192}