Skip to main content

motion_core/animation/
spring.rs

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