1use crate::config::SpringConfig;
4use animato_core::Update;
5
6#[derive(Clone, Debug, PartialEq)]
8#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
9pub enum Integrator {
10 SemiImplicitEuler,
12 RungeKutta4,
14}
15
16#[derive(Clone, Debug)]
35#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
36pub struct Spring {
37 pub config: SpringConfig,
39 position: f32,
40 velocity: f32,
41 target: f32,
42 integrator: Integrator,
43 previous_displacement: f32,
44 overshoot_count: u32,
45}
46
47impl Spring {
48 pub fn new(config: SpringConfig) -> Self {
50 Self {
51 config,
52 position: 0.0,
53 velocity: 0.0,
54 target: 0.0,
55 integrator: Integrator::SemiImplicitEuler,
56 previous_displacement: 0.0,
57 overshoot_count: 0,
58 }
59 }
60
61 pub fn from_velocity(initial: f32, velocity: f32, target: f32, config: SpringConfig) -> Self {
66 let mut spring = Self::new(config);
67 spring.position = initial;
68 spring.velocity = if velocity.is_finite() { velocity } else { 0.0 };
69 spring.target = target;
70 spring.previous_displacement = initial - target;
71 spring
72 }
73
74 pub fn set_target(&mut self, target: f32) {
76 self.target = target;
77 self.previous_displacement = self.position - self.target;
78 self.overshoot_count = 0;
79 }
80
81 pub fn position(&self) -> f32 {
83 self.position
84 }
85
86 pub fn velocity(&self) -> f32 {
88 self.velocity
89 }
90
91 pub fn is_settled(&self) -> bool {
93 let eps = self.config.epsilon;
94 (self.position - self.target).abs() < eps && self.velocity.abs() < eps
95 }
96
97 pub fn energy(&self) -> f32 {
102 let displacement = self.position - self.target;
103 0.5 * self.config.mass * self.velocity * self.velocity
104 + 0.5 * self.config.stiffness * displacement * displacement
105 }
106
107 pub fn overshoot_count(&self) -> u32 {
109 self.overshoot_count
110 }
111
112 pub fn snap_to(&mut self, pos: f32) {
114 self.position = pos;
115 self.velocity = 0.0;
116 self.target = pos;
117 self.previous_displacement = 0.0;
118 self.overshoot_count = 0;
119 }
120
121 pub fn use_rk4(mut self, yes: bool) -> Self {
123 self.integrator = if yes {
124 Integrator::RungeKutta4
125 } else {
126 Integrator::SemiImplicitEuler
127 };
128 self
129 }
130
131 #[inline]
134 fn acceleration(&self, position: f32, velocity: f32) -> f32 {
135 let displacement = position - self.target;
136 let spring_force = -self.config.stiffness * displacement;
137 let damping_force = -self.config.damping * velocity;
138 (spring_force + damping_force) / self.config.mass
139 }
140
141 fn step_euler(&mut self, dt: f32) {
142 let acc = self.acceleration(self.position, self.velocity);
143 self.velocity += acc * dt;
144 self.position += self.velocity * dt;
145 }
146
147 fn step_rk4(&mut self, dt: f32) {
148 let p0 = self.position;
152 let v0 = self.velocity;
153
154 let k1v = self.acceleration(p0, v0);
155 let k1p = v0;
156
157 let k2v = self.acceleration(p0 + k1p * dt / 2.0, v0 + k1v * dt / 2.0);
158 let k2p = v0 + k1v * dt / 2.0;
159
160 let k3v = self.acceleration(p0 + k2p * dt / 2.0, v0 + k2v * dt / 2.0);
161 let k3p = v0 + k2v * dt / 2.0;
162
163 let k4v = self.acceleration(p0 + k3p * dt, v0 + k3v * dt);
164 let k4p = v0 + k3v * dt;
165
166 self.position += (dt / 6.0) * (k1p + 2.0 * k2p + 2.0 * k3p + k4p);
167 self.velocity += (dt / 6.0) * (k1v + 2.0 * k2v + 2.0 * k3v + k4v);
168 }
169
170 fn track_overshoot(&mut self) {
171 let displacement = self.position - self.target;
172 let eps = self.config.epsilon.max(0.0);
173 if self.previous_displacement.abs() > eps
174 && displacement.abs() > eps
175 && self.previous_displacement.signum() != displacement.signum()
176 {
177 self.overshoot_count = self.overshoot_count.saturating_add(1);
178 }
179 self.previous_displacement = displacement;
180 }
181}
182
183impl Update for Spring {
184 fn update(&mut self, dt: f32) -> bool {
189 let dt = dt.max(0.0);
190 if dt == 0.0 || self.is_settled() {
191 return !self.is_settled();
192 }
193 if self.config.stiffness <= 0.0 {
195 self.position = self.target;
196 self.velocity = 0.0;
197 return false;
198 }
199 match self.integrator {
200 Integrator::SemiImplicitEuler => self.step_euler(dt),
201 Integrator::RungeKutta4 => self.step_rk4(dt),
202 }
203 self.track_overshoot();
204 !self.is_settled()
205 }
206}
207
208#[cfg(test)]
213mod tests {
214 use super::*;
215
216 const DT: f32 = 1.0 / 60.0;
217 const MAX_STEPS: usize = 10_000;
218
219 fn run_to_settle(spring: &mut Spring) -> usize {
220 let mut steps = 0;
221 while !spring.is_settled() {
222 spring.update(DT);
223 steps += 1;
224 assert!(
225 steps < MAX_STEPS,
226 "Spring did not settle within {} steps",
227 MAX_STEPS
228 );
229 }
230 steps
231 }
232
233 #[test]
234 fn gentle_settles_to_target() {
235 let mut s = Spring::new(SpringConfig::gentle());
236 s.set_target(100.0);
237 run_to_settle(&mut s);
238 assert!((s.position() - 100.0).abs() < 0.01);
239 }
240
241 #[test]
242 fn wobbly_settles_to_target() {
243 let mut s = Spring::new(SpringConfig::wobbly());
244 s.set_target(50.0);
245 run_to_settle(&mut s);
246 assert!((s.position() - 50.0).abs() < 0.01);
247 }
248
249 #[test]
250 fn stiff_settles_to_target() {
251 let mut s = Spring::new(SpringConfig::stiff());
252 s.set_target(-30.0);
253 run_to_settle(&mut s);
254 assert!((s.position() - (-30.0)).abs() < 0.01);
255 }
256
257 #[test]
258 fn slow_settles_to_target() {
259 let mut s = Spring::new(SpringConfig::slow());
260 s.set_target(1.0);
261 run_to_settle(&mut s);
262 assert!((s.position() - 1.0).abs() < 0.01);
263 }
264
265 #[test]
266 fn snappy_settles_to_target() {
267 let mut s = Spring::new(SpringConfig::snappy());
268 s.set_target(200.0);
269 run_to_settle(&mut s);
270 assert!((s.position() - 200.0).abs() < 0.01);
271 }
272
273 #[test]
274 fn snappy_settles_faster_than_slow() {
275 let mut fast = Spring::new(SpringConfig::snappy());
276 fast.set_target(100.0);
277 let fast_steps = run_to_settle(&mut fast);
278
279 let mut slow = Spring::new(SpringConfig::slow());
280 slow.set_target(100.0);
281 let slow_steps = run_to_settle(&mut slow);
282
283 assert!(
284 fast_steps < slow_steps,
285 "snappy={} slow={}",
286 fast_steps,
287 slow_steps
288 );
289 }
290
291 #[test]
292 fn zero_damping_oscillates() {
293 let cfg = SpringConfig {
294 stiffness: 100.0,
295 damping: 0.0,
296 mass: 1.0,
297 epsilon: 0.001,
298 };
299 let mut s = Spring::new(cfg);
300 s.set_target(1.0);
301 for _ in 0..10_000 {
303 s.update(DT);
304 }
305 assert!(!s.is_settled());
306 }
307
308 #[test]
309 fn snap_to_teleports() {
310 let mut s = Spring::new(SpringConfig::default());
311 s.set_target(100.0);
312 s.snap_to(100.0);
313 assert_eq!(s.position(), 100.0);
314 assert_eq!(s.velocity(), 0.0);
315 assert!(s.is_settled());
316 }
317
318 #[test]
319 fn rk4_also_settles() {
320 let mut s = Spring::new(SpringConfig::wobbly()).use_rk4(true);
321 s.set_target(100.0);
322 run_to_settle(&mut s);
323 assert!((s.position() - 100.0).abs() < 0.01);
324 }
325
326 #[test]
327 fn zero_stiffness_snaps_immediately() {
328 let cfg = SpringConfig {
329 stiffness: 0.0,
330 ..SpringConfig::default()
331 };
332 let mut s = Spring::new(cfg);
333 s.set_target(42.0);
334 s.update(DT);
335 assert_eq!(s.position(), 42.0);
336 assert!(s.is_settled());
337 }
338
339 #[test]
340 fn negative_dt_is_noop() {
341 let mut s = Spring::new(SpringConfig::default());
342 s.set_target(100.0);
343 let pos_before = s.position();
344 s.update(-1.0);
345 assert_eq!(s.position(), pos_before);
346 }
347
348 #[test]
349 fn from_velocity_reaches_target_and_loses_energy() {
350 let mut s = Spring::from_velocity(0.0, 300.0, 100.0, SpringConfig::stiff());
351 let start_energy = s.energy();
352 run_to_settle(&mut s);
353 assert!((s.position() - 100.0).abs() < 0.01);
354 assert!(s.energy() < start_energy);
355 }
356
357 #[test]
358 fn damping_modes_order_damping() {
359 let critical = SpringConfig::critically_damped(100.0);
360 let over = SpringConfig::overdamped(100.0, 1.5);
361 let under = SpringConfig::underdamped(100.0, 0.5);
362 assert!(under.damping < critical.damping);
363 assert!(over.damping > critical.damping);
364 }
365
366 #[test]
367 fn overshoot_count_tracks_target_crossings() {
368 let mut s = Spring::from_velocity(0.0, 0.0, 1.0, SpringConfig::underdamped(120.0, 0.2));
369 for _ in 0..240 {
370 s.update(DT);
371 }
372 assert!(s.overshoot_count() > 0);
373 }
374}