Skip to main content

egui_macview/motion/
mod.rs

1//! macOS CoreAnimation Spring Physics Engine.
2//!
3//! Models a physical damped harmonic oscillator identical to Apple's
4//! `CASpringAnimation` and SwiftUI `.spring(response:dampingRatio:)`.
5
6use serde::{Deserialize, Serialize};
7
8/// Authentic macOS CoreAnimation damped harmonic oscillator spring model.
9///
10/// Matches Apple's `CASpringAnimation` and SwiftUI `.spring(response:dampingRatio:)`.
11/// Provides exact closed-form analytical solutions for underdamped ($\zeta < 1$),
12/// critically damped ($\zeta = 1$), and overdamped ($\zeta > 1$) regimes without
13/// numerical Euler drift or floating-point overflow.
14///
15/// # Example
16///
17/// ```rust
18/// use egui_macview::CASpring;
19///
20/// // Create a responsive spring (0.35s period, 0.85 damping ratio)
21/// let spring = CASpring::new(0.35, 0.85);
22///
23/// // Evaluate position x(t) in [0.0, 1.0]
24/// let x_start = spring.evaluate(0.0);
25/// let x_mid = spring.evaluate(0.15);
26/// let x_settled = spring.evaluate(spring.settling_duration());
27///
28/// assert_eq!(x_start, 0.0);
29/// assert!(x_mid > 0.0 && x_mid < 1.1);
30/// assert!((x_settled - 1.0).abs() < 0.005);
31/// ```
32#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
33pub struct CASpring {
34    /// Perceptual oscillation period $T_0$ in seconds ($\ge 0.0001$).
35    pub response: f32,
36    /// Dimensionless damping ratio $\zeta$ ($\ge 0.0$).
37    /// - $\zeta < 1.0$: Underdamped (oscillates with decaying overshoot).
38    /// - $\zeta = 1.0$: Critically damped (fastest decay with zero overshoot).
39    /// - $\zeta > 1.0$: Overdamped (smooth monotonic decay without oscillation).
40    pub damping_ratio: f32,
41}
42
43impl Default for CASpring {
44    fn default() -> Self {
45        Self {
46            response: 0.35,
47            damping_ratio: 0.85,
48        }
49    }
50}
51
52impl CASpring {
53    /// Creates a new `CASpring` from cycle period `response` (in seconds)
54    /// and dimensionless `damping_ratio` ($\zeta$).
55    pub fn new(response: f32, damping_ratio: f32) -> Self {
56        Self {
57            response: response.max(0.0001),
58            damping_ratio: damping_ratio.max(0.0),
59        }
60    }
61
62    /// Creates a `CASpring` using Apple SwiftUI / AppKit perceptual parameters
63    /// `response` (in seconds) and `damping_ratio`.
64    #[inline]
65    pub fn from_perceptual(response: f32, damping_ratio: f32) -> Self {
66        Self::new(response, damping_ratio)
67    }
68
69    /// Creates a `CASpring` from undamped angular frequency $\omega_0$ (in rad/s)
70    /// and damping ratio $\zeta$.
71    pub fn from_frequency(omega_0: f32, damping_ratio: f32) -> Self {
72        let safe_omega = omega_0.max(0.0001);
73        let response = 2.0 * std::f32::consts::PI / safe_omega;
74        Self::new(response, damping_ratio)
75    }
76
77    /// Creates a `CASpring` from physical mass $m$, stiffness $k$, and damping $c$.
78    pub fn from_physical(mass: f32, stiffness: f32, damping: f32) -> Self {
79        let m = mass.max(0.0001);
80        let k = stiffness.max(0.0001);
81        let omega_0 = (k / m).sqrt();
82        let damping_ratio = damping / (2.0 * (k * m).sqrt());
83        Self::from_frequency(omega_0, damping_ratio)
84    }
85
86    /// Undamped natural angular frequency $\omega_0 = \frac{2\pi}{\text{response}}$ in rad/s.
87    #[inline]
88    pub fn omega_0(&self) -> f32 {
89        2.0 * std::f32::consts::PI / self.response
90    }
91
92    /// Damped angular frequency $\omega_d = \omega_0 \sqrt{|1 - \zeta^2|}$ in rad/s.
93    #[inline]
94    pub fn omega_d(&self) -> f32 {
95        let zeta = self.damping_ratio;
96        self.omega_0() * (1.0 - zeta * zeta).abs().sqrt()
97    }
98
99    /// Analytical settling duration $t_{\text{settle}} = \frac{-\ln(\epsilon)}{\zeta \omega_0} = \frac{6.907755}{\zeta \omega_0}$
100    /// for underdamped/critically damped oscillators ($\zeta \le 1$), and the dominant pole
101    /// $\frac{6.907755}{\zeta \omega_0 - \omega_d}$ for overdamped systems ($\zeta > 1$),
102    /// to within $\epsilon = 0.001$ (0.1%) of target resting state.
103    pub fn settling_duration(&self) -> f32 {
104        let zeta = self.damping_ratio;
105        if zeta <= 0.0 {
106            return f32::INFINITY;
107        }
108        let omega0 = self.omega_0();
109        if zeta <= 1.0 {
110            6.907755 / (zeta * omega0)
111        } else {
112            let alpha = self.omega_d();
113            let rate = (zeta * omega0 - alpha).max(0.0001);
114            6.907755 / rate
115        }
116    }
117
118    /// Evaluates displacement $x(t) \in [0.0, 1.0]$ at time $t$ in seconds.
119    #[inline]
120    pub fn position_at(&self, t: f32) -> f32 {
121        self.evaluate(t)
122    }
123
124    /// Theoretical peak overshoot ratio over resting state (0.0 if critically or overdamped).
125    pub fn overshoot_ratio(&self) -> f32 {
126        let zeta = self.damping_ratio;
127        if (0.0..1.0).contains(&zeta) {
128            (-std::f32::consts::PI * zeta / (1.0 - zeta * zeta).sqrt()).exp()
129        } else {
130            0.0
131        }
132    }
133
134    /// Evaluates displacement $x(t) \in [0.0, 1.0]$ at time $t$ in seconds.
135    pub fn evaluate(&self, t: f32) -> f32 {
136        if t <= 0.0 {
137            return 0.0;
138        }
139
140        let omega0 = self.omega_0();
141        let zeta = self.damping_ratio;
142
143        if zeta < 1.0 {
144            // Underdamped
145            let omega_d = self.omega_d();
146            let decay = (-zeta * omega0 * t).exp();
147            if decay == 0.0 {
148                1.0
149            } else {
150                let envelope =
151                    decay * ((omega_d * t).cos() + (zeta * omega0 / omega_d) * (omega_d * t).sin());
152                1.0 - envelope
153            }
154        } else if (zeta - 1.0).abs() < 1e-5 {
155            // Critically damped
156            let decay = (-omega0 * t).exp();
157            if decay == 0.0 {
158                1.0
159            } else {
160                1.0 - decay * (1.0 + omega0 * t)
161            }
162        } else {
163            // Overdamped: analytically rewritten using decaying exponentials
164            // to unconditionally prevent sinh/cosh floating-point overflow to infinity.
165            let alpha = self.omega_d();
166            let rate_minus = (zeta * omega0 - alpha).max(0.0);
167            let rate_plus = zeta * omega0 + alpha;
168            let exp_minus = (-rate_minus * t).exp();
169            let exp_plus = (-rate_plus * t).exp();
170            let c = zeta * omega0 / alpha;
171            let term = 0.5 * ((1.0 + c) * exp_minus + (1.0 - c) * exp_plus);
172            1.0 - term
173        }
174    }
175
176    /// Analytical velocity derivative $\dot{x}(t)$ at time $t$ in seconds.
177    pub fn velocity(&self, t: f32) -> f32 {
178        if t <= 0.0 {
179            return 0.0;
180        }
181
182        let omega0 = self.omega_0();
183        let zeta = self.damping_ratio;
184
185        if zeta < 1.0 {
186            // Underdamped
187            let omega_d = self.omega_d();
188            let decay = (-zeta * omega0 * t).exp();
189            if decay == 0.0 {
190                0.0
191            } else {
192                decay * (omega0 * omega0 / omega_d) * (omega_d * t).sin()
193            }
194        } else if (zeta - 1.0).abs() < 1e-5 {
195            // Critically damped
196            let decay = (-omega0 * t).exp();
197            if decay == 0.0 {
198                0.0
199            } else {
200                omega0 * omega0 * t * decay
201            }
202        } else {
203            // Overdamped
204            let alpha = self.omega_d();
205            let rate_minus = (zeta * omega0 - alpha).max(0.0);
206            let rate_plus = zeta * omega0 + alpha;
207            let exp_minus = (-rate_minus * t).exp();
208            let exp_plus = (-rate_plus * t).exp();
209            (omega0 * omega0 / (2.0 * alpha)) * (exp_minus - exp_plus)
210        }
211    }
212}