Skip to main content

i_slint_core/animations/simulations/
spring.rs

1// Copyright © Klarälvdalens Datakonsult AB, a KDAB Group company , info@kdab.com, author Robin Cramer <robin.cramer@kdab.com>
2// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
3
4// cSpell: ignore signum underdamped
5
6#[cfg(test)]
7use crate::animations::simulations::assert_approx_eq;
8
9#[cfg(not(feature = "std"))]
10use num_traits::Float;
11
12/// Converts a springs configuration into the `(natural_frequency, damping_ratio)` pair
13/// that the `SpringSimulation` solves the ODE with.
14pub trait SpringParameters {
15    /// Returns `(w_n, zeta)`
16    fn to_natural_frequency_and_damping_ratio(&self) -> (f32, f32);
17}
18
19/// `duration` decides the natural frequency and bounce decides the damping
20#[derive(Debug, Clone, Copy)]
21pub struct SpringDurationBounceParameters {
22    /// Fixes the spring's natural frequency, independent of `bounce`.
23    pub duration_secs: f32,
24    /// Expected range `-1.0..=1.0`, but not clamped here.
25    pub bounce: f32,
26}
27
28impl SpringDurationBounceParameters {
29    /// Creates new `duration`/`bounce`-style spring parameters.
30    pub fn new(duration_secs: f32, bounce: f32) -> Self {
31        Self { duration_secs, bounce }
32    }
33}
34
35impl SpringParameters for SpringDurationBounceParameters {
36    fn to_natural_frequency_and_damping_ratio(&self) -> (f32, f32) {
37        debug_assert!(self.duration_secs > 0., "duration must be greater than zero");
38        let w_n = 2. * core::f32::consts::PI / self.duration_secs;
39        let zeta = 1. - self.bounce;
40        (w_n, zeta)
41    }
42}
43
44/// `mass`/`stiffness`/`damping`-style spring configuration
45#[allow(dead_code)] // leaving in case a physical-spring curve is added
46#[derive(Debug, Clone, Copy)]
47pub struct SpringPhysicalParameters {
48    /// The mass attached to the spring
49    pub mass: f32,
50    /// The spring's stiffness (spring constant)
51    pub stiffness: f32,
52    /// The spring's damping coefficient
53    pub damping: f32,
54}
55
56impl SpringPhysicalParameters {
57    /// Creates new `mass`/`stiffness`/`damping`-style spring parameters.
58    #[allow(dead_code)] // leaving in case a physical-spring curve is added
59    pub fn new(mass: f32, stiffness: f32, damping: f32) -> Self {
60        Self { mass, stiffness, damping }
61    }
62}
63
64impl SpringParameters for SpringPhysicalParameters {
65    fn to_natural_frequency_and_damping_ratio(&self) -> (f32, f32) {
66        debug_assert!(self.mass > 0., "mass must be greater than zero");
67        debug_assert!(self.stiffness >= 0., "stiffness must not be negative");
68        let w_n = f32::sqrt(self.stiffness / self.mass);
69        let critical_damping = 2. * f32::sqrt(self.mass * self.stiffness);
70        let zeta = if critical_damping > 0. { self.damping / critical_damping } else { 0. };
71        (w_n, zeta)
72    }
73}
74
75/// Precomputed coefficients for a spring, one variant per damping regime
76/// All are relative to `target` (`x_rel = x - target`)
77/// `x_rel(0) = start_value - target`
78/// `vel(0) = initial_velocity`
79#[derive(Debug, Clone, Copy)]
80pub enum SpringRegime {
81    /// `zeta < 1`: oscillates while decaying. `x_rel(t) = e^(-zeta*w_n*t) * (c1*cos(w_d*t) + c2*sin(w_d*t))`
82    Underdamped { w_n: f32, zeta: f32, w_d: f32, c1: f32, c2: f32 },
83    /// `zeta == 1`: fastest non-oscillating approach. `x_rel(t) = (c1 + c2*t) * e^(-w_n*t)`
84    Critical { w_n: f32, c1: f32, c2: f32 },
85    /// `zeta > 1`: slow, non-oscillating approach. `x_rel(t) = c1*e^(r1*t) + c2*e^(r2*t)`
86    Overdamped { r1: f32, r2: f32, c1: f32, c2: f32 },
87}
88
89impl SpringRegime {
90    /// `zeta` values within this distance of `1.0` are treated as critically damped, to avoid
91    /// `w_d` (underdamped) or `sqrt(zeta^2 - 1)` (overdamped) blowing up near the boundary.
92    const CRITICAL_ZETA_EPSILON: f32 = 1e-3;
93
94    pub(crate) fn new(x0: f32, v0: f32, w_n: f32, zeta: f32) -> Self {
95        if (zeta - 1.).abs() < Self::CRITICAL_ZETA_EPSILON {
96            Self::Critical { w_n, c1: x0, c2: v0 + w_n * x0 }
97        } else if zeta < 1. {
98            let w_d = w_n * f32::sqrt(1. - zeta * zeta);
99            Self::Underdamped { w_n, zeta, w_d, c1: x0, c2: (v0 + zeta * w_n * x0) / w_d }
100        } else {
101            let disc = f32::sqrt(zeta * zeta - 1.);
102            let r1 = w_n * (-zeta + disc);
103            let r2 = w_n * (-zeta - disc);
104            let c1 = (v0 - r2 * x0) / (r1 - r2);
105            Self::Overdamped { r1, r2, c1, c2: x0 - c1 }
106        }
107    }
108
109    /// This regime's damping ratio
110    pub(crate) fn zeta(&self) -> f32 {
111        match *self {
112            Self::Underdamped { zeta, .. } => zeta,
113            Self::Critical { .. } => 1.0,
114            // Not stored in regime but doesn't matter in comparisons so return 1
115            // It is going to be less than 1 in reality
116            Self::Overdamped { .. } => 1.0,
117        }
118    }
119
120    /// Evaluates the closed form at elapsed time `t`, returning `(x_rel, vel)`.
121    pub(crate) fn evaluate(&self, t: f32) -> (f32, f32) {
122        match *self {
123            Self::Underdamped { w_n, zeta, w_d, c1, c2 } => {
124                let decay = f32::exp(-zeta * w_n * t);
125                let (s, c) = f32::sin_cos(w_d * t);
126                let pos = decay * (c1 * c + c2 * s);
127                let vel =
128                    decay * ((-zeta * w_n * c1 + w_d * c2) * c + (-zeta * w_n * c2 - w_d * c1) * s);
129                (pos, vel)
130            }
131            Self::Critical { w_n, c1, c2 } => {
132                let decay = f32::exp(-w_n * t);
133                let pos = decay * (c1 + c2 * t);
134                let vel = decay * (c2 - w_n * (c1 + c2 * t));
135                (pos, vel)
136            }
137            Self::Overdamped { r1, r2, c1, c2 } => {
138                let pos = c1 * f32::exp(r1 * t) + c2 * f32::exp(r2 * t);
139                let vel = c1 * r1 * f32::exp(r1 * t) + c2 * r2 * f32::exp(r2 * t);
140                (pos, vel)
141            }
142        }
143    }
144}
145
146#[cfg(test)]
147mod spring_regime_tests {
148    use super::*;
149
150    const W_N: f32 = 10.;
151    const X0: f32 = 5.;
152    const V0: f32 = -3.;
153
154    #[test]
155    fn regime_matches_initial_conditions() {
156        let regime = SpringRegime::new(X0, V0, W_N, 0.3);
157        let (pos, vel) = regime.evaluate(0.);
158        assert_approx_eq!(pos, X0);
159        assert_approx_eq!(vel, V0);
160
161        let regime = SpringRegime::new(X0, V0, W_N, 1.);
162        let (pos, vel) = regime.evaluate(0.);
163        assert_approx_eq!(pos, X0);
164        assert_approx_eq!(vel, V0);
165
166        let regime = SpringRegime::new(X0, V0, W_N, 1.8);
167        let (pos, vel) = regime.evaluate(0.);
168        assert_approx_eq!(pos, X0);
169        assert_approx_eq!(vel, V0);
170    }
171
172    #[test]
173    fn regime_decays_to_rest_over_time() {
174        let regime = SpringRegime::new(X0, V0, W_N, 0.3);
175        let (pos, vel) = regime.evaluate(10.);
176        assert_approx_eq!(pos, 0.);
177        assert_approx_eq!(vel, 0.);
178
179        let regime = SpringRegime::new(X0, V0, W_N, 1.);
180        let (pos, vel) = regime.evaluate(10.);
181        assert_approx_eq!(pos, 0.);
182        assert_approx_eq!(vel, 0.);
183
184        let regime = SpringRegime::new(X0, V0, W_N, 1.8);
185        let (pos, vel) = regime.evaluate(10.);
186        assert_approx_eq!(pos, 0.);
187        assert_approx_eq!(vel, 0.);
188    }
189
190    #[test]
191    fn undamped_oscillates_without_decay() {
192        // zeta == 0: pure oscillation, amplitude must be preserved over a full period
193        let regime = SpringRegime::new(X0, 0., W_N, 0.);
194        let period = 2. * core::f32::consts::PI / W_N;
195        let (pos, vel) = regime.evaluate(period);
196        assert_approx_eq!(pos, X0);
197        assert_approx_eq!(vel, 0.);
198
199        // Quarter period: position crosses zero, velocity is at its (negative) extreme
200        let (pos, vel) = regime.evaluate(period / 4.);
201        assert_approx_eq!(pos, 0.);
202        assert_approx_eq!(vel, -X0 * W_N);
203    }
204}