Skip to main content

motion_core/animation/
spring.rs

1//! Closed-form damped spring response and conservative tick-based settling time.
2
3use super::Animation;
4use crate::{Duration, Interpolate, TICKS_PER_SECOND, Time};
5
6/// A finite physical spring between arbitrary interpolatable values.
7///
8/// Initial velocity is in endpoint distances per second. The default mass is one,
9/// stiffness 100, damping 10 and initial velocity zero. Duration is the conservative
10/// displacement-envelope settling time at threshold 0.001, rounded up to ticks.
11/// Values at/before zero and at/after duration are exact stored endpoints.
12///
13/// Builders validate the complete candidate configuration immediately. Nonfinite
14/// or nonpositive physical parameters, an unrepresentable response or settling
15/// time, and invalid thresholds panic. Zero damping is rejected because this type
16/// promises a finite settling duration. Use a signal for undamped oscillation.
17///
18/// ```
19/// use motion_core::prelude::*;
20/// use motion_core::{
21///     DisplayList, RasterLimits, ResourcePool, ValidatedDisplayList, MAX_PATH_VERBS,
22///     MAX_STROKE_VERBS, MAX_SVG_PATH_BYTES, sample_offsets,
23/// };
24/// let movement = Spring::new(Point::ORIGIN, Point::new(100.0, 40.0))
25///     .damping(20.0)
26///     .settling_threshold(0.0001);
27/// let end = movement.duration().unwrap();
28/// assert_eq!(movement.sample(end), Point::new(100.0, 40.0));
29/// assert_eq!(movement.sample((-1).seconds()), Point::ORIGIN);
30/// ```
31#[derive(Clone, Debug)]
32pub struct Spring<Value> {
33    from: Value,
34    to: Value,
35    parameters: Parameters,
36    response: Response,
37}
38
39impl<Value> Spring<Value> {
40    /// Creates a spring with the default physical parameters and settling threshold.
41    pub fn new(from: Value, to: Value) -> Self {
42        let parameters = Parameters::default();
43        Self {
44            from,
45            to,
46            parameters,
47            response: Response::new(parameters),
48        }
49    }
50
51    /// Sets finite positive mass and recomputes the finite response immediately.
52    pub fn mass(mut self, mass: f64) -> Self {
53        self.parameters.mass = mass;
54        self.rebuild()
55    }
56
57    /// Sets finite positive stiffness and recomputes the response immediately.
58    pub fn stiffness(mut self, stiffness: f64) -> Self {
59        self.parameters.stiffness = stiffness;
60        self.rebuild()
61    }
62
63    /// Sets finite strictly positive damping. Zero has no finite settling time.
64    pub fn damping(mut self, damping: f64) -> Self {
65        self.parameters.damping = damping;
66        self.rebuild()
67    }
68
69    /// Sets finite normalized velocity towards the target; negative moves away.
70    /// Units are endpoint distances per second, not pixels per second.
71    pub fn initial_velocity(mut self, velocity: f64) -> Self {
72        self.parameters.velocity = velocity;
73        self.rebuild()
74    }
75
76    /// Sets a finite relative displacement-envelope threshold strictly between
77    /// zero and one. This is not a pixel tolerance or a velocity threshold.
78    pub fn settling_threshold(mut self, threshold: f64) -> Self {
79        self.parameters.threshold = threshold;
80        self.rebuild()
81    }
82
83    fn rebuild(mut self) -> Self {
84        self.response = Response::new(self.parameters);
85        self
86    }
87}
88
89impl Default for Spring<f64> {
90    fn default() -> Self {
91        Self::new(0.0, 1.0)
92    }
93}
94
95impl<Value: Interpolate + Send + Sync> Animation<Value> for Spring<Value> {
96    fn sample(&self, time: Time) -> Value {
97        if time <= Time::ZERO {
98            return self.from.clone();
99        }
100        if time >= self.response.duration {
101            return self.to.clone();
102        }
103        self.from
104            .interpolate(&self.to, self.response.progress(time.as_seconds()))
105    }
106
107    fn duration(&self) -> Option<Duration> {
108        Some(self.response.duration)
109    }
110}
111
112#[derive(Clone, Copy, Debug)]
113struct Parameters {
114    mass: f64,
115    stiffness: f64,
116    damping: f64,
117    velocity: f64,
118    threshold: f64,
119}
120
121impl Default for Parameters {
122    fn default() -> Self {
123        Self {
124            mass: 1.0,
125            stiffness: 100.0,
126            damping: 10.0,
127            velocity: 0.0,
128            threshold: 0.001,
129        }
130    }
131}
132
133#[derive(Clone, Copy, Debug)]
134enum Regime {
135    Under { oscillation: f64 },
136    Critical,
137    Over { separation: f64 },
138}
139
140#[derive(Clone, Copy, Debug)]
141struct Response {
142    regime: Regime,
143    rate: f64,
144    velocity: f64,
145    duration: Duration,
146}
147
148impl Response {
149    fn new(parameters: Parameters) -> Self {
150        assert!(
151            parameters.mass.is_finite() && parameters.mass > 0.0,
152            "spring mass must be finite and positive"
153        );
154        assert!(
155            parameters.stiffness.is_finite() && parameters.stiffness > 0.0,
156            "spring stiffness must be finite and positive"
157        );
158        assert!(
159            parameters.damping.is_finite() && parameters.damping > 0.0,
160            "finite spring damping must be finite and positive"
161        );
162        assert!(
163            parameters.velocity.is_finite(),
164            "spring velocity must be finite"
165        );
166        assert!(
167            parameters.threshold.is_finite()
168                && parameters.threshold > 0.0
169                && parameters.threshold < 1.0,
170            "spring settling threshold must be finite and between zero and one"
171        );
172        let frequency = libm::sqrt(parameters.stiffness) / libm::sqrt(parameters.mass);
173        let quotient = parameters.damping / parameters.mass;
174        let decay = if quotient.is_finite() {
175            quotient * 0.5
176        } else {
177            (parameters.damping * 0.5) / parameters.mass
178        };
179        assert!(
180            frequency.is_finite() && frequency > 0.0 && decay.is_finite() && decay > 0.0,
181            "spring derived rates must be finite and positive"
182        );
183        let (regime, rate) = if decay < frequency {
184            let oscillation =
185                frequency * libm::sqrt((frequency - decay) / frequency * (1.0 + decay / frequency));
186            assert!(
187                oscillation.is_finite() && oscillation > 0.0,
188                "spring oscillation must be representable"
189            );
190            (Regime::Under { oscillation }, decay)
191        } else if decay == frequency {
192            (Regime::Critical, decay)
193        } else {
194            let root = decay * libm::sqrt((decay - frequency) / decay * (1.0 + frequency / decay));
195            let separation = 2.0 * root;
196            let slow = (frequency / decay) * frequency / (1.0 + root / decay);
197            assert!(
198                separation.is_finite() && separation > 0.0 && slow.is_finite() && slow > 0.0,
199                "spring characteristic rates must be representable"
200            );
201            (Regime::Over { separation }, slow)
202        };
203        let velocity = parameters.velocity / rate;
204        assert!(
205            velocity.is_finite(),
206            "spring normalized velocity must be representable"
207        );
208        let duration = settling_duration(rate, (1.0 - velocity).abs(), parameters.threshold);
209        if let Regime::Under { oscillation } = regime {
210            assert!(
211                (oscillation * duration.as_seconds()).is_finite(),
212                "spring phase must be representable"
213            );
214        }
215        Self {
216            regime,
217            rate,
218            velocity,
219            duration,
220        }
221    }
222
223    fn progress(self, seconds: f64) -> f64 {
224        let elapsed = self.rate * seconds;
225        if elapsed.is_infinite() {
226            return 1.0;
227        }
228        let decay = libm::exp(-elapsed);
229        let weighted_time = elapsed * decay;
230        let base = critical_progress(elapsed, decay);
231        let progress = match self.regime {
232            Regime::Critical => base + self.velocity * weighted_time,
233            Regime::Under { oscillation } => {
234                let angle = oscillation * seconds;
235                let (sinc, remainder) = sine_ratio(angle);
236                let half_sine = libm::sin(angle * 0.5);
237                base + weighted_time * remainder
238                    + decay * (2.0 * half_sine * half_sine)
239                    + self.velocity * weighted_time * sinc
240            }
241            Regime::Over { separation } => {
242                let (ratio, remainder) = exponential_ratio(separation * seconds);
243                base + weighted_time * remainder + self.velocity * weighted_time * ratio
244            }
245        };
246        assert!(progress.is_finite(), "spring response must be finite");
247        progress
248    }
249}
250
251fn critical_progress(elapsed: f64, decay: f64) -> f64 {
252    if elapsed < 0.001 {
253        elapsed
254            * elapsed
255            * (0.5
256                + elapsed
257                    * (-1.0 / 3.0 + elapsed * (0.125 + elapsed * (-1.0 / 30.0 + elapsed / 144.0))))
258    } else {
259        -libm::expm1(-elapsed) - elapsed * decay
260    }
261}
262
263fn sine_ratio(angle: f64) -> (f64, f64) {
264    if angle.abs() < 0.001 {
265        let square = angle * angle;
266        let remainder = square * (1.0 / 6.0 + square * (-1.0 / 120.0 + square / 5_040.0));
267        (1.0 - remainder, remainder)
268    } else {
269        let ratio = libm::sin(angle) / angle;
270        (ratio, 1.0 - ratio)
271    }
272}
273
274fn exponential_ratio(elapsed: f64) -> (f64, f64) {
275    if elapsed.is_infinite() {
276        (0.0, 1.0)
277    } else if elapsed < 0.001 {
278        let remainder = elapsed
279            * (0.5
280                + elapsed
281                    * (-1.0 / 6.0
282                        + elapsed * (1.0 / 24.0 + elapsed * (-1.0 / 120.0 + elapsed / 720.0))));
283        (1.0 - remainder, remainder)
284    } else {
285        let ratio = -libm::expm1(-elapsed) / elapsed;
286        (ratio, 1.0 - ratio)
287    }
288}
289
290fn log_envelope(elapsed: f64, slope: f64) -> f64 {
291    if elapsed == 0.0 {
292        return 0.0;
293    }
294    if slope == 0.0 {
295        return -elapsed;
296    }
297    let product_log = libm::log(slope) + libm::log(elapsed);
298    let envelope_log = if product_log > 0.0 {
299        product_log + libm::log1p(libm::exp(-product_log))
300    } else {
301        libm::log1p(libm::exp(product_log))
302    };
303    envelope_log - elapsed
304}
305
306fn settling_duration(rate: f64, slope: f64, threshold: f64) -> Duration {
307    let target = libm::log(threshold);
308    let mut lower = 0.0;
309    let mut upper = 1.0;
310    while log_envelope(upper, slope) > target {
311        upper *= 2.0;
312    }
313    for _ in 0..80 {
314        let middle = lower + (upper - lower) * 0.5;
315        if middle == lower || middle == upper {
316            break;
317        }
318        if log_envelope(middle, slope) > target {
319            lower = middle;
320        } else {
321            upper = middle;
322        }
323    }
324    let seconds = (upper * (1.0 + 32.0 * f64::EPSILON)) / rate;
325    let ticks = libm::ceil(seconds * TICKS_PER_SECOND as f64).max(1.0);
326    assert!(
327        ticks.is_finite() && ticks < -(i64::MIN as f64),
328        "spring settling duration exceeds the signed tick range"
329    );
330    Time::from_ticks(ticks as i64)
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    use crate::{Spring, TimeLiteral};
337
338    fn close(actual: f64, expected: f64) {
339        assert!(actual.is_finite() && expected.is_finite());
340        assert!(
341            (actual - expected).abs() <= 2e-12 * expected.abs().max(1e-12),
342            "{actual} != {expected}"
343        );
344    }
345
346    #[test]
347    fn all_damping_regimes_match_analytic_references_and_retain_first_tick_motion() {
348        for damping in [10.0, 20.0, 40.0] {
349            let response = Response::new(Parameters {
350                damping,
351                ..Parameters::default()
352            });
353            let decay = damping * 0.5;
354            for seconds in [0.01, 0.1, 0.25, 0.5] {
355                let expected = if damping < 20.0 {
356                    let oscillation = libm::sqrt(100.0 - decay * decay);
357                    1.0 - libm::exp(-decay * seconds)
358                        * (libm::cos(oscillation * seconds)
359                            + decay / oscillation * libm::sin(oscillation * seconds))
360                } else if damping == 20.0 {
361                    1.0 - libm::exp(-decay * seconds) * (1.0 + decay * seconds)
362                } else {
363                    let root = libm::sqrt(decay * decay - 100.0);
364                    1.0 - libm::exp(-decay * seconds)
365                        * (libm::cosh(root * seconds) + decay / root * libm::sinh(root * seconds))
366                };
367                close(response.progress(seconds), expected);
368            }
369            let tick = 1.0 / TICKS_PER_SECOND as f64;
370            let expected = 50.0 * tick * tick;
371            assert!((response.progress(tick) / expected - 1.0).abs() < 1e-6);
372        }
373    }
374
375    #[test]
376    fn settling_envelope_stays_below_threshold_across_regimes_and_large_velocities() {
377        for damping in [0.1, 10.0, 20.0 - 1e-8, 20.0, 20.0 + 1e-8, 40.0, 1e8] {
378            for velocity in [-4.0, 0.0, 20.0, 1e300] {
379                let parameters = Parameters {
380                    damping,
381                    velocity,
382                    ..Parameters::default()
383                };
384                let response = Response::new(parameters);
385                assert!(response.duration > Time::ZERO);
386                for factor in [1.0, 1.1, 2.0, 10.0] {
387                    let seconds = response.duration.as_seconds() * factor;
388                    let error = (1.0 - response.progress(seconds)).abs();
389                    assert!(
390                        error <= parameters.threshold * (1.0 + 1e-10),
391                        "damping={damping}, velocity={velocity}, error={error}"
392                    );
393                    assert!(
394                        log_envelope(response.rate * seconds, (1.0 - response.velocity).abs())
395                            <= libm::log(parameters.threshold) + 1e-12
396                    );
397                }
398            }
399        }
400    }
401
402    #[test]
403    fn initial_velocity_and_mass_scaling_have_physical_units() {
404        let tick = Time::from_ticks(1);
405        for velocity in [-3.0, 4.0] {
406            let spring = Spring::new(0.0_f64, 1.0).initial_velocity(velocity);
407            assert!((spring.sample(tick) / tick.as_seconds() - velocity).abs() < 1e-6);
408        }
409        let base = Spring::new(0.0_f64, 1.0);
410        let scaled = Spring::new(0.0_f64, 1.0)
411            .mass(2.0)
412            .stiffness(200.0)
413            .damping(20.0);
414        for time in [100.milliseconds(), 300.milliseconds(), 500.milliseconds()] {
415            close(base.sample(time), scaled.sample(time));
416        }
417        assert!((base.duration().unwrap().ticks() - scaled.duration().unwrap().ticks()).abs() <= 1);
418    }
419
420    #[test]
421    fn near_critical_responses_and_durations_converge_without_coefficient_blowup() {
422        let critical = Spring::new(0.0_f64, 1.0).damping(20.0);
423        for damping in [20.0 - 1e-10, 20.0 + 1e-10] {
424            let nearby = Spring::new(0.0_f64, 1.0).damping(damping);
425            for time in [10.milliseconds(), 100.milliseconds(), 500.milliseconds()] {
426                assert!((nearby.sample(time) - critical.sample(time)).abs() < 1e-11);
427            }
428            assert!(
429                (nearby.duration().unwrap().as_seconds()
430                    / critical.duration().unwrap().as_seconds()
431                    - 1.0)
432                    .abs()
433                    < 1e-4
434            );
435        }
436    }
437
438    #[test]
439    fn default_threshold_and_random_order_sampling_pin_exact_endpoints() {
440        let spring = Spring::default();
441        let end = spring.duration().unwrap();
442        assert!(end > Time::ZERO);
443        assert_eq!(spring.sample(Time::from_ticks(i64::MIN)), 0.0);
444        assert_eq!(spring.sample(Time::ZERO), 0.0);
445        assert_eq!(spring.sample(end), 1.0);
446        assert_eq!(spring.sample(Time::from_ticks(i64::MAX)), 1.0);
447        let expected = spring.sample(300.milliseconds());
448        assert!(spring.sample(500.milliseconds()) > 1.0);
449        for time in [
450            end,
451            Time::ZERO,
452            300.milliseconds(),
453            100.milliseconds(),
454            300.milliseconds(),
455        ] {
456            assert_eq!(spring.sample(time), spring.clone().sample(time));
457            if time == 300.milliseconds() {
458                assert_eq!(spring.sample(time), expected);
459            }
460        }
461        let tighter = spring.clone().settling_threshold(1e-6);
462        assert!(tighter.duration().unwrap() > end);
463        let smallest = spring.settling_threshold(f64::from_bits(1));
464        let response = smallest.response;
465        assert!(
466            log_envelope(
467                response.rate * response.duration.as_seconds(),
468                (1.0 - response.velocity).abs()
469            ) <= libm::log(f64::from_bits(1))
470        );
471    }
472
473    #[test]
474    fn invalid_physical_parameters_and_unrepresentable_durations_fail_while_building() {
475        for value in [f64::NAN, f64::INFINITY, -1.0, 0.0] {
476            assert!(std::panic::catch_unwind(|| Spring::default().mass(value)).is_err());
477            assert!(std::panic::catch_unwind(|| Spring::default().stiffness(value)).is_err());
478            assert!(std::panic::catch_unwind(|| Spring::default().damping(value)).is_err());
479        }
480        for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
481            assert!(
482                std::panic::catch_unwind(|| Spring::default().initial_velocity(value)).is_err()
483            );
484        }
485        for value in [0.0, 1.0, -1.0, f64::NAN, f64::INFINITY] {
486            assert!(
487                std::panic::catch_unwind(|| Spring::default().settling_threshold(value)).is_err()
488            );
489        }
490        assert!(std::panic::catch_unwind(|| Spring::default().damping(1e-15)).is_err());
491        assert!(std::panic::catch_unwind(|| Spring::default().stiffness(1e-30)).is_err());
492        assert!(std::panic::catch_unwind(|| Spring::default().mass(f64::from_bits(1))).is_err());
493    }
494}