1use crate::config::SpringConfig;
4use animato_core::Update;
5
6#[derive(Clone, Debug, PartialEq)]
8pub enum Integrator {
9 SemiImplicitEuler,
11 RungeKutta4,
13}
14
15#[derive(Clone, Debug)]
34pub struct Spring {
35 pub config: SpringConfig,
37 position: f32,
38 velocity: f32,
39 target: f32,
40 integrator: Integrator,
41}
42
43impl Spring {
44 pub fn new(config: SpringConfig) -> Self {
46 Self {
47 config,
48 position: 0.0,
49 velocity: 0.0,
50 target: 0.0,
51 integrator: Integrator::SemiImplicitEuler,
52 }
53 }
54
55 pub fn set_target(&mut self, target: f32) {
57 self.target = target;
58 }
59
60 pub fn position(&self) -> f32 {
62 self.position
63 }
64
65 pub fn velocity(&self) -> f32 {
67 self.velocity
68 }
69
70 pub fn is_settled(&self) -> bool {
72 let eps = self.config.epsilon;
73 (self.position - self.target).abs() < eps && self.velocity.abs() < eps
74 }
75
76 pub fn snap_to(&mut self, pos: f32) {
78 self.position = pos;
79 self.velocity = 0.0;
80 }
81
82 pub fn use_rk4(mut self, yes: bool) -> Self {
84 self.integrator = if yes {
85 Integrator::RungeKutta4
86 } else {
87 Integrator::SemiImplicitEuler
88 };
89 self
90 }
91
92 #[inline]
95 fn acceleration(&self, position: f32, velocity: f32) -> f32 {
96 let displacement = position - self.target;
97 let spring_force = -self.config.stiffness * displacement;
98 let damping_force = -self.config.damping * velocity;
99 (spring_force + damping_force) / self.config.mass
100 }
101
102 fn step_euler(&mut self, dt: f32) {
103 let acc = self.acceleration(self.position, self.velocity);
104 self.velocity += acc * dt;
105 self.position += self.velocity * dt;
106 }
107
108 fn step_rk4(&mut self, dt: f32) {
109 let p0 = self.position;
113 let v0 = self.velocity;
114
115 let k1v = self.acceleration(p0, v0);
116 let k1p = v0;
117
118 let k2v = self.acceleration(p0 + k1p * dt / 2.0, v0 + k1v * dt / 2.0);
119 let k2p = v0 + k1v * dt / 2.0;
120
121 let k3v = self.acceleration(p0 + k2p * dt / 2.0, v0 + k2v * dt / 2.0);
122 let k3p = v0 + k2v * dt / 2.0;
123
124 let k4v = self.acceleration(p0 + k3p * dt, v0 + k3v * dt);
125 let k4p = v0 + k3v * dt;
126
127 self.position += (dt / 6.0) * (k1p + 2.0 * k2p + 2.0 * k3p + k4p);
128 self.velocity += (dt / 6.0) * (k1v + 2.0 * k2v + 2.0 * k3v + k4v);
129 }
130}
131
132impl Update for Spring {
133 fn update(&mut self, dt: f32) -> bool {
138 let dt = dt.max(0.0);
139 if dt == 0.0 || self.is_settled() {
140 return !self.is_settled();
141 }
142 if self.config.stiffness <= 0.0 {
144 self.position = self.target;
145 self.velocity = 0.0;
146 return false;
147 }
148 match self.integrator {
149 Integrator::SemiImplicitEuler => self.step_euler(dt),
150 Integrator::RungeKutta4 => self.step_rk4(dt),
151 }
152 !self.is_settled()
153 }
154}
155
156#[cfg(test)]
161mod tests {
162 use super::*;
163
164 const DT: f32 = 1.0 / 60.0;
165 const MAX_STEPS: usize = 10_000;
166
167 fn run_to_settle(spring: &mut Spring) -> usize {
168 let mut steps = 0;
169 while !spring.is_settled() {
170 spring.update(DT);
171 steps += 1;
172 assert!(
173 steps < MAX_STEPS,
174 "Spring did not settle within {} steps",
175 MAX_STEPS
176 );
177 }
178 steps
179 }
180
181 #[test]
182 fn gentle_settles_to_target() {
183 let mut s = Spring::new(SpringConfig::gentle());
184 s.set_target(100.0);
185 run_to_settle(&mut s);
186 assert!((s.position() - 100.0).abs() < 0.01);
187 }
188
189 #[test]
190 fn wobbly_settles_to_target() {
191 let mut s = Spring::new(SpringConfig::wobbly());
192 s.set_target(50.0);
193 run_to_settle(&mut s);
194 assert!((s.position() - 50.0).abs() < 0.01);
195 }
196
197 #[test]
198 fn stiff_settles_to_target() {
199 let mut s = Spring::new(SpringConfig::stiff());
200 s.set_target(-30.0);
201 run_to_settle(&mut s);
202 assert!((s.position() - (-30.0)).abs() < 0.01);
203 }
204
205 #[test]
206 fn slow_settles_to_target() {
207 let mut s = Spring::new(SpringConfig::slow());
208 s.set_target(1.0);
209 run_to_settle(&mut s);
210 assert!((s.position() - 1.0).abs() < 0.01);
211 }
212
213 #[test]
214 fn snappy_settles_to_target() {
215 let mut s = Spring::new(SpringConfig::snappy());
216 s.set_target(200.0);
217 run_to_settle(&mut s);
218 assert!((s.position() - 200.0).abs() < 0.01);
219 }
220
221 #[test]
222 fn snappy_settles_faster_than_slow() {
223 let mut fast = Spring::new(SpringConfig::snappy());
224 fast.set_target(100.0);
225 let fast_steps = run_to_settle(&mut fast);
226
227 let mut slow = Spring::new(SpringConfig::slow());
228 slow.set_target(100.0);
229 let slow_steps = run_to_settle(&mut slow);
230
231 assert!(
232 fast_steps < slow_steps,
233 "snappy={} slow={}",
234 fast_steps,
235 slow_steps
236 );
237 }
238
239 #[test]
240 fn zero_damping_oscillates() {
241 let cfg = SpringConfig {
242 stiffness: 100.0,
243 damping: 0.0,
244 mass: 1.0,
245 epsilon: 0.001,
246 };
247 let mut s = Spring::new(cfg);
248 s.set_target(1.0);
249 for _ in 0..10_000 {
251 s.update(DT);
252 }
253 assert!(!s.is_settled());
254 }
255
256 #[test]
257 fn snap_to_teleports() {
258 let mut s = Spring::new(SpringConfig::default());
259 s.set_target(100.0);
260 s.snap_to(100.0);
261 assert_eq!(s.position(), 100.0);
262 assert_eq!(s.velocity(), 0.0);
263 assert!(s.is_settled());
264 }
265
266 #[test]
267 fn rk4_also_settles() {
268 let mut s = Spring::new(SpringConfig::wobbly()).use_rk4(true);
269 s.set_target(100.0);
270 run_to_settle(&mut s);
271 assert!((s.position() - 100.0).abs() < 0.01);
272 }
273
274 #[test]
275 fn zero_stiffness_snaps_immediately() {
276 let cfg = SpringConfig {
277 stiffness: 0.0,
278 ..SpringConfig::default()
279 };
280 let mut s = Spring::new(cfg);
281 s.set_target(42.0);
282 s.update(DT);
283 assert_eq!(s.position(), 42.0);
284 assert!(s.is_settled());
285 }
286
287 #[test]
288 fn negative_dt_is_noop() {
289 let mut s = Spring::new(SpringConfig::default());
290 s.set_target(100.0);
291 let pos_before = s.position();
292 s.update(-1.0);
293 assert_eq!(s.position(), pos_before);
294 }
295}