1use std::{fmt, sync::Arc};
4
5use crate::{
6 timing::Duration,
7 traits::{Animatable, Animation, AnimationState},
8};
9
10#[derive(Debug, Clone, Copy, PartialEq)]
16pub struct SpringConfig {
17 pub stiffness: f32,
19 pub damping: f32,
21 pub mass: f32,
23 pub epsilon: f32,
25}
26
27impl SpringConfig {
28 #[must_use]
30 pub const fn new(stiffness: f32, damping: f32) -> Self {
31 Self {
32 stiffness,
33 damping,
34 mass: 1.0,
35 epsilon: 0.001,
36 }
37 }
38
39 #[must_use]
41 pub const fn with_mass(mut self, mass: f32) -> Self {
42 self.mass = mass;
43 self
44 }
45
46 #[must_use]
48 pub const fn with_epsilon(mut self, epsilon: f32) -> Self {
49 self.epsilon = epsilon;
50 self
51 }
52
53 #[must_use]
55 pub const fn snappy() -> Self {
56 Self::new(420.0, 28.0)
57 }
58
59 fn sanitized(self) -> Self {
60 let defaults = Self::default();
61 Self {
62 stiffness: finite_non_negative(self.stiffness),
63 damping: finite_non_negative(self.damping),
64 mass: if self.mass.is_finite() && self.mass > 0.0 {
65 self.mass
66 } else {
67 f32::EPSILON
68 },
69 epsilon: if self.epsilon.is_finite() && self.epsilon > 0.0 {
70 self.epsilon
71 } else {
72 defaults.epsilon
73 },
74 }
75 }
76}
77
78impl Default for SpringConfig {
79 fn default() -> Self {
80 Self::new(220.0, 24.0)
81 }
82}
83
84#[derive(Debug, Clone, Copy)]
85struct SpringChannel {
86 position: f32,
87 velocity: f32,
88 config: SpringConfig,
89}
90
91impl SpringChannel {
92 fn new(config: SpringConfig) -> Self {
93 Self {
94 position: 0.0,
95 velocity: 0.0,
96 config: config.sanitized(),
97 }
98 }
99
100 fn advance(&mut self, seconds: f64) {
101 if seconds <= 0.0 {
102 return;
103 }
104
105 let position = f64::from(self.position);
106 let velocity = f64::from(self.velocity);
107 let stiffness = f64::from(self.config.stiffness);
108 let damping = f64::from(self.config.damping);
109 let mass = f64::from(self.config.mass);
110 let displacement = position - 1.0;
111 let decay = damping / (2.0 * mass);
112 let natural_frequency_squared = stiffness / mass;
113 let discriminant = decay.mul_add(decay, -natural_frequency_squared);
114 let tolerance = natural_frequency_squared.max(1.0) * 1.0e-10;
115
116 let (next_displacement, next_velocity) = if discriminant < -tolerance {
117 underdamped(displacement, velocity, decay, -discriminant, seconds)
118 } else if discriminant > tolerance {
119 overdamped(displacement, velocity, decay, discriminant, seconds)
120 } else {
121 critically_damped(displacement, velocity, decay, seconds)
122 };
123
124 #[allow(
125 clippy::cast_possible_truncation,
126 reason = "Spring state is stored as f32 to match interpolation progress."
127 )]
128 {
129 self.position = (next_displacement + 1.0) as f32;
130 self.velocity = next_velocity as f32;
131 }
132 }
133
134 fn is_settled(self) -> bool {
135 (1.0 - self.position).abs() <= self.config.epsilon
136 && self.velocity.abs() <= self.config.epsilon
137 }
138
139 fn reset_position(&mut self, position: f32) {
140 self.position = position;
141 self.velocity = 0.0;
142 }
143}
144
145type Compositor<T> = Arc<dyn Fn(&[T]) -> T>;
146
147#[derive(Clone)]
180pub struct Spring<T: Animatable> {
181 from: T,
182 to: T,
183 current: T,
184 outputs: Vec<T>,
185 channels: Vec<SpringChannel>,
186 compose: Compositor<T>,
187 state: AnimationState,
188}
189
190impl<T: Animatable + fmt::Debug> fmt::Debug for Spring<T> {
191 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
192 formatter
193 .debug_struct("Spring")
194 .field("from", &self.from)
195 .field("to", &self.to)
196 .field("current", &self.current)
197 .field("channels", &self.channels)
198 .field("state", &self.state)
199 .finish_non_exhaustive()
200 }
201}
202
203impl<T: Animatable> Spring<T> {
204 #[must_use]
209 pub fn new(from: T, to: T, config: SpringConfig) -> Self {
210 Self::with_channels(from, to, [config], |outputs| outputs[0].clone())
211 }
212
213 #[must_use]
222 pub fn with_channels(
223 from: T,
224 to: T,
225 configs: impl IntoIterator<Item = SpringConfig>,
226 compose: impl Fn(&[T]) -> T + 'static,
227 ) -> Self {
228 let mut channels: Vec<_> = configs.into_iter().map(SpringChannel::new).collect();
229 if channels.is_empty() {
230 channels.push(SpringChannel::new(SpringConfig::default()));
231 }
232 let outputs = vec![from.clone(); channels.len()];
233
234 Self {
235 current: from.clone(),
236 from,
237 to,
238 outputs,
239 channels,
240 compose: Arc::new(compose),
241 state: AnimationState::Running,
242 }
243 }
244
245 #[must_use]
247 pub fn channel_count(&self) -> usize {
248 self.channels.len()
249 }
250
251 pub fn retarget(&mut self, target: T) {
256 self.from = self.current.clone();
257 self.to = target;
258 for (channel, output) in self.channels.iter_mut().zip(&mut self.outputs) {
259 channel.position = 0.0;
260 *output = self.from.clone();
261 }
262 self.state = AnimationState::Running;
263 }
264
265 fn sample(&mut self) {
266 for (channel, output) in self.channels.iter().zip(&mut self.outputs) {
267 *output = T::extrapolate(&self.from, &self.to, channel.position);
268 }
269 self.current = (self.compose)(&self.outputs);
270 }
271
272 fn integrate(&mut self, delta: Duration) {
273 let seconds = delta.as_secs();
274 for channel in &mut self.channels {
275 channel.advance(seconds);
276 }
277 self.sample();
278
279 if self.channels.iter().copied().all(SpringChannel::is_settled) {
280 self.finish();
281 }
282 }
283}
284
285impl<T: Animatable> Animation<T> for Spring<T> {
286 fn value(&self) -> &T {
287 &self.current
288 }
289
290 fn state(&self) -> AnimationState {
291 self.state
292 }
293
294 fn tick(&mut self, delta: Duration) {
295 if self.state == AnimationState::Running {
296 self.integrate(delta);
297 }
298 }
299
300 fn pause(&mut self) {
301 if self.state == AnimationState::Running {
302 self.state = AnimationState::Paused;
303 }
304 }
305
306 fn resume(&mut self) {
307 if self.state == AnimationState::Paused {
308 self.state = AnimationState::Running;
309 }
310 }
311
312 fn cancel(&mut self) {
313 if matches!(self.state, AnimationState::Running | AnimationState::Paused) {
314 self.state = AnimationState::Canceled;
315 }
316 }
317
318 fn seek(&mut self, progress: f32) {
319 let progress = if progress.is_nan() {
320 0.0
321 } else {
322 progress.clamp(0.0, 1.0)
323 };
324 for channel in &mut self.channels {
325 channel.reset_position(progress);
326 }
327 self.sample();
328 }
329
330 fn finish(&mut self) {
331 for (channel, output) in self.channels.iter_mut().zip(&mut self.outputs) {
332 channel.reset_position(1.0);
333 *output = self.to.clone();
334 }
335 self.current = self.to.clone();
336 self.state = AnimationState::Completed;
337 }
338
339 fn retarget(&mut self, target: &T) -> bool {
340 self.retarget(target.clone());
341 true
342 }
343
344 fn into_value(self: Box<Self>) -> T {
345 self.current
346 }
347}
348
349fn finite_non_negative(value: f32) -> f32 {
350 if value.is_finite() {
351 value.max(0.0)
352 } else {
353 0.0
354 }
355}
356
357fn underdamped(
358 displacement: f64,
359 velocity: f64,
360 decay: f64,
361 negative_discriminant: f64,
362 seconds: f64,
363) -> (f64, f64) {
364 let damped_frequency = negative_discriminant.sqrt();
365 let angle = damped_frequency * seconds;
366 let (sin, cos) = angle.sin_cos();
367 let exponential = (-decay * seconds).exp();
368 let sine_coefficient = (velocity + decay * displacement) / damped_frequency;
369 let next_displacement = exponential * displacement.mul_add(cos, sine_coefficient * sin);
370 let next_velocity = exponential
371 * ((-decay * displacement + damped_frequency * sine_coefficient) * cos
372 + (-decay * sine_coefficient - damped_frequency * displacement) * sin);
373
374 (next_displacement, next_velocity)
375}
376
377fn critically_damped(displacement: f64, velocity: f64, decay: f64, seconds: f64) -> (f64, f64) {
378 let linear_coefficient = velocity + decay * displacement;
379 let exponential = (-decay * seconds).exp();
380 let value = displacement + linear_coefficient * seconds;
381
382 (
383 value * exponential,
384 (linear_coefficient - decay * value) * exponential,
385 )
386}
387
388fn overdamped(
389 displacement: f64,
390 velocity: f64,
391 decay: f64,
392 discriminant: f64,
393 seconds: f64,
394) -> (f64, f64) {
395 let root = discriminant.sqrt();
396 let first_rate = -decay + root;
397 let second_rate = -decay - root;
398 let first_coefficient = (velocity - second_rate * displacement) / (first_rate - second_rate);
399 let second_coefficient = displacement - first_coefficient;
400 let first_term = first_coefficient * (first_rate * seconds).exp();
401 let second_term = second_coefficient * (second_rate * seconds).exp();
402
403 (
404 first_term + second_term,
405 first_rate * first_term + second_rate * second_term,
406 )
407}
408
409#[cfg(test)]
410mod tests {
411 use super::{Spring, SpringConfig};
412 use crate::{Animation, AnimationState, timing::Duration};
413 use float_cmp::assert_approx_eq;
414
415 #[test]
416 fn channels_apply_independent_physics_to_selected_fields() {
417 let slow = SpringConfig {
418 stiffness: 40.0,
419 damping: 14.0,
420 ..SpringConfig::default()
421 };
422 let fast = SpringConfig {
423 stiffness: 420.0,
424 damping: 28.0,
425 ..SpringConfig::default()
426 };
427 let mut spring = Spring::with_channels(
428 (0.0_f32, 0.0_f32),
429 (100.0, 100.0),
430 [slow, fast],
431 |outputs| (outputs[0].0, outputs[1].1),
432 );
433
434 spring.tick(Duration::from_millis(100.0));
435
436 assert_eq!(spring.channel_count(), 2);
437 assert!(spring.value().1 > spring.value().0 * 2.0);
438 }
439
440 #[test]
441 fn analytic_solution_preserves_full_delta() {
442 let config = SpringConfig::default();
443 let mut single_tick = Spring::new(0.0_f32, 1.0, config);
444 let mut divided_ticks = Spring::new(0.0_f32, 1.0, config);
445
446 single_tick.tick(Duration::from_millis(500.0));
447 for _ in 0..5 {
448 divided_ticks.tick(Duration::from_millis(100.0));
449 }
450
451 assert_approx_eq!(
452 f32,
453 *single_tick.value(),
454 *divided_ticks.value(),
455 epsilon = 0.000_01
456 );
457 assert!(*single_tick.value() > 0.9);
458 }
459
460 #[test]
461 fn completion_waits_for_every_channel() {
462 let fast = SpringConfig {
463 stiffness: 500.0,
464 damping: 50.0,
465 epsilon: 0.01,
466 ..SpringConfig::default()
467 };
468 let slow = SpringConfig {
469 stiffness: 20.0,
470 damping: 8.0,
471 epsilon: 0.000_1,
472 ..SpringConfig::default()
473 };
474 let mut spring =
475 Spring::with_channels((0.0_f32, 0.0_f32), (1.0, 1.0), [fast, slow], |outputs| {
476 (outputs[0].0, outputs[1].1)
477 });
478
479 spring.tick(Duration::from_millis(500.0));
480
481 assert_eq!(spring.state(), AnimationState::Running);
482 assert!((1.0 - spring.value().0).abs() < (1.0 - spring.value().1).abs());
483 }
484
485 #[test]
486 fn empty_channel_list_uses_default_channel() {
487 let mut spring =
488 Spring::with_channels(0.0_f32, 1.0, std::iter::empty(), |outputs| outputs[0]);
489
490 spring.tick(Duration::from_millis(16.0));
491
492 assert_eq!(spring.channel_count(), 1);
493 assert!(*spring.value() > 0.0);
494 }
495
496 #[test]
497 fn invalid_config_values_are_sanitized() {
498 let mut spring = Spring::new(
499 0.0_f32,
500 1.0,
501 SpringConfig {
502 stiffness: f32::NAN,
503 damping: f32::NEG_INFINITY,
504 mass: 0.0,
505 epsilon: f32::NAN,
506 },
507 );
508
509 spring.tick(Duration::from_millis(16.0));
510
511 assert!(spring.value().is_finite());
512 }
513
514 #[test]
515 fn snappy_preset_is_stiffer_than_default() {
516 let default = SpringConfig::default();
517 let snappy = SpringConfig::snappy();
518
519 assert!(snappy.stiffness > default.stiffness);
520 assert!(snappy.damping > default.damping);
521 assert_approx_eq!(f32, snappy.mass, default.mass);
522 assert_approx_eq!(f32, snappy.epsilon, default.epsilon);
523 }
524
525 #[test]
526 fn config_builders_set_physical_parameters() {
527 let config = SpringConfig::new(300.0, 29.0)
528 .with_mass(2.0)
529 .with_epsilon(0.01);
530
531 assert_approx_eq!(f32, config.stiffness, 300.0);
532 assert_approx_eq!(f32, config.damping, 29.0);
533 assert_approx_eq!(f32, config.mass, 2.0);
534 assert_approx_eq!(f32, config.epsilon, 0.01);
535 }
536
537 #[test]
538 fn finish_sets_all_channels_to_the_exact_target() {
539 let mut spring = Spring::with_channels(
540 (0.0_f32, 0.0_f32),
541 (10.0, 20.0),
542 [SpringConfig::default(), SpringConfig::default()],
543 |outputs| (outputs[0].0, outputs[1].1),
544 );
545
546 spring.finish();
547
548 assert_eq!(spring.state(), AnimationState::Completed);
549 assert_eq!(*spring.value(), (10.0, 20.0));
550 }
551}