Skip to main content

sequoia_animation/
spring_machine.rs

1use sequoia_core::Point;
2
3use crate::Spring;
4
5#[derive(Clone, Debug, PartialEq)]
6#[non_exhaustive]
7pub enum AnimValue {
8    Static(f32),
9
10    Spring { target: f32, spring: Spring },
11}
12
13impl AnimValue {
14    pub fn static_px(v: f32) -> Self {
15        Self::Static(v)
16    }
17
18    pub fn spring(target: f32, damping_ratio: f32, frequency: f32) -> Self {
19        Self::Spring {
20            target,
21            spring: Spring::new(damping_ratio, frequency),
22        }
23    }
24
25    pub fn target(&self) -> f32 {
26        match self {
27            Self::Static(v) | Self::Spring { target: v, .. } => *v,
28        }
29    }
30
31    pub fn is_spring(&self) -> bool {
32        matches!(self, Self::Spring { .. })
33    }
34}
35
36#[derive(Clone, Debug, PartialEq)]
37pub struct StateDef {
38    pub name: String,
39
40    pub x: AnimValue,
41
42    pub y: AnimValue,
43}
44
45impl StateDef {
46    pub fn new(name: &str, x: AnimValue, y: AnimValue) -> Self {
47        Self {
48            name: name.to_string(),
49            x,
50            y,
51        }
52    }
53}
54
55#[derive(Clone, Copy, Debug, PartialEq, Eq)]
56#[non_exhaustive]
57pub enum Trigger {
58    Load,
59}
60
61#[derive(Clone, Debug, PartialEq)]
62pub struct TransitionDef {
63    pub from: String,
64
65    pub to: String,
66
67    pub trigger: Trigger,
68}
69
70impl TransitionDef {
71    pub fn new(from: &str, to: &str, trigger: Trigger) -> Self {
72        Self {
73            from: from.to_string(),
74            to: to.to_string(),
75            trigger,
76        }
77    }
78}
79
80#[derive(Clone, Copy, Debug)]
81struct Flight {
82    from: usize,
83    to: usize,
84
85    elapsed: f32,
86}
87
88#[derive(Clone, Debug)]
89pub struct SpringMachine {
90    states: Vec<StateDef>,
91    transitions: Vec<TransitionDef>,
92
93    current: usize,
94
95    active: Option<Flight>,
96
97    fired: bool,
98
99    pos: Point,
100}
101
102impl SpringMachine {
103    pub fn new(states: Vec<StateDef>, transitions: Vec<TransitionDef>) -> Self {
104        let pos = states
105            .first()
106            .map(|s| Point::new(s.x.target(), s.y.target()))
107            .unwrap_or_default();
108        Self {
109            states,
110            transitions,
111            current: 0,
112            active: None,
113            fired: false,
114            pos,
115        }
116    }
117
118    pub fn states(&self) -> &[StateDef] {
119        &self.states
120    }
121
122    pub fn current_name(&self) -> &str {
123        self.states
124            .get(self.current)
125            .map(|s| s.name.as_str())
126            .unwrap_or("")
127    }
128
129    fn find_state(&self, name: &str) -> Option<usize> {
130        self.states.iter().position(|s| s.name == name)
131    }
132
133    pub fn advance(&mut self, dt: f64) -> bool {
134        let dt = dt.max(0.0) as f32;
135        if self.states.is_empty() {
136            self.pos = Point::default();
137            return false;
138        }
139        if !self.fired {
140            self.fired = true;
141            self.active = self.fire_load();
142        }
143        let running = match self.active.as_mut() {
144            None => false,
145            Some(f) => {
146                f.elapsed += dt;
147                if Self::settled(&self.states[f.to], f.elapsed) {
148                    self.current = f.to;
149                    self.active = None;
150                    false
151                } else {
152                    true
153                }
154            }
155        };
156        self.pos = self.offset_at();
157        running
158    }
159
160    fn fire_load(&mut self) -> Option<Flight> {
161        let tr = self
162            .transitions
163            .iter()
164            .find(|t| t.trigger == Trigger::Load)?;
165        let (from, to) = (self.find_state(&tr.from)?, self.find_state(&tr.to)?);
166        self.current = from;
167        let target = &self.states[to];
168        if matches!(target.x, AnimValue::Static(_)) && matches!(target.y, AnimValue::Static(_)) {
169            self.current = to;
170            self.pos = Point::new(target.x.target(), target.y.target());
171            return None;
172        }
173        Some(Flight {
174            from,
175            to,
176            elapsed: 0.0,
177        })
178    }
179
180    fn settled(state: &StateDef, t: f32) -> bool {
181        const EPS: f32 = 0.001;
182        let ok_x = match &state.x {
183            AnimValue::Static(_) => true,
184            AnimValue::Spring { spring, .. } => t >= spring.settle_time(EPS),
185        };
186        let ok_y = match &state.y {
187            AnimValue::Static(_) => true,
188            AnimValue::Spring { spring, .. } => t >= spring.settle_time(EPS),
189        };
190        ok_x && ok_y
191    }
192
193    fn offset_at(&self) -> Point {
194        let Some(f) = self.active else {
195            return self
196                .states
197                .get(self.current)
198                .map(|s| Point::new(s.x.target(), s.y.target()))
199                .unwrap_or_default();
200        };
201        Point::new(
202            interpolate_axis(&self.states[f.from].x, &self.states[f.to].x, f.elapsed),
203            interpolate_axis(&self.states[f.from].y, &self.states[f.to].y, f.elapsed),
204        )
205    }
206
207    pub fn offset(&self) -> Point {
208        self.pos
209    }
210
211    pub fn is_flying(&self) -> bool {
212        self.active.is_some()
213    }
214}
215
216fn interpolate_axis(from: &AnimValue, to: &AnimValue, t: f32) -> f32 {
217    let f = from.target();
218    let target = to.target();
219    match to {
220        AnimValue::Static(_) => target,
221        AnimValue::Spring { spring, .. } => f + (target - f) * spring.value(t),
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    fn demo() -> SpringMachine {
230        SpringMachine::new(
231            vec![
232                StateDef::new(
233                    "idle",
234                    AnimValue::static_px(408.0),
235                    AnimValue::static_px(8.0),
236                ),
237                StateDef::new(
238                    "bouncing",
239                    AnimValue::spring(8.0, 0.5, 8.0),
240                    AnimValue::spring(228.0, 0.5, 8.0),
241                ),
242            ],
243            vec![TransitionDef::new("idle", "bouncing", Trigger::Load)],
244        )
245    }
246
247    #[test]
248    fn load_fires_and_starts_at_from() {
249        let mut m = demo();
250        assert!(m.advance(0.0), "Load leci od pierwszej klatki");
251        assert_eq!(m.offset(), Point::new(408.0, 8.0));
252        assert_eq!(m.states()[1].x.target(), 8.0);
253        assert_eq!(m.states()[1].y.target(), 228.0);
254    }
255
256    #[test]
257    fn no_transition_stays_put() {
258        let mut m = SpringMachine::new(
259            vec![StateDef::new(
260                "idle",
261                AnimValue::static_px(10.0),
262                AnimValue::static_px(20.0),
263            )],
264            vec![],
265        );
266        assert!(!m.advance(0.016));
267        assert_eq!(m.offset(), Point::new(10.0, 20.0));
268        assert_eq!(m.current_name(), "idle");
269    }
270
271    #[test]
272    fn static_target_snaps_immediately() {
273        let mut m = SpringMachine::new(
274            vec![
275                StateDef::new("a", AnimValue::static_px(0.0), AnimValue::static_px(0.0)),
276                StateDef::new("b", AnimValue::static_px(100.0), AnimValue::static_px(50.0)),
277            ],
278            vec![TransitionDef::new("a", "b", Trigger::Load)],
279        );
280        assert!(!m.advance(0.0));
281        assert_eq!(m.offset(), Point::new(100.0, 50.0));
282    }
283
284    #[test]
285    fn empty_machine_is_inert() {
286        let mut m = SpringMachine::new(vec![], vec![]);
287        assert!(!m.advance(0.016));
288        assert_eq!(m.offset(), Point::ZERO);
289        assert_eq!(m.current_name(), "");
290    }
291
292    #[test]
293    fn unknown_states_never_fly() {
294        let mut m = SpringMachine::new(
295            vec![StateDef::new(
296                "a",
297                AnimValue::static_px(0.0),
298                AnimValue::static_px(0.0),
299            )],
300            vec![TransitionDef::new("a", "nope", Trigger::Load)],
301        );
302        assert!(!m.advance(0.016));
303        assert_eq!(m.offset(), Point::new(0.0, 0.0));
304    }
305
306    #[test]
307    fn negative_dt_is_clamped() {
308        let mut m = demo();
309        assert!(m.advance(-1.0));
310        assert_eq!(m.offset(), Point::new(408.0, 8.0));
311    }
312
313    #[test]
314    fn flight_is_visible_across_frames_then_settles() {
315        let mut m = SpringMachine::new(
316            vec![
317                StateDef::new(
318                    "idle",
319                    AnimValue::static_px(408.0),
320                    AnimValue::static_px(8.0),
321                ),
322                StateDef::new(
323                    "bouncing",
324                    AnimValue::spring(8.0, 0.6, 2.5),
325                    AnimValue::spring(228.0, 0.6, 2.5),
326                ),
327            ],
328            vec![TransitionDef::new("idle", "bouncing", Trigger::Load)],
329        );
330        assert!(m.advance(0.0));
331        let mut prev = m.offset().x;
332        assert!(prev > 380.0, "start at right edge");
333        let mut moved = 0;
334        for _ in 0..12 {
335            if !m.advance(0.025) {
336                break;
337            }
338            if m.offset().x < prev - 5.0 {
339                moved += 1;
340            }
341            prev = m.offset().x;
342        }
343        assert!(moved >= 2, "the flight is to be visible for several frames");
344
345        for _ in 0..200 {
346            if !m.advance(0.025) {
347                break;
348            }
349        }
350        assert!(!m.is_flying());
351        let p = m.offset();
352        assert!((p.x - 8.0).abs() < 0.01, "x settled: {p:?}");
353        assert!((p.y - 228.0).abs() < 0.01, "y settled: {p:?}");
354    }
355
356    #[test]
357    fn settles_exactly_on_target() {
358        let mut m = SpringMachine::new(
359            vec![
360                StateDef::new("a", AnimValue::static_px(0.0), AnimValue::static_px(0.0)),
361                StateDef::new(
362                    "b",
363                    AnimValue::spring(100.0, 1.0, 100.0),
364                    AnimValue::spring(50.0, 1.0, 100.0),
365                ),
366            ],
367            vec![TransitionDef::new("a", "b", Trigger::Load)],
368        );
369        assert!(m.advance(0.0));
370        for _ in 0..100 {
371            if !m.advance(0.016) {
372                break;
373            }
374        }
375        assert!(!m.is_flying());
376        assert_eq!(m.offset(), Point::new(100.0, 50.0));
377    }
378
379    #[test]
380    fn mixed_static_and_spring_axes() {
381        let mut m = SpringMachine::new(
382            vec![
383                StateDef::new("a", AnimValue::static_px(0.0), AnimValue::static_px(0.0)),
384                StateDef::new(
385                    "b",
386                    AnimValue::static_px(100.0),
387                    AnimValue::spring(50.0, 1.0, 4.0),
388                ),
389            ],
390            vec![TransitionDef::new("a", "b", Trigger::Load)],
391        );
392        assert!(m.advance(0.0));
393
394        assert_eq!(m.offset().x, 100.0);
395        for _ in 0..200 {
396            if !m.advance(0.016) {
397                break;
398            }
399        }
400        assert_eq!(m.offset(), Point::new(100.0, 50.0));
401    }
402
403    #[test]
404    fn underdamped_axis_overshoots_midflight() {
405        let mut m = SpringMachine::new(
406            vec![
407                StateDef::new("a", AnimValue::static_px(100.0), AnimValue::static_px(0.0)),
408                StateDef::new(
409                    "b",
410                    AnimValue::spring(0.0, 0.3, 3.0),
411                    AnimValue::static_px(0.0),
412                ),
413            ],
414            vec![TransitionDef::new("a", "b", Trigger::Load)],
415        );
416        assert!(m.advance(0.0));
417        let mut min = f32::INFINITY;
418        for _ in 0..120 {
419            m.advance(1.0 / 60.0);
420            min = min.min(m.offset().x);
421        }
422        assert!(min < -1.0, "under-damped X should exceed 0, min={min}");
423    }
424}