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}