Skip to main content

sequoia_animation/
choreography.rs

1use crate::animation::{Animation, AnimationFrame, LoopMode};
2use crate::mixer::Mixer;
3
4#[derive(Clone, Debug)]
5struct Entry {
6    animation: Animation,
7    at: f64,
8    weight: f32,
9}
10
11#[derive(Clone, Debug, Default)]
12pub struct Choreography {
13    entries: Vec<Entry>,
14    loop_mode: LoopMode,
15}
16
17impl Choreography {
18    pub fn new() -> Self {
19        Self::default()
20    }
21
22    pub fn with_loop(mut self, mode: LoopMode) -> Self {
23        self.loop_mode = mode;
24        self
25    }
26
27    pub fn add(mut self, animation: Animation, at_secs: f64) -> Self {
28        self.entries.push(Entry {
29            animation,
30            at: at_secs.max(0.0),
31            weight: 1.0,
32        });
33        self
34    }
35
36    pub fn add_weighted(mut self, animation: Animation, at_secs: f64, weight: f32) -> Self {
37        self.entries.push(Entry {
38            animation,
39            at: at_secs.max(0.0),
40            weight,
41        });
42        self
43    }
44
45    pub fn stagger(animations: impl IntoIterator<Item = Animation>, step: f64) -> Self {
46        Self::new().add_staggered(animations, step)
47    }
48
49    pub fn add_staggered(
50        mut self,
51        animations: impl IntoIterator<Item = Animation>,
52        step: f64,
53    ) -> Self {
54        let mut at = self.duration();
55        for animation in animations {
56            self.entries.push(Entry {
57                animation,
58                at,
59                weight: 1.0,
60            });
61            at += step.max(0.0);
62        }
63        self
64    }
65
66    pub fn len(&self) -> usize {
67        self.entries.len()
68    }
69
70    pub fn is_empty(&self) -> bool {
71        self.entries.is_empty()
72    }
73
74    pub fn loop_mode(&self) -> LoopMode {
75        self.loop_mode
76    }
77
78    pub fn duration(&self) -> f64 {
79        self.entries
80            .iter()
81            .map(|e| {
82                let len = match e.animation.loop_mode() {
83                    LoopMode::Once => e.animation.duration(),
84
85                    LoopMode::Loop | LoopMode::PingPong => e.animation.duration(),
86                };
87                e.at + len.max(0.0)
88            })
89            .fold(0.0, f64::max)
90    }
91
92    pub fn is_finished_at(&self, t: f64) -> bool {
93        self.loop_mode == LoopMode::Once && t >= self.duration()
94    }
95
96    pub fn sample(&self, t: f64) -> AnimationFrame {
97        let t = self.wrap_time(t);
98        let samples: Vec<(AnimationFrame, f32)> = self
99            .entries
100            .iter()
101            .filter_map(|e| {
102                let local = t - e.at;
103                if local < 0.0 || e.weight <= 0.0 {
104                    return None;
105                }
106                Some((e.animation.sample(local), e.weight))
107            })
108            .collect();
109        if samples.is_empty() {
110            return AnimationFrame::new();
111        }
112        Mixer::mix_frames(samples.iter().map(|(f, w)| (f, *w)))
113    }
114
115    fn wrap_time(&self, t: f64) -> f64 {
116        let duration = self.duration();
117        if duration <= 0.0 {
118            return 0.0;
119        }
120        match self.loop_mode {
121            LoopMode::Once => t.max(0.0).min(duration),
122            LoopMode::Loop => t.max(0.0) % duration,
123            LoopMode::PingPong => {
124                let period = duration * 2.0;
125                let t = t.max(0.0) % period;
126                if t <= duration { t } else { period - t }
127            }
128        }
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use crate::animation::{Keyframe, Track};
136    use crate::value::Value;
137
138    fn fade(dur: f64) -> Animation {
139        Animation::new("fade", dur).with_track(
140            "opacity",
141            Track::try_new(vec![
142                Keyframe::new(0.0, Value::f32(0.0)),
143                Keyframe::new(dur, Value::f32(1.0)),
144            ])
145            .unwrap(),
146        )
147    }
148
149    fn slide(name: &str, dur: f64, to: f32) -> Animation {
150        Animation::new(name, dur).with_track(
151            "x",
152            Track::try_new(vec![
153                Keyframe::new(0.0, Value::px(0.0)),
154                Keyframe::new(dur, Value::px(to)),
155            ])
156            .unwrap(),
157        )
158    }
159
160    #[test]
161    fn empty_choreography_is_inert() {
162        let c = Choreography::new();
163        assert!(c.is_empty());
164        assert_eq!(c.duration(), 0.0);
165        assert!(c.sample(1.0).is_empty());
166        assert!(c.is_finished_at(0.0));
167    }
168
169    #[test]
170    fn duration_is_latest_clip_end() {
171        let c = Choreography::new()
172            .add(slide("a", 0.4, 10.0), 0.0)
173            .add(slide("b", 0.4, 10.0), 0.25);
174        assert!((c.duration() - 0.65).abs() < 1e-9);
175        assert_eq!(c.len(), 2);
176    }
177
178    #[test]
179    fn clips_before_their_offset_contribute_nothing() {
180        let c = Choreography::new()
181            .add(slide("a", 0.2, 100.0), 0.0)
182            .add(slide("b", 0.2, 100.0), 0.5);
183
184        let f0 = c.sample(0.0);
185        assert_eq!(f0.get("x"), Some(&Value::px(0.0)));
186
187        let f1 = c.sample(0.1);
188        assert_eq!(f1.get("x"), Some(&Value::px(50.0)));
189    }
190
191    #[test]
192    fn stagger_offsets_each_clip() {
193        let c = Choreography::stagger([fade(0.2), fade(0.2), fade(0.2)], 0.1);
194
195        assert!((c.duration() - 0.4).abs() < 1e-9);
196
197        assert_eq!(c.sample(0.0).get("opacity"), Some(&Value::f32(0.0)));
198
199        let mixed = c.sample(0.1).get("opacity").copied().unwrap();
200        assert!(matches!(mixed, Value::F32(v) if (v - 0.25).abs() < 1e-6));
201
202        assert_eq!(c.sample(10.0).get("opacity"), Some(&Value::f32(1.0)));
203    }
204
205    #[test]
206    fn weighted_entries_blend() {
207        let a = slide("a", 1.0, 100.0);
208        let b = slide("b", 1.0, -100.0);
209        let c = Choreography::new()
210            .add_weighted(a, 0.0, 0.75)
211            .add_weighted(b, 0.0, 0.25);
212        let v = c.sample(1.0).get("x").copied().unwrap();
213        assert_eq!(v, Value::px(50.0));
214    }
215
216    #[test]
217    fn once_holds_last_frame_and_reports_finished() {
218        let c = Choreography::new().add(fade(0.2), 0.0);
219        assert!(!c.is_finished_at(0.1));
220        assert!(c.is_finished_at(0.2));
221        assert_eq!(c.sample(5.0).get("opacity"), Some(&Value::f32(1.0)));
222    }
223
224    #[test]
225    fn looping_timeline_wraps() {
226        let c = Choreography::new()
227            .add(fade(0.2), 0.0)
228            .with_loop(LoopMode::Loop);
229        assert!(!c.is_finished_at(100.0));
230
231        let v = c.sample(0.25).get("opacity").copied().unwrap();
232        assert!(matches!(v, Value::F32(x) if (x - 0.25).abs() < 1e-5));
233    }
234
235    #[test]
236    fn pingpong_reverses_at_the_end() {
237        let c = Choreography::new()
238            .add(fade(0.2), 0.0)
239            .with_loop(LoopMode::PingPong);
240
241        let v = c.sample(0.3).get("opacity").copied().unwrap();
242        assert!(matches!(v, Value::F32(x) if (x - 0.5).abs() < 1e-5));
243    }
244
245    #[test]
246    fn add_staggered_chains_from_the_end() {
247        let c = Choreography::new()
248            .add(fade(0.3), 0.0)
249            .add_staggered([fade(0.2), fade(0.2)], 0.05);
250
251        assert!((c.duration() - 0.55).abs() < 1e-9);
252    }
253}