1#[derive(Debug, Clone, Copy, PartialEq)]
9pub struct Spring {
10 pub stiffness: f32,
11 pub damping: f32,
12}
13
14impl Default for Spring {
15 fn default() -> Self {
16 Spring {
18 stiffness: 170.0,
19 damping: 22.0,
20 }
21 }
22}
23
24impl Spring {
25 pub fn new(stiffness: f32, damping: f32) -> Self {
26 Spring {
27 stiffness: stiffness.max(1.0),
28 damping: damping.max(0.0),
29 }
30 }
31
32 pub fn wobbly() -> Self {
34 Spring {
35 stiffness: 180.0,
36 damping: 12.0,
37 }
38 }
39
40 pub fn stiff() -> Self {
42 Spring {
43 stiffness: 210.0,
44 damping: 2.0 * 210.0_f32.sqrt(),
45 }
46 }
47
48 pub fn position(self, seconds: f32) -> f32 {
50 if seconds <= 0.0 {
51 return 0.0;
52 }
53 let w0 = self.stiffness.sqrt();
54 let zeta = self.damping / (2.0 * w0);
55 if zeta < 1.0 {
56 let wd = w0 * (1.0 - zeta * zeta).sqrt();
58 let decay = (-zeta * w0 * seconds).exp();
59 1.0 - decay * ((wd * seconds).cos() + (zeta * w0 / wd) * (wd * seconds).sin())
60 } else {
61 let decay = (-w0 * seconds).exp();
63 1.0 - decay * (1.0 + w0 * seconds)
64 }
65 }
66
67 pub fn settle_seconds(self) -> f32 {
70 let w0 = self.stiffness.sqrt();
71 let zeta = (self.damping / (2.0 * w0)).min(1.0);
72 let constants = 4.6 + 2.0 * zeta;
76 (constants / (zeta * w0).max(0.5)).min(10.0)
77 }
78
79 pub fn easing(self) -> impl Fn(f32) -> f32 + 'static {
81 let total = self.settle_seconds();
82 move |t: f32| {
83 if t >= 1.0 {
84 1.0
85 } else {
86 self.position(t * total)
87 }
88 }
89 }
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95
96 #[test]
97 fn starts_at_zero_ends_at_one() {
98 for spring in [Spring::default(), Spring::wobbly(), Spring::stiff()] {
99 let ease = spring.easing();
100 assert_eq!(ease(0.0), 0.0);
101 assert_eq!(ease(1.0), 1.0);
102 assert!((ease(0.999) - 1.0).abs() < 0.02);
103 }
104 }
105
106 #[test]
107 fn underdamped_overshoots() {
108 let ease = Spring::wobbly().easing();
109 let max = (0..=1000)
110 .map(|i| ease(i as f32 / 1000.0))
111 .fold(f32::MIN, f32::max);
112 assert!(max > 1.01, "wobbly spring should ring past 1, got {max}");
113 }
114
115 #[test]
116 fn critically_damped_never_crosses_one() {
117 let ease = Spring::stiff().easing();
118 for i in 0..=1000 {
119 assert!(ease(i as f32 / 1000.0) <= 1.0 + 1e-4);
120 }
121 }
122
123 #[test]
124 fn position_is_monotone_toward_target_early_on() {
125 let spring = Spring::default();
126 let quarter = spring.position(spring.settle_seconds() * 0.25);
127 let half = spring.position(spring.settle_seconds() * 0.5);
128 assert!(quarter > 0.1);
129 assert!(half >= quarter * 0.9);
130 }
131
132 #[test]
133 fn settle_time_is_sane() {
134 for spring in [Spring::default(), Spring::wobbly(), Spring::stiff()] {
135 let s = spring.settle_seconds();
136 assert!(s > 0.05 && s < 10.0, "settle {s}");
137 }
138 }
139}