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