Skip to main content

ashlar_motion/
spring.rs

1//! Closed-form damped springs set by a duration and a bounce.
2//!
3//! A spring with duration `d` and bounce `b` has angular frequency `ω = 2π / d` and damping ratio
4//! `ζ = 1 − b` for `b ≥ 0` and `ζ = 1 / (1 + b)` for `b < 0`: bounce 0 is critically damped, a
5//! positive bounce overshoots, a negative one is overdamped. It is the SwiftUI-style
6//! parameterisation, so a duration is roughly how long the motion feels, whatever the bounce.
7//!
8//! [`Spring::sample`] evaluates position and velocity in closed form (no stepping), so a sample
9//! costs the same at any time and two samples at the same time always agree.
10
11use crate::class::Eps;
12use core::f64::consts::TAU;
13
14/// The longest settle time, in seconds. A value still moving then snaps to its target.
15pub const MAX_SETTLE: f32 = 120.0;
16
17/// A damped spring of unit mass, set by `(duration, bounce)`. The fields are private so the
18/// invariants hold: the duration is 0 (no spring, see [`Spring::NONE`]) or within
19/// [`Spring::MIN_DURATION`]`..=`[`Spring::MAX_DURATION`], and the bounce is within
20/// `-MAX_BOUNCE..=MAX_BOUNCE`.
21#[derive(Clone, Copy, Debug, PartialEq)]
22pub struct Spring {
23    duration: f32,
24    bounce: f32,
25}
26
27impl Default for Spring {
28    fn default() -> Self {
29        Spring::DEFAULT
30    }
31}
32
33/// The derived constants of a spring, in f64.
34#[derive(Clone, Copy, Debug)]
35struct Params {
36    /// Decay rate `ζω`.
37    alpha: f64,
38    /// Undamped angular frequency `ω`.
39    w0: f64,
40    kind: Kind,
41}
42
43#[derive(Clone, Copy, Debug)]
44enum Kind {
45    /// ζ < 1, with the damped frequency `ω√(1 − ζ²)`.
46    Under { wd: f64 },
47    /// ζ = 1.
48    Critical,
49    /// ζ > 1, with `s = ω√(ζ² − 1)`.
50    Over { s: f64 },
51}
52
53impl Spring {
54    /// The shortest duration of a spring that moves, in seconds.
55    pub const MIN_DURATION: f32 = 0.01;
56    /// The longest duration, in seconds.
57    pub const MAX_DURATION: f32 = 60.0;
58    /// The largest bounce either way.
59    pub const MAX_BOUNCE: f32 = 0.95;
60    /// 0.3 s without bounce: the default for most interface motion.
61    pub const DEFAULT: Spring = Spring { duration: 0.3, bounce: 0.0 };
62    /// No spring: values jump to their target (treated like `Curve::Instant`).
63    pub const NONE: Spring = Spring { duration: 0.0, bounce: 0.0 };
64
65    /// A spring of `duration` seconds and `bounce` (−0.95..=0.95). Never panics: a NaN duration
66    /// gives the default 0.3 s, zero or negative gives [`Spring::NONE`], other values are clamped
67    /// to `MIN_DURATION..=MAX_DURATION`; a NaN bounce is 0 and others are clamped.
68    pub const fn new(duration: f32, bounce: f32) -> Spring {
69        let duration = if duration.is_nan() {
70            Self::DEFAULT.duration
71        } else if duration <= 0.0 {
72            0.0
73        } else {
74            duration.clamp(Self::MIN_DURATION, Self::MAX_DURATION)
75        };
76        let bounce = if bounce.is_nan() { 0.0 } else { bounce.clamp(-Self::MAX_BOUNCE, Self::MAX_BOUNCE) };
77        Spring { duration, bounce }
78    }
79
80    /// A spring from a response (the duration) and a damping ratio `ζ` (1 = critical, below 1
81    /// bounces). Clamped like [`Spring::new`]; a NaN ratio is 1.
82    pub const fn from_response(response: f32, damping_ratio: f32) -> Spring {
83        let zeta = if damping_ratio.is_nan() { 1.0 } else { damping_ratio };
84        let bounce = if zeta <= 0.0 {
85            Self::MAX_BOUNCE
86        } else if zeta <= 1.0 {
87            1.0 - zeta
88        } else {
89            1.0 / zeta - 1.0
90        };
91        Spring::new(response, bounce)
92    }
93
94    /// The duration in seconds (0 for [`Spring::NONE`]).
95    pub const fn duration(self) -> f32 {
96        self.duration
97    }
98
99    /// The bounce, −0.95..=0.95.
100    pub const fn bounce(self) -> f32 {
101        self.bounce
102    }
103
104    /// Whether this is no spring at all (duration 0): values jump.
105    pub const fn is_none(self) -> bool {
106        self.duration <= 0.0
107    }
108
109    /// The damping ratio `ζ`.
110    pub fn damping_ratio(self) -> f32 {
111        zeta(self.bounce) as f32
112    }
113
114    /// The stiffness `k = ω²` for unit mass (0 for [`Spring::NONE`]).
115    pub fn stiffness(self) -> f32 {
116        self.params().map_or(0.0, |p| (p.w0 * p.w0) as f32)
117    }
118
119    /// The damping coefficient `c = 2ζω` for unit mass (0 for [`Spring::NONE`]).
120    pub fn damping(self) -> f32 {
121        self.params().map_or(0.0, |p| (2.0 * p.alpha) as f32)
122    }
123
124    fn params(self) -> Option<Params> {
125        if self.is_none() {
126            return None;
127        }
128        let w0 = TAU / f64::from(self.duration);
129        let zeta = zeta(self.bounce);
130        let alpha = zeta * w0;
131        let kind = if zeta < 1.0 {
132            let wd = w0 * (1.0 - zeta * zeta).sqrt();
133            if wd > 0.0 { Kind::Under { wd } } else { Kind::Critical }
134        } else if zeta > 1.0 {
135            let s = w0 * (zeta * zeta - 1.0).sqrt();
136            if s > 0.0 { Kind::Over { s } } else { Kind::Critical }
137        } else {
138            Kind::Critical
139        };
140        Some(Params { alpha, w0, kind })
141    }
142
143    /// Position and velocity `t` seconds after starting at `x0` with velocity `v0`, pulled to
144    /// `target`. Closed form for all three damping regimes. Non-finite inputs are replaced
145    /// (position by the target, velocity by 0, a bad target by the start), `t ≤ 0` returns the
146    /// start, and the result is always finite.
147    pub fn sample(self, x0: f32, v0: f32, target: f32, t: f32) -> (f32, f32) {
148        let (x0, v0, target) = sane_state(x0, v0, target);
149        if t.is_nan() || t <= 0.0 {
150            return (x0, v0);
151        }
152        let Some(p) = self.params() else { return (target, 0.0) };
153        let (x, v) = p.sample(f64::from(x0), f64::from(v0), f64::from(target), f64::from(t));
154        (finite_f32(x, target), finite_f32(v, 0.0))
155    }
156
157    /// The time in seconds after which a motion from `(from, v0)` to `target` stays within
158    /// `eps` (position and velocity) for good. Never earlier than the true settle (it is the last
159    /// crossing of a bound of the motion), at most [`MAX_SETTLE`], and 0 when already settled.
160    pub fn settle_time(self, from: f32, v0: f32, target: f32, eps: Eps) -> f32 {
161        self.settle_time_lanes([(from, v0, target)], eps)
162    }
163
164    /// The settle time of several lanes `(from, v0, target)` moving together: one bound over all
165    /// of them (each coefficient of the bound is the largest of any lane's), so it is never
166    /// earlier than any lane's settle and costs one root solve.
167    pub fn settle_time_lanes(self, lanes: impl IntoIterator<Item = (f32, f32, f32)>, eps: Eps) -> f32 {
168        let Some(p) = self.params() else { return 0.0 };
169        let mut bounds = Bounds::default();
170        for (from, v0, target) in lanes {
171            let (from, v0, target) = sane_state(from, v0, target);
172            bounds = bounds.max(p.bounds(f64::from(from) - f64::from(target), f64::from(v0)));
173        }
174        let t = p.solve(&bounds, eps.sanitized());
175        // a little later for f32 rounding of the samples near the boundary, then capped
176        let t = t * (1.0 + 1e-4) + 1e-4;
177        if t.is_finite() { (t as f32).clamp(0.0, MAX_SETTLE) } else { MAX_SETTLE }
178    }
179}
180
181/// Non-negative coefficients of upper bounds on `|y(t)|` and `|v(t)|`; each bound only grows with
182/// its coefficients, so the lane-wise maximum bounds every lane at once.
183#[derive(Clone, Copy, Debug, Default)]
184struct Bounds {
185    /// `|y| ≤ (y_a + y_b·t)·e^{-rt}`, and the same for `v`.
186    y_a: f64,
187    y_b: f64,
188    v_a: f64,
189    v_b: f64,
190    /// Underdamped: the envelope amplitude (`y_e·e^{-αt}`), `y_f` unused. Overdamped: the slow and
191    /// fast mode amplitudes (`y_e·e^{r1 t} + y_f·e^{r2 t}`).
192    y_e: f64,
193    y_f: f64,
194    v_e: f64,
195    v_f: f64,
196}
197
198impl Bounds {
199    fn max(self, o: Bounds) -> Bounds {
200        Bounds {
201            y_a: self.y_a.max(o.y_a),
202            y_b: self.y_b.max(o.y_b),
203            v_a: self.v_a.max(o.v_a),
204            v_b: self.v_b.max(o.v_b),
205            y_e: self.y_e.max(o.y_e),
206            y_f: self.y_f.max(o.y_f),
207            v_e: self.v_e.max(o.v_e),
208            v_f: self.v_f.max(o.v_f),
209        }
210    }
211}
212
213impl Params {
214    /// `(e^{-αt}·C(t), e^{-αt}·S(t))`, where the displacement is `y = y0·C + (v0 + α·y0)·S`
215    /// (times the decay): `C = cos(ωd t), S = sin(ωd t)/ωd` underdamped, `C = 1, S = t` critical,
216    /// `C = cosh(st), S = sinh(st)/s` overdamped. Written so nothing overflows for any `t`.
217    fn decayed_c_s(&self, t: f64) -> (f64, f64) {
218        match self.kind {
219            Kind::Under { wd } => {
220                let e = (-self.alpha * t).exp();
221                let (sin, cos) = (wd * t).sin_cos();
222                (e * cos, e * sin / wd)
223            }
224            Kind::Critical => {
225                let e = (-self.w0 * t).exp();
226                (e, e * t)
227            }
228            Kind::Over { s } => {
229                let e1 = ((s - self.alpha) * t).exp();
230                let e2 = (-(s + self.alpha) * t).exp();
231                let x = s * t;
232                let es = if x < 1e-3 {
233                    // sinh(x)/s without cancellation
234                    (-self.alpha * t).exp() * t * (1.0 + x * x / 6.0)
235                } else {
236                    (e1 - e2) / (2.0 * s)
237                };
238                ((e1 + e2) * 0.5, es)
239            }
240        }
241    }
242
243    fn sample(&self, x0: f64, v0: f64, target: f64, t: f64) -> (f64, f64) {
244        let y0 = x0 - target;
245        let (ec, es) = self.decayed_c_s(t);
246        let y = y0 * ec + (v0 + self.alpha * y0) * es;
247        let v = v0 * ec - (self.alpha * v0 + self.w0 * self.w0 * y0) * es;
248        (target + y, v)
249    }
250
251    /// The bound coefficients of a motion with displacement `y0` and velocity `v0`. Both `y` and
252    /// `v` have the form `e^{-αt}(a·C + b·S)`.
253    fn bounds(&self, y0: f64, v0: f64) -> Bounds {
254        let w2 = self.w0 * self.w0;
255        // y = e(y0·C + by·S), v = e(v0·C − bv·S)
256        let by = v0 + self.alpha * y0;
257        let bv = self.alpha * v0 + w2 * y0;
258        let (y_e, y_f, v_e, v_f) = match self.kind {
259            // the exact envelope R·e^{-αt}
260            Kind::Under { wd } => ((y0 * y0 + (by / wd) * (by / wd)).sqrt(), 0.0, (v0 * v0 + (bv / wd) * (bv / wd)).sqrt(), 0.0),
261            Kind::Critical => (0.0, 0.0, 0.0, 0.0),
262            // y = c1·e^{r1 t} + c2·e^{r2 t} with r2 < r1 < 0
263            Kind::Over { s } => {
264                let (r1, r2) = (s - self.alpha, -s - self.alpha);
265                let c2 = (v0 - r1 * y0) / (r2 - r1);
266                let c1 = y0 - c2;
267                (c1.abs(), c2.abs(), (r1 * c1).abs(), (r2 * c2).abs())
268            }
269        };
270        Bounds { y_a: y0.abs(), y_b: by.abs(), v_a: v0.abs(), v_b: bv.abs(), y_e, y_f, v_e, v_f }
271    }
272
273    /// The last time either bound exceeds its threshold: when `|y| ≤ eps.pos` and
274    /// `|v| ≤ eps.vel` hold for good.
275    fn solve(&self, b: &Bounds, eps: Eps) -> f64 {
276        let (ep, ev) = (f64::from(eps.pos), f64::from(eps.vel));
277        let (ty, tv) = match self.kind {
278            // |C| ≤ 1 and |S| ≤ t, or the envelope
279            Kind::Under { .. } => (
280                linear_exp_root(b.y_a, b.y_b, self.alpha, ep).min(exp_root(b.y_e, self.alpha, ep)),
281                linear_exp_root(b.v_a, b.v_b, self.alpha, ev).min(exp_root(b.v_e, self.alpha, ev)),
282            ),
283            Kind::Critical => (linear_exp_root(b.y_a, b.y_b, self.w0, ep), linear_exp_root(b.v_a, b.v_b, self.w0, ev)),
284            // e^{-αt}cosh(st) ≤ e^{r1 t} and e^{-αt}sinh(st)/s ≤ t·e^{r1 t}; or the two modes
285            Kind::Over { s } => {
286                let (slow, fast) = (self.alpha - s, self.alpha + s);
287                (
288                    linear_exp_root(b.y_a, b.y_b, slow, ep).min(two_exp_root(b.y_e, slow, b.y_f, fast, ep)),
289                    linear_exp_root(b.v_a, b.v_b, slow, ev).min(two_exp_root(b.v_e, slow, b.v_f, fast, ev)),
290                )
291            }
292        };
293        let t = ty.max(tv);
294        if t.is_nan() { f64::from(MAX_SETTLE) } else { t.max(0.0) }
295    }
296}
297
298fn zeta(bounce: f32) -> f64 {
299    let b = f64::from(bounce);
300    if b >= 0.0 { 1.0 - b } else { 1.0 / (1.0 + b) }
301}
302
303/// The time after which `a·e^{-rt} ≤ eps` for good (0 when it already is).
304fn exp_root(a: f64, r: f64, eps: f64) -> f64 {
305    if a.is_nan() || a <= eps {
306        return 0.0;
307    }
308    if r.is_nan() || r <= 0.0 || !a.is_finite() {
309        return f64::INFINITY;
310    }
311    (a / eps).ln() / r
312}
313
314/// The time at which `a·e^{-ra·t} + b·e^{-rb·t}` falls to `eps` (it only decreases), with
315/// `ra ≤ rb`: a bracketed false position (Illinois) that returns the bracket's late end, so it is
316/// never early.
317fn two_exp_root(a: f64, ra: f64, b: f64, rb: f64, eps: f64) -> f64 {
318    let f = |t: f64| a * (-ra * t).exp() + b * (-rb * t).exp() - eps;
319    // the sum is at least a·e^{-ra·t} and at most (a + b)·e^{-ra·t}
320    let (mut lo, mut hi) = (exp_root(a, ra, eps), exp_root(a + b, ra, eps));
321    if !hi.is_finite() || hi <= lo {
322        return hi;
323    }
324    let (mut flo, mut fhi) = (f(lo), f(hi));
325    if flo <= 0.0 {
326        // the fast term is gone by `lo`: the bound is under eps from there on
327        return lo;
328    }
329    let mut side = 0i8;
330    for _ in 0..24 {
331        if flo.is_nan() || fhi.is_nan() || fhi > 0.0 || hi - lo < 1e-6 * (1.0 + hi) {
332            break;
333        }
334        let t = (lo * fhi - hi * flo) / (fhi - flo);
335        let t = if t > lo && t < hi { t } else { (lo + hi) * 0.5 };
336        let ft = f(t);
337        if ft > 0.0 {
338            lo = t;
339            flo = ft;
340            if side == -1 {
341                fhi *= 0.5;
342            }
343            side = -1;
344        } else {
345            hi = t;
346            fhi = ft;
347            if side == 1 {
348                flo *= 0.5;
349            }
350            side = 1;
351        }
352    }
353    hi
354}
355
356/// The time after which `(a + b·t)·e^{-rt} ≤ eps` for good: the last root, found by Newton's
357/// method on the concave `ln(a + bt) − rt − ln(eps)` from a point past it, so every iterate stays
358/// at or after the root (never early) and stopping at any iteration is safe.
359fn linear_exp_root(a: f64, b: f64, r: f64, eps: f64) -> f64 {
360    if b.is_nan() || b <= 0.0 {
361        return exp_root(a, r, eps);
362    }
363    if r.is_nan() || r <= 0.0 || !a.is_finite() || !b.is_finite() {
364        return f64::INFINITY;
365    }
366    // a + bt ≤ (a + 2b/r)·e^{rt/2}, so the bound is under eps from t0 on
367    let big = a + 2.0 * b / r;
368    if big.is_nan() || big <= eps {
369        return 0.0;
370    }
371    let mut t = 2.0 * (big / eps).ln() / r;
372    let target = eps.ln();
373    for _ in 0..16 {
374        let h = (a + b * t).ln() - r * t - target;
375        let dh = b / (a + b * t) - r;
376        if dh.is_nan() || dh >= 0.0 {
377            // left of the peak and already under eps: under eps since 0
378            return if a <= eps { 0.0 } else { t };
379        }
380        let next = t - h / dh;
381        if !next.is_finite() || next >= t {
382            break;
383        }
384        let step = t - next;
385        t = next;
386        if step < 1e-9 * (1.0 + t) {
387            break;
388        }
389    }
390    t.max(0.0)
391}
392
393/// Replaces non-finite inputs: a bad target by the start (or 0), a bad start by the target, a bad
394/// velocity by 0.
395pub(crate) fn sane_state(x0: f32, v0: f32, target: f32) -> (f32, f32, f32) {
396    let target = if target.is_finite() {
397        target
398    } else if x0.is_finite() {
399        x0
400    } else {
401        0.0
402    };
403    let x0 = if x0.is_finite() { x0 } else { target };
404    let v0 = if v0.is_finite() { v0 } else { 0.0 };
405    (x0, v0, target)
406}
407
408/// `x` as an f32 when it is finite there, else `fallback`.
409pub(crate) fn finite_f32(x: f64, fallback: f32) -> f32 {
410    let y = x as f32;
411    if y.is_finite() { y } else { fallback }
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417
418    #[test]
419    fn constructor_clamps() {
420        assert_eq!(Spring::new(f32::NAN, f32::NAN), Spring::DEFAULT);
421        assert!(Spring::new(-1.0, 0.0).is_none());
422        assert!(Spring::new(f32::NEG_INFINITY, 0.0).is_none());
423        assert_eq!(Spring::new(f32::INFINITY, 2.0).duration(), Spring::MAX_DURATION);
424        assert_eq!(Spring::new(1e-9, -2.0).duration(), Spring::MIN_DURATION);
425        assert_eq!(Spring::new(1.0, -2.0).bounce(), -Spring::MAX_BOUNCE);
426        assert_eq!(Spring::from_response(0.5, 1.0), Spring::new(0.5, 0.0));
427        assert!((Spring::from_response(0.5, 0.7).bounce() - 0.3).abs() < 1e-6);
428        assert!((Spring::from_response(0.5, 2.0).damping_ratio() - 2.0).abs() < 1e-5);
429    }
430
431    #[test]
432    fn critical_has_no_overshoot() {
433        let s = Spring::new(0.4, 0.0);
434        let mut t = 0.0;
435        while t < 2.0 {
436            let (x, _) = s.sample(0.0, 0.0, 1.0, t);
437            assert!(x <= 1.0 + 1e-6, "{x} at {t}");
438            t += 0.001;
439        }
440    }
441
442    #[test]
443    fn linear_exp_root_is_last_crossing() {
444        for &(a, b, r) in &[(1.0, 10.0, 3.0), (0.0, 5.0, 1.0), (2.0, 0.0, 2.0), (1e-6, 1.0, 50.0)] {
445            let eps = 1e-3;
446            let t = linear_exp_root(a, b, r, eps);
447            let f = |t: f64| (a + b * t) * (-r * t).exp();
448            assert!(f(t) <= eps * (1.0 + 1e-9), "{a} {b} {r}");
449            let mut u = t;
450            while u < t + 20.0 {
451                assert!(f(u) <= eps * (1.0 + 1e-9));
452                u += 0.01;
453            }
454            assert!(f(t - 1e-3) > eps * 0.99 || t < 1e-3);
455        }
456    }
457}