1use std::collections::BTreeMap;
2
3use crate::engine::animator::ease;
4use crate::schema::animation::EasingType;
5use crate::schema::time::{TimeCtx, TimeError};
6
7use super::schema::{VarDef, VarSet};
8
9#[derive(Debug, Clone, PartialEq, thiserror::Error)]
10pub enum VarsError {
11 #[error("variable `{name}`: {source}")]
12 Time {
13 name: String,
14 #[source]
15 source: TimeError,
16 },
17 #[error(
18 "variable `{name}`: keyframe {index} starts at {at}s, which is not after keyframe {prev_index}'s start ({prev_at}s) — keyframes must be strictly increasing"
19 )]
20 KeyframesNotIncreasing {
21 name: String,
22 index: usize,
23 at: f64,
24 prev_index: usize,
25 prev_at: f64,
26 },
27 #[error("variable `{name}`: keyframe {index} has a negative duration ({duration}s)")]
28 NegativeDuration {
29 name: String,
30 index: usize,
31 duration: f64,
32 },
33}
34
35#[derive(Debug, Clone, PartialEq)]
36struct Segment {
37 at: f64,
38 duration: f64,
39 start: f64,
40 to: f64,
41 easing: EasingType,
42}
43
44impl Segment {
45 fn value_at(&self, t: f64) -> f64 {
46 if self.duration <= 0.0 {
47 return self.to;
48 }
49 let raw = ((t - self.at) / self.duration).clamp(0.0, 1.0);
50 let eased = ease(raw, &self.easing);
51 self.start + (self.to - self.start) * eased
52 }
53}
54
55#[derive(Debug, Clone, PartialEq)]
56pub struct VarTrack {
57 default: f64,
58 segments: Vec<Segment>,
59}
60
61impl VarTrack {
62 pub fn compile(name: &str, def: &VarDef, ctx: &TimeCtx) -> Result<Self, VarsError> {
63 let mut segments = Vec::with_capacity(def.animation.len());
64 let mut running = def.default;
65 let mut prev: Option<(usize, f64)> = None;
66
67 for (index, kf) in def.animation.iter().enumerate() {
68 let at_ctx = TimeCtx {
69 scene_start: 0.0,
70 ..*ctx
71 };
72 let at = kf
73 .at
74 .resolve_absolute(&at_ctx)
75 .map_err(|source| VarsError::Time {
76 name: name.to_string(),
77 source,
78 })?;
79
80 if let Some((prev_index, prev_at)) = prev {
81 if at <= prev_at {
82 return Err(VarsError::KeyframesNotIncreasing {
83 name: name.to_string(),
84 index,
85 at,
86 prev_index,
87 prev_at,
88 });
89 }
90 }
91 prev = Some((index, at));
92
93 let duration_ctx = TimeCtx {
94 beat_offset: 0.0,
95 scene_start: 0.0,
96 ..*ctx
97 };
98 let duration = kf
99 .duration
100 .resolve_relative(&duration_ctx)
101 .map_err(|source| VarsError::Time {
102 name: name.to_string(),
103 source,
104 })?;
105 if duration < 0.0 {
106 return Err(VarsError::NegativeDuration {
107 name: name.to_string(),
108 index,
109 duration,
110 });
111 }
112
113 segments.push(Segment {
114 at,
115 duration,
116 start: running,
117 to: kf.to,
118 easing: kf.easing.clone(),
119 });
120 running = kf.to;
121 }
122
123 Ok(VarTrack {
124 default: def.default,
125 segments,
126 })
127 }
128
129 pub fn value_at(&self, t: f64) -> f64 {
130 let mut value = self.default;
131 for seg in &self.segments {
132 if t < seg.at {
133 return value;
134 }
135 let end = seg.at + seg.duration;
136 if t <= end {
137 return seg.value_at(t);
138 }
139 value = seg.to;
140 }
141 value
142 }
143}
144
145#[derive(Debug, Clone, PartialEq, Default)]
146pub struct VarTable {
147 tracks: BTreeMap<String, VarTrack>,
148}
149
150impl VarTable {
151 pub fn compile(vars: &VarSet, ctx: &TimeCtx) -> Result<Self, VarsError> {
152 let mut tracks = BTreeMap::new();
153 for (name, def) in vars {
154 tracks.insert(name.clone(), VarTrack::compile(name, def, ctx)?);
155 }
156 Ok(VarTable { tracks })
157 }
158
159 pub fn value_at(&self, name: &str, t: f64) -> Option<f64> {
160 self.tracks.get(name).map(|track| track.value_at(t))
161 }
162
163 pub fn names(&self) -> impl Iterator<Item = &str> {
164 self.tracks.keys().map(String::as_str)
165 }
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171 use crate::schema::time::TimePoint;
172 use crate::vars::schema::VarKeyframe;
173
174 fn ctx(bpm: Option<f64>, beat_offset: f64) -> TimeCtx {
175 TimeCtx {
176 bpm,
177 beat_offset,
178 scene_start: 0.0,
179 }
180 }
181
182 fn linear_0_to_1_over_3_beats() -> VarDef {
183 VarDef {
184 default: 0.0,
185 animation: vec![VarKeyframe {
186 at: TimePoint::Spec("@4.85s".to_string()),
187 to: 1.0,
188 duration: TimePoint::Spec("3b".to_string()),
189 easing: EasingType::Linear,
190 }],
191 }
192 }
193
194 #[test]
195 fn value_before_first_keyframe_is_default() {
196 let def = linear_0_to_1_over_3_beats();
197 let track = VarTrack::compile("keyDraw", &def, &ctx(Some(120.0), 0.0)).unwrap();
198 assert_eq!(track.value_at(0.0), 0.0);
199 assert_eq!(track.value_at(4.84), 0.0);
200 }
201
202 #[test]
203 fn value_at_several_sampled_instants_across_the_tween() {
204 let def = linear_0_to_1_over_3_beats();
205 let track = VarTrack::compile("keyDraw", &def, &ctx(Some(120.0), 0.0)).unwrap();
206
207 assert_eq!(track.value_at(4.85), 0.0);
208 assert!((track.value_at(5.6) - 0.5).abs() < 1e-9);
209 assert_eq!(track.value_at(6.35), 1.0);
210 }
211
212 #[test]
213 fn value_after_last_keyframe_holds_at_to() {
214 let def = linear_0_to_1_over_3_beats();
215 let track = VarTrack::compile("keyDraw", &def, &ctx(Some(120.0), 0.0)).unwrap();
216 assert_eq!(track.value_at(6.36), 1.0);
217 assert_eq!(track.value_at(1000.0), 1.0);
218 }
219
220 #[test]
221 fn beat_offset_does_not_leak_into_duration() {
222 let def = linear_0_to_1_over_3_beats();
223 let track = VarTrack::compile("keyDraw", &def, &ctx(Some(120.0), 2.2)).unwrap();
224 assert_eq!(track.value_at(4.85), 0.0);
225 assert_eq!(track.value_at(6.35), 1.0);
226 }
227
228 #[test]
229 fn multiple_keyframes_chain_start_values() {
230 let def = VarDef {
231 default: 0.0,
232 animation: vec![
233 VarKeyframe {
234 at: TimePoint::Seconds(1.0),
235 to: 10.0,
236 duration: TimePoint::Seconds(1.0),
237 easing: EasingType::Linear,
238 },
239 VarKeyframe {
240 at: TimePoint::Seconds(3.0),
241 to: 0.0,
242 duration: TimePoint::Seconds(1.0),
243 easing: EasingType::Linear,
244 },
245 ],
246 };
247 let track = VarTrack::compile("v", &def, &ctx(None, 0.0)).unwrap();
248 assert_eq!(track.value_at(0.0), 0.0);
249 assert_eq!(track.value_at(1.0), 0.0);
250 assert_eq!(track.value_at(1.5), 5.0);
251 assert_eq!(track.value_at(2.0), 10.0);
252 assert_eq!(track.value_at(2.5), 10.0);
253 assert_eq!(track.value_at(3.0), 10.0);
254 assert_eq!(track.value_at(3.5), 5.0);
255 assert_eq!(track.value_at(4.0), 0.0);
256 assert_eq!(track.value_at(100.0), 0.0);
257 }
258
259 #[test]
260 fn zero_duration_keyframe_snaps() {
261 let def = VarDef {
262 default: 0.0,
263 animation: vec![VarKeyframe {
264 at: TimePoint::Seconds(1.0),
265 to: 1.0,
266 duration: TimePoint::Seconds(0.0),
267 easing: EasingType::Linear,
268 }],
269 };
270 let track = VarTrack::compile("v", &def, &ctx(None, 0.0)).unwrap();
271 assert_eq!(track.value_at(0.999), 0.0);
272 assert_eq!(track.value_at(1.0), 1.0);
273 assert_eq!(track.value_at(2.0), 1.0);
274 }
275
276 #[test]
277 fn ease_in_out_is_not_linear_at_the_midpoint() {
278 let def = VarDef {
279 default: 0.0,
280 animation: vec![VarKeyframe {
281 at: TimePoint::Seconds(0.0),
282 to: 1.0,
283 duration: TimePoint::Seconds(1.0),
284 easing: EasingType::EaseInOut,
285 }],
286 };
287 let track = VarTrack::compile("v", &def, &ctx(None, 0.0)).unwrap();
288 let quarter = track.value_at(0.25);
289 assert!(
290 quarter < 0.25,
291 "expected eased value below linear, got {quarter}"
292 );
293 assert!((track.value_at(0.5) - 0.5).abs() < 1e-9);
294 }
295
296 #[test]
297 fn beat_unit_without_bpm_errors_with_the_variable_name() {
298 let def = linear_0_to_1_over_3_beats();
299 let err = VarTrack::compile("keyDraw", &def, &ctx(None, 0.0)).unwrap_err();
300 assert!(matches!(err, VarsError::Time { name, .. } if name == "keyDraw"));
301 }
302
303 #[test]
304 fn non_increasing_keyframes_are_rejected() {
305 let def = VarDef {
306 default: 0.0,
307 animation: vec![
308 VarKeyframe {
309 at: TimePoint::Seconds(2.0),
310 to: 1.0,
311 duration: TimePoint::Seconds(1.0),
312 easing: EasingType::Linear,
313 },
314 VarKeyframe {
315 at: TimePoint::Seconds(2.0),
316 to: 0.0,
317 duration: TimePoint::Seconds(1.0),
318 easing: EasingType::Linear,
319 },
320 ],
321 };
322 let err = VarTrack::compile("v", &def, &ctx(None, 0.0)).unwrap_err();
323 assert!(matches!(
324 err,
325 VarsError::KeyframesNotIncreasing { index: 1, .. }
326 ));
327 }
328
329 #[test]
330 fn negative_duration_is_rejected() {
331 let def = VarDef {
332 default: 0.0,
333 animation: vec![VarKeyframe {
334 at: TimePoint::Spec("@2s-3s".to_string()),
335 to: 1.0,
336 duration: TimePoint::Spec("1s-3s".to_string()),
337 easing: EasingType::Linear,
338 }],
339 };
340 let err = VarTrack::compile("v", &def, &ctx(None, 0.0)).unwrap_err();
341 assert!(matches!(err, VarsError::NegativeDuration { .. }));
342 }
343
344 #[test]
345 fn var_table_compiles_and_resolves_by_name() {
346 let mut vars = VarSet::new();
347 vars.insert("keyDraw".to_string(), linear_0_to_1_over_3_beats());
348 vars.insert(
349 "constant".to_string(),
350 VarDef {
351 default: 42.0,
352 animation: Vec::new(),
353 },
354 );
355
356 let table = VarTable::compile(&vars, &ctx(Some(120.0), 0.0)).unwrap();
357 assert_eq!(table.value_at("constant", 0.0), Some(42.0));
358 assert_eq!(table.value_at("constant", 999.0), Some(42.0));
359 assert_eq!(table.value_at("keyDraw", 0.0), Some(0.0));
360 assert_eq!(table.value_at("keyDraw", 1000.0), Some(1.0));
361 assert_eq!(table.value_at("nope", 0.0), None);
362
363 let mut names: Vec<&str> = table.names().collect();
364 names.sort_unstable();
365 assert_eq!(names, vec!["constant", "keyDraw"]);
366 }
367}