Skip to main content

sequoia_animation/
spring_value.rs

1use crate::Spring;
2use sequoia_core::Point;
3
4const SETTLE_EPS: f32 = 1e-3;
5
6#[derive(Clone, Copy, Debug, PartialEq)]
7pub struct SpringValue {
8    value: f32,
9    target: f32,
10    velocity: f32,
11    spring: Spring,
12}
13
14impl SpringValue {
15    pub fn new(value: f32, spring: Spring) -> Self {
16        Self {
17            value,
18            target: value,
19            velocity: 0.0,
20            spring,
21        }
22    }
23
24    pub fn with_default_spring(value: f32) -> Self {
25        Self::new(value, Spring::new(0.9, 6.0))
26    }
27
28    pub fn value(&self) -> f32 {
29        self.value
30    }
31
32    pub fn target(&self) -> f32 {
33        self.target
34    }
35
36    pub fn velocity(&self) -> f32 {
37        self.velocity
38    }
39
40    pub fn spring(&self) -> Spring {
41        self.spring
42    }
43
44    pub fn is_settled(&self) -> bool {
45        (self.value - self.target).abs() <= SETTLE_EPS && self.velocity.abs() <= SETTLE_EPS
46    }
47
48    pub fn set_target(&mut self, target: f32) {
49        self.target = target;
50        if self.is_settled() {
51            self.value = target;
52            self.velocity = 0.0;
53        }
54    }
55
56    pub fn jump_to(&mut self, value: f32) {
57        self.value = value;
58        self.target = value;
59        self.velocity = 0.0;
60    }
61
62    pub fn reset_value(&mut self, value: f32) {
63        self.value = value;
64        self.velocity = 0.0;
65    }
66
67    pub fn set_spring(&mut self, spring: Spring) {
68        self.spring = spring;
69    }
70
71    pub fn nudge(&mut self, velocity: f32) {
72        self.velocity += velocity;
73    }
74
75    pub fn advance(&mut self, dt: f32) -> bool {
76        let dt = dt.clamp(0.0, 0.25);
77        if dt <= 0.0 {
78            return !self.is_settled();
79        }
80        let w0 = std::f32::consts::TAU * self.spring.frequency.max(0.0001);
81        let k = w0 * w0;
82        let c = 2.0 * self.spring.damping_ratio.max(0.0) * w0;
83
84        let max_step = 1.0 / (w0 * 4.0).max(60.0);
85        let steps = (dt / max_step).ceil().max(1.0) as usize;
86        let h = dt / steps as f32;
87        for _ in 0..steps {
88            let accel = -k * (self.value - self.target) - c * self.velocity;
89            self.velocity += accel * h;
90            self.value += self.velocity * h;
91        }
92        if self.is_settled() {
93            self.value = self.target;
94            self.velocity = 0.0;
95            false
96        } else {
97            true
98        }
99    }
100}
101
102impl Default for SpringValue {
103    fn default() -> Self {
104        Self::with_default_spring(0.0)
105    }
106}
107
108#[derive(Clone, Copy, Debug, PartialEq)]
109pub struct SpringPoint {
110    pub x: SpringValue,
111    pub y: SpringValue,
112}
113
114impl SpringPoint {
115    pub fn new(x: f32, y: f32, spring: Spring) -> Self {
116        Self {
117            x: SpringValue::new(x, spring),
118            y: SpringValue::new(y, spring),
119        }
120    }
121
122    pub fn with_default_spring(x: f32, y: f32) -> Self {
123        Self {
124            x: SpringValue::with_default_spring(x),
125            y: SpringValue::with_default_spring(y),
126        }
127    }
128
129    pub fn point(&self) -> Point {
130        Point::new(self.x.value(), self.y.value())
131    }
132
133    pub fn set_target(&mut self, x: f32, y: f32) {
134        self.x.set_target(x);
135        self.y.set_target(y);
136    }
137
138    pub fn jump_to(&mut self, x: f32, y: f32) {
139        self.x.jump_to(x);
140        self.y.jump_to(y);
141    }
142
143    pub fn set_spring(&mut self, spring: Spring) {
144        self.x.set_spring(spring);
145        self.y.set_spring(spring);
146    }
147
148    pub fn is_settled(&self) -> bool {
149        self.x.is_settled() && self.y.is_settled()
150    }
151
152    pub fn advance(&mut self, dt: f32) -> bool {
153        let flying_x = self.x.advance(dt);
154        let flying_y = self.y.advance(dt);
155        flying_x || flying_y
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn chases_target_then_snaps_exactly() {
165        let mut v = SpringValue::new(0.0, Spring::new(0.8, 5.0));
166        v.set_target(100.0);
167        assert_eq!(v.value(), 0.0, "set_target does not teleport");
168        assert!(v.advance(1.0 / 60.0), "still flying");
169        assert!(v.value() > 0.0 && v.value() < 100.0);
170        let mut frames = 1;
171        while v.advance(1.0 / 60.0) {
172            frames += 1;
173            assert!(frames < 10_000, "spring must settle");
174        }
175        assert_eq!(v.value(), 100.0, "settles exactly on target");
176        assert_eq!(v.velocity(), 0.0);
177        assert!(v.is_settled());
178    }
179
180    #[test]
181    fn retarget_keeps_momentum_and_does_not_jump() {
182        let mut v = SpringValue::new(0.0, Spring::new(0.9, 3.0));
183        v.set_target(100.0);
184        for _ in 0..3 {
185            v.advance(1.0 / 60.0);
186        }
187        let mid = v.value();
188        let vel = v.velocity();
189        assert!(mid > 0.0 && mid < 100.0, "in flight: {mid}");
190        assert!(vel > 0.0, "moving forward");
191
192        v.set_target(0.0);
193        assert_eq!(v.value(), mid, "no teleport on retarget");
194        assert_eq!(v.velocity(), vel, "velocity preserved on retarget");
195
196        for _ in 0..600 {
197            v.advance(1.0 / 120.0);
198        }
199        assert!(v.is_settled());
200        assert_eq!(v.value(), 0.0);
201    }
202
203    #[test]
204    fn overdamped_never_overshoots() {
205        let mut v = SpringValue::new(0.0, Spring::new(1.6, 6.0));
206        v.set_target(50.0);
207        let mut peak = 0.0f32;
208        for _ in 0..600 {
209            v.advance(1.0 / 120.0);
210            peak = peak.max(v.value());
211        }
212        assert!(peak <= 50.0 + 1e-3, "over-damped overshoot: {peak}");
213    }
214
215    #[test]
216    fn underdamped_overshoots() {
217        let mut v = SpringValue::new(0.0, Spring::new(0.25, 4.0));
218        v.set_target(50.0);
219        let mut peak = 0.0f32;
220        for _ in 0..600 {
221            v.advance(1.0 / 120.0);
222            peak = peak.max(v.value());
223        }
224        assert!(peak > 52.0, "under-damped should overshoot: {peak}");
225    }
226
227    #[test]
228    fn low_frame_rate_stays_stable() {
229        let mut v = SpringValue::new(0.0, Spring::new(0.5, 10.0));
230        v.set_target(10.0);
231        for _ in 0..60 {
232            v.advance(0.05);
233            assert!(v.value().is_finite());
234            assert!(v.value() < 100.0, "stable integration: {}", v.value());
235        }
236        assert!(v.is_settled() || (v.value() - 10.0).abs() < 1.0);
237    }
238
239    #[test]
240    fn zero_dt_does_not_move() {
241        let mut v = SpringValue::new(0.0, Spring::new(0.7, 6.0));
242        v.set_target(10.0);
243        v.advance(0.0);
244        assert_eq!(v.value(), 0.0);
245    }
246
247    #[test]
248    fn jump_is_instant_and_no_op_retarget_is_clean() {
249        let mut v = SpringValue::new(0.0, Spring::new(0.7, 6.0));
250        v.jump_to(3.0);
251        assert_eq!(v.value(), 3.0);
252        assert!(v.is_settled());
253        v.set_target(3.0);
254        assert!(v.is_settled());
255        assert_eq!(v.value(), 3.0);
256    }
257
258    #[test]
259    fn nudge_adds_velocity() {
260        let mut v = SpringValue::new(0.0, Spring::new(0.7, 6.0));
261        v.nudge(120.0);
262        v.advance(1.0 / 60.0);
263        assert!(v.value() > 0.0, "nudge pushes the value");
264    }
265
266    #[test]
267    fn spring_point_animates_both_axes() {
268        let mut p = SpringPoint::with_default_spring(0.0, 0.0);
269        p.set_target(10.0, -20.0);
270        for _ in 0..600 {
271            p.advance(1.0 / 120.0);
272        }
273        assert!(p.is_settled());
274        assert_eq!(p.point(), Point::new(10.0, -20.0));
275    }
276}