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 a stateful `CASpring` transition toward `target` using `egui` temporary memory,
119    /// requesting a repaint only while `(target - start_val).abs() > f32::EPSILON` and
120    /// `elapsed < self.settling_duration()`.
121    pub fn animate_f32(&self, ctx: &egui::Context, id: egui::Id, target: f32) -> f32 {
122        let now = ctx.input(|i| i.time);
123        self.animate_f32_at(ctx, id, target, now, false)
124    }
125
126    /// Evaluates a stateful `CASpring` transition toward `target` at explicit timestamp `now`,
127    /// performing zero spring math or `IdTypeMap` write locks when settled at rest.
128    pub fn animate_f32_at(
129        &self,
130        ctx: &egui::Context,
131        id: egui::Id,
132        target: f32,
133        now: f64,
134        frame_paced: bool,
135    ) -> f32 {
136        let stored = ctx.data(|d| d.get_temp::<(f32, f32, f64)>(id));
137        let Some((prev_target, start_val, start_time)) = stored else {
138            ctx.data_mut(|d| d.insert_temp(id, (target, target, now)));
139            return target;
140        };
141
142        let request_next_frame = |ctx: &egui::Context| {
143            if frame_paced {
144                ctx.request_repaint_after(std::time::Duration::from_millis(16));
145            } else {
146                ctx.request_repaint();
147            }
148        };
149
150        if (prev_target - target).abs() > f32::EPSILON {
151            let elapsed = (now - start_time).max(0.0) as f32;
152            let cur = if (prev_target - start_val).abs() > f32::EPSILON
153                && elapsed < self.settling_duration()
154            {
155                start_val + (prev_target - start_val) * self.position_at(elapsed)
156            } else {
157                prev_target
158            };
159            ctx.data_mut(|d| d.insert_temp(id, (target, cur, now)));
160            request_next_frame(ctx);
161            cur
162        } else {
163            let elapsed = (now - start_time).max(0.0) as f32;
164            if (target - start_val).abs() > f32::EPSILON && elapsed < self.settling_duration() {
165                request_next_frame(ctx);
166                start_val + (target - start_val) * self.position_at(elapsed)
167            } else {
168                if (target - start_val).abs() > f32::EPSILON {
169                    ctx.data_mut(|d| d.insert_temp(id, (target, target, now)));
170                }
171                target
172            }
173        }
174    }
175
176    /// Evaluates displacement $x(t) \in [0.0, 1.0]$ at time $t$ in seconds.
177    #[inline]
178    pub fn position_at(&self, t: f32) -> f32 {
179        self.evaluate(t)
180    }
181
182    /// Theoretical peak overshoot ratio over resting state (0.0 if critically or overdamped).
183    pub fn overshoot_ratio(&self) -> f32 {
184        let zeta = self.damping_ratio;
185        if (0.0..1.0).contains(&zeta) {
186            (-std::f32::consts::PI * zeta / (1.0 - zeta * zeta).sqrt()).exp()
187        } else {
188            0.0
189        }
190    }
191
192    /// Evaluates displacement $x(t) \in [0.0, 1.0]$ at time $t$ in seconds.
193    pub fn evaluate(&self, t: f32) -> f32 {
194        if t <= 0.0 {
195            return 0.0;
196        }
197
198        let omega0 = self.omega_0();
199        let zeta = self.damping_ratio;
200
201        if zeta < 1.0 {
202            // Underdamped
203            let omega_d = self.omega_d();
204            let decay = (-zeta * omega0 * t).exp();
205            if decay == 0.0 {
206                1.0
207            } else {
208                let envelope =
209                    decay * ((omega_d * t).cos() + (zeta * omega0 / omega_d) * (omega_d * t).sin());
210                1.0 - envelope
211            }
212        } else if (zeta - 1.0).abs() < 1e-5 {
213            // Critically damped
214            let decay = (-omega0 * t).exp();
215            if decay == 0.0 {
216                1.0
217            } else {
218                1.0 - decay * (1.0 + omega0 * t)
219            }
220        } else {
221            // Overdamped: analytically rewritten using decaying exponentials
222            // to unconditionally prevent sinh/cosh floating-point overflow to infinity.
223            let alpha = self.omega_d();
224            let rate_minus = (zeta * omega0 - alpha).max(0.0);
225            let rate_plus = zeta * omega0 + alpha;
226            let exp_minus = (-rate_minus * t).exp();
227            let exp_plus = (-rate_plus * t).exp();
228            let c = zeta * omega0 / alpha;
229            let term = 0.5 * ((1.0 + c) * exp_minus + (1.0 - c) * exp_plus);
230            1.0 - term
231        }
232    }
233
234    /// Analytical velocity derivative $\dot{x}(t)$ at time $t$ in seconds.
235    pub fn velocity(&self, t: f32) -> f32 {
236        if t <= 0.0 {
237            return 0.0;
238        }
239
240        let omega0 = self.omega_0();
241        let zeta = self.damping_ratio;
242
243        if zeta < 1.0 {
244            // Underdamped
245            let omega_d = self.omega_d();
246            let decay = (-zeta * omega0 * t).exp();
247            if decay == 0.0 {
248                0.0
249            } else {
250                decay * (omega0 * omega0 / omega_d) * (omega_d * t).sin()
251            }
252        } else if (zeta - 1.0).abs() < 1e-5 {
253            // Critically damped
254            let decay = (-omega0 * t).exp();
255            if decay == 0.0 {
256                0.0
257            } else {
258                omega0 * omega0 * t * decay
259            }
260        } else {
261            // Overdamped
262            let alpha = self.omega_d();
263            let rate_minus = (zeta * omega0 - alpha).max(0.0);
264            let rate_plus = zeta * omega0 + alpha;
265            let exp_minus = (-rate_minus * t).exp();
266            let exp_plus = (-rate_plus * t).exp();
267            (omega0 * omega0 / (2.0 * alpha)) * (exp_minus - exp_plus)
268        }
269    }
270}