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}