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)]
181pub struct Spring<T: Animatable> {
182 from: T,
183 to: T,
184 current: T,
185 outputs: Vec<T>,
186 channels: Vec<SpringChannel>,
187 compose: Compositor<T>,
188 state: AnimationState,
189}
190
191impl<T: Animatable + fmt::Debug> fmt::Debug for Spring<T> {
192 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
193 formatter
194 .debug_struct("Spring")
195 .field("from", &self.from)
196 .field("to", &self.to)
197 .field("current", &self.current)
198 .field("channels", &self.channels)
199 .field("state", &self.state)
200 .finish_non_exhaustive()
201 }
202}
203
204impl<T: Animatable> Spring<T> {
205 #[must_use]
210 pub fn new(from: T, to: T, config: SpringConfig) -> Self {
211 Self::with_channels(from, to, [config], |outputs| outputs[0].clone())
212 }
213
214 #[must_use]
223 pub fn with_channels(
224 from: T,
225 to: T,
226 configs: impl IntoIterator<Item = SpringConfig>,
227 compose: impl Fn(&[T]) -> T + 'static,
228 ) -> Self {
229 let mut channels: Vec<_> = configs.into_iter().map(SpringChannel::new).collect();
230 if channels.is_empty() {
231 channels.push(SpringChannel::new(SpringConfig::default()));
232 }
233 let outputs = vec![from.clone(); channels.len()];
234
235 Self {
236 current: from.clone(),
237 from,
238 to,
239 outputs,
240 channels,
241 compose: Arc::new(compose),
242 state: AnimationState::Running,
243 }
244 }
245
246 #[must_use]
248 pub fn channel_count(&self) -> usize {
249 self.channels.len()
250 }
251
252 pub fn retarget(&mut self, target: T) {
257 self.from = self.current.clone();
258 self.to = target;
259 for (channel, output) in self.channels.iter_mut().zip(&mut self.outputs) {
260 channel.position = 0.0;
261 *output = self.from.clone();
262 }
263 self.state = AnimationState::Running;
264 }
265
266 fn sample(&mut self) {
267 for (channel, output) in self.channels.iter().zip(&mut self.outputs) {
268 *output = T::extrapolate(&self.from, &self.to, channel.position);
269 }
270 self.current = (self.compose)(&self.outputs);
271 }
272
273 fn integrate(&mut self, delta: Duration) {
274 let seconds = delta.as_secs();
275 for channel in &mut self.channels {
276 channel.advance(seconds);
277 }
278 self.sample();
279
280 if self.channels.iter().copied().all(SpringChannel::is_settled) {
281 self.finish();
282 }
283 }
284}
285
286impl<T: Animatable> Animation<T> for Spring<T> {
287 fn value(&self) -> &T {
288 &self.current
289 }
290
291 fn state(&self) -> AnimationState {
292 self.state
293 }
294
295 fn tick(&mut self, delta: Duration) {
296 if self.state == AnimationState::Running {
297 self.integrate(delta);
298 }
299 }
300
301 fn pause(&mut self) {
302 if self.state == AnimationState::Running {
303 self.state = AnimationState::Paused;
304 }
305 }
306
307 fn resume(&mut self) {
308 if self.state == AnimationState::Paused {
309 self.state = AnimationState::Running;
310 }
311 }
312
313 fn cancel(&mut self) {
314 if matches!(self.state, AnimationState::Running | AnimationState::Paused) {
315 self.state = AnimationState::Canceled;
316 }
317 }
318
319 fn seek(&mut self, progress: f32) {
320 let progress = if progress.is_nan() {
321 0.0
322 } else {
323 progress.clamp(0.0, 1.0)
324 };
325 for channel in &mut self.channels {
326 channel.reset_position(progress);
327 }
328 self.sample();
329 }
330
331 fn finish(&mut self) {
332 for (channel, output) in self.channels.iter_mut().zip(&mut self.outputs) {
333 channel.reset_position(1.0);
334 *output = self.to.clone();
335 }
336 self.current = self.to.clone();
337 self.state = AnimationState::Completed;
338 }
339
340 fn retarget(&mut self, target: &T) -> bool {
341 self.retarget(target.clone());
342 true
343 }
344
345 fn into_value(self: Box<Self>) -> T {
346 self.current
347 }
348}
349
350fn finite_non_negative(value: f32) -> f32 {
351 if value.is_finite() {
352 value.max(0.0)
353 } else {
354 0.0
355 }
356}
357
358fn underdamped(
359 displacement: f64,
360 velocity: f64,
361 decay: f64,
362 negative_discriminant: f64,
363 seconds: f64,
364) -> (f64, f64) {
365 let damped_frequency = negative_discriminant.sqrt();
366 let angle = damped_frequency * seconds;
367 let (sin, cos) = angle.sin_cos();
368 let exponential = (-decay * seconds).exp();
369 let sine_coefficient = (velocity + decay * displacement) / damped_frequency;
370 let next_displacement = exponential * displacement.mul_add(cos, sine_coefficient * sin);
371 let next_velocity = exponential
372 * ((-decay * displacement + damped_frequency * sine_coefficient) * cos
373 + (-decay * sine_coefficient - damped_frequency * displacement) * sin);
374
375 (next_displacement, next_velocity)
376}
377
378fn critically_damped(displacement: f64, velocity: f64, decay: f64, seconds: f64) -> (f64, f64) {
379 let linear_coefficient = velocity + decay * displacement;
380 let exponential = (-decay * seconds).exp();
381 let value = displacement + linear_coefficient * seconds;
382
383 (
384 value * exponential,
385 (linear_coefficient - decay * value) * exponential,
386 )
387}
388
389fn overdamped(
390 displacement: f64,
391 velocity: f64,
392 decay: f64,
393 discriminant: f64,
394 seconds: f64,
395) -> (f64, f64) {
396 let root = discriminant.sqrt();
397 let first_rate = -decay + root;
398 let second_rate = -decay - root;
399 let first_coefficient = (velocity - second_rate * displacement) / (first_rate - second_rate);
400 let second_coefficient = displacement - first_coefficient;
401 let first_term = first_coefficient * (first_rate * seconds).exp();
402 let second_term = second_coefficient * (second_rate * seconds).exp();
403
404 (
405 first_term + second_term,
406 first_rate * first_term + second_rate * second_term,
407 )
408}
409
410#[cfg(test)]
411mod tests {
412 use super::{Spring, SpringConfig};
413 use crate::{
414 timing::Duration,
415 traits::{Animation, AnimationState},
416 };
417 use float_cmp::assert_approx_eq;
418
419 #[test]
420 fn channels_apply_independent_physics_to_selected_fields() {
421 let slow = SpringConfig {
422 stiffness: 40.0,
423 damping: 14.0,
424 ..SpringConfig::default()
425 };
426 let fast = SpringConfig {
427 stiffness: 420.0,
428 damping: 28.0,
429 ..SpringConfig::default()
430 };
431 let mut spring = Spring::with_channels(
432 (0.0_f32, 0.0_f32),
433 (100.0, 100.0),
434 [slow, fast],
435 |outputs| (outputs[0].0, outputs[1].1),
436 );
437
438 spring.tick(Duration::from_millis(100.0));
439
440 assert_eq!(spring.channel_count(), 2);
441 assert!(spring.value().1 > spring.value().0 * 2.0);
442 }
443
444 #[test]
445 fn analytic_solution_preserves_full_delta() {
446 let config = SpringConfig::default();
447 let mut single_tick = Spring::new(0.0_f32, 1.0, config);
448 let mut divided_ticks = Spring::new(0.0_f32, 1.0, config);
449
450 single_tick.tick(Duration::from_millis(500.0));
451 for _ in 0..5 {
452 divided_ticks.tick(Duration::from_millis(100.0));
453 }
454
455 assert_approx_eq!(
456 f32,
457 *single_tick.value(),
458 *divided_ticks.value(),
459 epsilon = 0.000_01
460 );
461 assert!(*single_tick.value() > 0.9);
462 }
463
464 #[test]
465 fn completion_waits_for_every_channel() {
466 let fast = SpringConfig {
467 stiffness: 500.0,
468 damping: 50.0,
469 epsilon: 0.01,
470 ..SpringConfig::default()
471 };
472 let slow = SpringConfig {
473 stiffness: 20.0,
474 damping: 8.0,
475 epsilon: 0.000_1,
476 ..SpringConfig::default()
477 };
478 let mut spring =
479 Spring::with_channels((0.0_f32, 0.0_f32), (1.0, 1.0), [fast, slow], |outputs| {
480 (outputs[0].0, outputs[1].1)
481 });
482
483 spring.tick(Duration::from_millis(500.0));
484
485 assert_eq!(spring.state(), AnimationState::Running);
486 assert!((1.0 - spring.value().0).abs() < (1.0 - spring.value().1).abs());
487 }
488
489 #[test]
490 fn empty_channel_list_uses_default_channel() {
491 let mut spring =
492 Spring::with_channels(0.0_f32, 1.0, std::iter::empty(), |outputs| outputs[0]);
493
494 spring.tick(Duration::from_millis(16.0));
495
496 assert_eq!(spring.channel_count(), 1);
497 assert!(*spring.value() > 0.0);
498 }
499
500 #[test]
501 fn invalid_config_values_are_sanitized() {
502 let mut spring = Spring::new(
503 0.0_f32,
504 1.0,
505 SpringConfig {
506 stiffness: f32::NAN,
507 damping: f32::NEG_INFINITY,
508 mass: 0.0,
509 epsilon: f32::NAN,
510 },
511 );
512
513 spring.tick(Duration::from_millis(16.0));
514
515 assert!(spring.value().is_finite());
516 }
517
518 #[test]
519 fn snappy_preset_is_stiffer_than_default() {
520 let default = SpringConfig::default();
521 let snappy = SpringConfig::snappy();
522
523 assert!(snappy.stiffness > default.stiffness);
524 assert!(snappy.damping > default.damping);
525 assert_approx_eq!(f32, snappy.mass, default.mass);
526 assert_approx_eq!(f32, snappy.epsilon, default.epsilon);
527 }
528
529 #[test]
530 fn config_builders_set_physical_parameters() {
531 let config = SpringConfig::new(300.0, 29.0)
532 .with_mass(2.0)
533 .with_epsilon(0.01);
534
535 assert_approx_eq!(f32, config.stiffness, 300.0);
536 assert_approx_eq!(f32, config.damping, 29.0);
537 assert_approx_eq!(f32, config.mass, 2.0);
538 assert_approx_eq!(f32, config.epsilon, 0.01);
539 }
540
541 #[test]
542 fn finish_sets_all_channels_to_the_exact_target() {
543 let mut spring = Spring::with_channels(
544 (0.0_f32, 0.0_f32),
545 (10.0, 20.0),
546 [SpringConfig::default(), SpringConfig::default()],
547 |outputs| (outputs[0].0, outputs[1].1),
548 );
549
550 spring.finish();
551
552 assert_eq!(spring.state(), AnimationState::Completed);
553 assert_eq!(*spring.value(), (10.0, 20.0));
554 }
555}