1use std::num::NonZeroU32;
2
3use glam::Vec4;
4use loam_math::{Bivector, Rotor, Rotor4};
5use serde::{Deserialize, Serialize};
6
7#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
8pub enum Ease {
9 #[default]
10 Linear,
11 InOutCubic,
12 OutCubic,
13}
14
15impl Ease {
16 pub fn apply(self, u: f32) -> f32 {
17 let u = u.clamp(0.0, 1.0);
18 match self {
19 Self::Linear => u,
20 Self::InOutCubic => {
21 if u < 0.5 {
22 4.0 * u * u * u
23 } else {
24 let v = -2.0 * u + 2.0;
25 1.0 - v * v * v / 2.0
26 }
27 }
28 Self::OutCubic => {
29 let v = 1.0 - u;
30 1.0 - v * v * v
31 }
32 }
33 }
34}
35
36pub trait Interpolate: Copy {
37 fn mix(from: Self, to: Self, u: f32) -> Self;
38}
39
40impl Interpolate for f32 {
41 fn mix(from: Self, to: Self, u: f32) -> Self {
42 from + (to - from) * u
43 }
44}
45
46impl Interpolate for Vec4 {
47 fn mix(from: Self, to: Self, u: f32) -> Self {
48 from + (to - from) * u
49 }
50}
51
52impl Interpolate for Rotor4 {
53 fn mix(from: Self, to: Self, u: f32) -> Self {
55 let relative = from.inverse() * to;
56 from * (relative.log() * u).exp()
57 }
58}
59
60#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)]
62pub struct Key<T> {
63 pub t: f32,
64 pub value: T,
65 #[serde(default)]
66 pub ease: Ease,
67}
68
69#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
71#[serde(transparent)]
72pub struct Track<T> {
73 keys: Vec<Key<T>>,
74}
75
76impl<T> Default for Track<T> {
77 fn default() -> Self {
78 Self { keys: Vec::new() }
79 }
80}
81
82impl<T> Track<T> {
83 pub fn new() -> Self {
84 Self::default()
85 }
86
87 pub fn key(mut self, t: f32, value: T, ease: Ease) -> Self {
88 self.keys.push(Key { t, value, ease });
89 self
90 }
91
92 pub fn keys(&self) -> &[Key<T>] {
93 &self.keys
94 }
95}
96
97impl<T: Interpolate> Track<T> {
98 pub fn sample(&self, frame: u32, fps: NonZeroU32) -> Option<T> {
100 let first = self.keys.first()?;
101 let t = frame as f32 / fps.get() as f32;
102 if t <= first.t {
103 return Some(first.value);
104 }
105 let last = self.keys.last()?;
106 if t >= last.t {
107 return Some(last.value);
108 }
109 let i = self.keys.partition_point(|k| k.t <= t) - 1;
110 let (a, b) = (&self.keys[i], &self.keys[i + 1]);
111 let span = b.t - a.t;
112 let u = if span > 0.0 { (t - a.t) / span } else { 1.0 };
113 Some(T::mix(a.value, b.value, b.ease.apply(u)))
114 }
115}
116
117#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
118pub struct BodyTrack {
119 pub name: String,
120 #[serde(default)]
121 pub position: Option<Track<Vec4>>,
122 #[serde(default)]
123 pub orientation: Option<Track<Rotor4>>,
124}
125
126#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
127pub struct Timeline {
128 pub fps: u32,
129 pub frames: u32,
130 #[serde(default)]
131 pub w_slice: Option<Track<f32>>,
132 #[serde(default)]
133 pub bodies: Vec<BodyTrack>,
134}
135
136const UNIT_ROTOR_TOLERANCE: f32 = 1e-3;
137
138impl Timeline {
139 pub fn validate(&self) -> Result<NonZeroU32, TimelineError> {
140 let fps = NonZeroU32::new(self.fps).ok_or(TimelineError::ZeroFps)?;
141 if self.frames == 0 {
142 return Err(TimelineError::ZeroFrames);
143 }
144 if let Some(track) = &self.w_slice {
145 validate_times(track, "w_slice")?;
146 }
147 for (index, body) in self.bodies.iter().enumerate() {
148 if self.bodies[..index].iter().any(|b| b.name == body.name) {
149 return Err(TimelineError::DuplicateBody {
150 name: body.name.clone(),
151 });
152 }
153 if let Some(track) = &body.position {
154 validate_times(track, &format!("{}.position", body.name))?;
155 }
156 if let Some(track) = &body.orientation {
157 validate_rotors(track, &format!("{}.orientation", body.name))?;
158 }
159 }
160 Ok(fps)
161 }
162}
163
164fn validate_times<T>(track: &Track<T>, channel: &str) -> Result<(), TimelineError> {
165 let keys = track.keys();
166 if keys.is_empty() {
167 return Err(TimelineError::EmptyTrack {
168 channel: channel.to_owned(),
169 });
170 }
171 for (index, key) in keys.iter().enumerate() {
172 if !key.t.is_finite() || key.t < 0.0 {
173 return Err(TimelineError::KeyTime {
174 channel: channel.to_owned(),
175 index,
176 });
177 }
178 if index > 0 && key.t <= keys[index - 1].t {
179 return Err(TimelineError::KeyOrder {
180 channel: channel.to_owned(),
181 index,
182 });
183 }
184 }
185 Ok(())
186}
187
188fn validate_rotors(track: &Track<Rotor4>, channel: &str) -> Result<(), TimelineError> {
189 validate_times(track, channel)?;
190 for (index, key) in track.keys().iter().enumerate() {
191 let norm_squared = key.value.norm_squared();
192 let pseudoscalar = (key.value * key.value.inverse()).xyzw;
193 if !norm_squared.is_finite()
194 || (norm_squared - 1.0).abs() > UNIT_ROTOR_TOLERANCE
195 || !pseudoscalar.is_finite()
196 || pseudoscalar.abs() > UNIT_ROTOR_TOLERANCE
197 {
198 return Err(TimelineError::NonUnitRotor {
199 channel: channel.to_owned(),
200 index,
201 norm_squared,
202 pseudoscalar,
203 });
204 }
205 }
206 Ok(())
207}
208
209#[derive(Debug, thiserror::Error)]
210pub enum TimelineError {
211 #[error("fps must be nonzero")]
212 ZeroFps,
213 #[error("frame count must be nonzero")]
214 ZeroFrames,
215 #[error("channel '{channel}' has no keys")]
216 EmptyTrack { channel: String },
217 #[error("channel '{channel}' key {index} has a negative or non-finite time")]
218 KeyTime { channel: String, index: usize },
219 #[error("channel '{channel}' key {index} is not strictly later than its predecessor")]
220 KeyOrder { channel: String, index: usize },
221 #[error("channel '{channel}' key {index} is not a unit rotor (norm² = {norm_squared}, R reverse(R) pseudoscalar = {pseudoscalar})")]
222 NonUnitRotor {
223 channel: String,
224 index: usize,
225 norm_squared: f32,
226 pseudoscalar: f32,
227 },
228 #[error("duplicate body name '{name}'")]
229 DuplicateBody { name: String },
230 #[error(transparent)]
231 Ron(#[from] ron::error::SpannedError),
232}
233
234#[derive(Copy, Clone, Debug, PartialEq, Eq)]
235pub struct Playhead {
236 frame: u32,
237 frames: u32,
238 playing: bool,
239}
240
241impl Playhead {
242 pub fn new(frames: u32) -> Self {
244 Self {
245 frame: 0,
246 frames: frames.max(1),
247 playing: true,
248 }
249 }
250
251 pub fn advance(&mut self) {
252 if self.playing {
253 self.frame = (self.frame + 1).min(self.last());
254 }
255 }
256
257 pub fn seek(&mut self, frame: u32) {
258 self.frame = frame.min(self.last());
259 }
260
261 pub fn frame(&self) -> u32 {
262 self.frame
263 }
264
265 pub fn frames(&self) -> u32 {
266 self.frames
267 }
268
269 pub fn playing(&self) -> bool {
270 self.playing
271 }
272
273 pub fn set_playing(&mut self, playing: bool) {
274 self.playing = playing;
275 }
276
277 pub fn finished(&self) -> bool {
278 self.frame >= self.last()
279 }
280
281 fn last(&self) -> u32 {
282 self.frames - 1
283 }
284}
285
286#[derive(Copy, Clone, Debug, PartialEq)]
287#[must_use]
288pub enum Drive<T> {
289 Host,
290 Directed(T),
291}
292
293#[derive(Clone, Debug)]
295pub struct Director {
296 timeline: Timeline,
297 fps: NonZeroU32,
298 playhead: Playhead,
299}
300
301impl Director {
302 pub fn new(timeline: Timeline) -> Result<Self, TimelineError> {
303 let fps = timeline.validate()?;
304 let playhead = Playhead::new(timeline.frames);
305 Ok(Self {
306 timeline,
307 fps,
308 playhead,
309 })
310 }
311
312 pub fn from_ron(text: &str) -> Result<Self, TimelineError> {
313 Self::new(ron::from_str::<Timeline>(text)?)
314 }
315
316 pub fn timeline(&self) -> &Timeline {
317 &self.timeline
318 }
319
320 pub fn advance(&mut self) {
321 self.playhead.advance();
322 }
323
324 pub fn seek(&mut self, frame: u32) {
325 self.playhead.seek(frame);
326 }
327
328 pub fn playhead(&self) -> Playhead {
329 self.playhead
330 }
331
332 pub fn set_playing(&mut self, playing: bool) {
333 self.playhead.set_playing(playing);
334 }
335
336 pub fn frame(&self) -> u32 {
337 self.playhead.frame()
338 }
339
340 pub fn finished(&self) -> bool {
341 self.playhead.finished()
342 }
343
344 pub fn w_slice(&self) -> Drive<f32> {
345 self.drive(self.timeline.w_slice.as_ref())
346 }
347
348 pub fn position(&self, body: &str) -> Drive<Vec4> {
349 self.drive(self.body(body).and_then(|b| b.position.as_ref()))
350 }
351
352 pub fn orientation(&self, body: &str) -> Drive<Rotor4> {
353 self.drive(self.body(body).and_then(|b| b.orientation.as_ref()))
354 }
355
356 pub fn bodies(&self) -> impl Iterator<Item = &str> {
358 self.timeline.bodies.iter().map(|b| b.name.as_str())
359 }
360
361 fn body(&self, name: &str) -> Option<&BodyTrack> {
362 self.timeline.bodies.iter().find(|b| b.name == name)
363 }
364
365 fn drive<T: Interpolate>(&self, track: Option<&Track<T>>) -> Drive<T> {
366 match track.and_then(|t| t.sample(self.playhead.frame(), self.fps)) {
367 Some(value) => Drive::Directed(value),
368 None => Drive::Host,
369 }
370 }
371}
372
373#[cfg(test)]
374mod tests {
375 use super::*;
376 use loam_math::Bivector4;
377 use std::f32::consts::PI;
378
379 const FPS: NonZeroU32 = NonZeroU32::new(60).unwrap();
380
381 fn rotor(b: Bivector4) -> Rotor4 {
382 b.exp()
383 }
384
385 fn spin_timeline() -> Timeline {
386 Timeline {
387 fps: 60,
388 frames: 121,
389 w_slice: Some(Track::new().key(0.0, -1.0, Ease::Linear).key(
390 2.0,
391 1.0,
392 Ease::InOutCubic,
393 )),
394 bodies: vec![BodyTrack {
395 name: "row".to_owned(),
396 position: Some(
397 Track::new()
398 .key(0.0, Vec4::new(0.0, 0.0, 0.0, -3.0), Ease::Linear)
399 .key(2.0, Vec4::new(1.0, 2.0, 3.0, 0.0), Ease::Linear),
400 ),
401 orientation: Some(Track::new().key(0.0, Rotor4::IDENTITY, Ease::Linear).key(
402 2.0,
403 rotor(Bivector4::new(0.7, 0.0, 0.0, 0.0, 0.0, 0.3)),
404 Ease::Linear,
405 )),
406 }],
407 }
408 }
409
410 #[test]
411 fn easing_clamps_endpoints() {
412 for ease in [Ease::Linear, Ease::InOutCubic, Ease::OutCubic] {
413 assert_eq!(ease.apply(0.0), 0.0);
414 assert!((ease.apply(1.0) - 1.0).abs() < 1e-6);
415 assert_eq!(ease.apply(-4.0), 0.0);
416 assert!((ease.apply(9.0) - 1.0).abs() < 1e-6);
417 }
418 assert!((Ease::InOutCubic.apply(0.5) - 0.5).abs() < 1e-6);
419 }
420
421 #[test]
422 fn sampling_uses_timeline_rate() {
423 let track = Track::new()
424 .key(0.0, 0.0, Ease::Linear)
425 .key(1.0, 10.0, Ease::Linear);
426 let thirty = NonZeroU32::new(30).unwrap();
427 assert_eq!(track.sample(0, thirty), Some(0.0));
428 assert_eq!(track.sample(15, thirty), Some(5.0));
429 assert_eq!(track.sample(30, thirty), Some(10.0));
430 assert_eq!(track.sample(30, FPS), Some(5.0));
431 assert_eq!(track.sample(60, FPS), Some(10.0));
432 }
433
434 #[test]
435 fn destination_key_selects_easing() {
436 let track = Track::new()
437 .key(0.0, 0.0, Ease::OutCubic)
438 .key(1.0, 10.0, Ease::InOutCubic);
439 let quarter = track.sample(15, FPS).unwrap();
440 assert!((quarter - 0.625).abs() < 1e-6, "sampled {quarter}");
441 }
442
443 #[test]
444 fn position_track_interpolates_w() {
445 let track = Track::new()
446 .key(0.0, Vec4::new(0.0, 0.0, 0.0, -4.0), Ease::Linear)
447 .key(1.0, Vec4::new(2.0, -6.0, 0.0, 4.0), Ease::Linear);
448 let quarter = track.sample(15, FPS).unwrap();
449 assert!(
450 (quarter - Vec4::new(0.5, -1.5, 0.0, -2.0)).length() < 1e-6,
451 "sampled {quarter}"
452 );
453 }
454
455 #[test]
456 fn sampling_holds_endpoints() {
457 let track = Track::new()
458 .key(1.0, 4.0, Ease::Linear)
459 .key(2.0, 9.0, Ease::Linear);
460 assert_eq!(track.sample(0, FPS), Some(4.0));
461 assert_eq!(track.sample(30, FPS), Some(4.0));
462 assert_eq!(track.sample(60, FPS), Some(4.0));
463 assert_eq!(track.sample(120, FPS), Some(9.0));
464 assert_eq!(track.sample(100_000, FPS), Some(9.0));
465 }
466
467 #[test]
468 fn seeking_matches_sequential_playback() {
469 let mut walked = Director::new(spin_timeline()).unwrap();
470 for _ in 0..37 {
471 walked.advance();
472 }
473 let mut sought = Director::new(spin_timeline()).unwrap();
474 sought.seek(37);
475 let mut wandered = Director::new(spin_timeline()).unwrap();
476 wandered.seek(500);
477 wandered.seek(3);
478 wandered.seek(37);
479
480 assert_eq!(walked.frame(), 37);
481 for other in [&sought, &wandered] {
482 assert_eq!(walked.w_slice(), other.w_slice());
483 assert_eq!(walked.position("row"), other.position("row"));
484 assert_eq!(walked.orientation("row"), other.orientation("row"));
485 }
486 }
487
488 #[test]
489 fn playback_stops_at_last_frame() {
490 let mut director = Director::new(spin_timeline()).unwrap();
491 for expected in 1..=10 {
492 director.advance();
493 assert_eq!(director.frame(), expected);
494 }
495 assert!(!director.finished());
496 for _ in 0..1000 {
497 director.advance();
498 }
499 assert_eq!(director.frame(), 120);
500 assert!(director.finished());
501 let final_value = director.orientation("row");
502 assert!(matches!(final_value, Drive::Directed(_)));
503 director.set_playing(false);
504 director.advance();
505 assert_eq!(director.orientation("row"), final_value);
506 }
507
508 #[test]
509 fn paused_seek_clamps_to_frame_range() {
510 let mut playhead = Playhead::new(10);
511 playhead.advance();
512 playhead.set_playing(false);
513 playhead.advance();
514 playhead.advance();
515 assert_eq!(playhead.frame(), 1);
516 playhead.seek(99);
517 assert_eq!(playhead.frame(), 9);
518 assert!(playhead.finished());
519 playhead.seek(0);
520 assert_eq!(playhead.frame(), 0);
521 }
522
523 #[test]
524 fn rotor_interpolation_preserves_norm_and_endpoints() {
525 let from = rotor(Bivector4::new(0.31, -0.62, 0.14, 0.83, -0.27, 0.45));
526 let to = rotor(Bivector4::new(-0.71, 0.22, 0.96, -0.18, 0.53, -0.34));
527 let probes = [
528 Vec4::new(1.0, 0.0, 0.0, 0.0),
529 Vec4::new(0.0, 1.0, 0.0, 0.0),
530 Vec4::new(0.0, 0.0, 1.0, 0.0),
531 Vec4::new(0.0, 0.0, 0.0, 1.0),
532 Vec4::new(0.5, -0.5, 0.5, -0.5),
533 ];
534
535 for step in 0..=100 {
536 let u = step as f32 / 100.0;
537 let r = Rotor4::mix(from, to, u);
538 assert!(
539 (r.norm_squared() - 1.0).abs() < 1e-6,
540 "u = {u}, norm² = {}",
541 r.norm_squared()
542 );
543 }
544
545 for probe in probes {
546 let start = Rotor4::mix(from, to, 0.0).apply(probe);
547 let end = Rotor4::mix(from, to, 1.0).apply(probe);
548 assert!((start - from.apply(probe)).length() < 1e-5);
549 assert!((end - to.apply(probe)).length() < 1e-5);
550 }
551 }
552
553 #[test]
554 fn rotor_track_uses_short_arc() {
555 let track = Track::new().key(0.0, Rotor4::IDENTITY, Ease::Linear).key(
556 1.0,
557 rotor(Bivector4::new(1.9 * PI, 0.0, 0.0, 0.0, 0.0, 0.0)),
558 Ease::Linear,
559 );
560 let midpoint = track.sample(30, FPS).unwrap();
561 let travelled = midpoint.log().magnitude();
562 assert!(
563 (travelled - 0.05 * PI).abs() < 1e-3,
564 "midpoint travelled {travelled} rad"
565 );
566 }
567
568 #[test]
569 fn isoclinic_half_turn_interpolates_rotation_action() {
570 for sign in [-1.0, 1.0] {
571 let to = Rotor4 {
572 s: 0.0,
573 xyzw: sign,
574 ..Rotor4::IDENTITY
575 };
576 let timeline = Timeline {
577 fps: 60,
578 frames: 61,
579 w_slice: None,
580 bodies: vec![BodyTrack {
581 name: "row".to_owned(),
582 position: None,
583 orientation: Some(Track::new().key(0.0, Rotor4::IDENTITY, Ease::Linear).key(
584 1.0,
585 to,
586 Ease::Linear,
587 )),
588 }],
589 };
590 let mut director = Director::new(timeline).unwrap();
591 director.seek(30);
592 let Drive::Directed(midpoint) = director.orientation("row") else {
593 panic!("missing orientation")
594 };
595 for probe in [Vec4::X, Vec4::Y, Vec4::Z, Vec4::W] {
596 let rotated = midpoint.apply(probe);
597 assert!((rotated.length() - 1.0).abs() < 1e-5);
598 assert!((rotated - probe).length() > 0.5);
599 assert!((midpoint.apply(rotated) + probe).length() < 1e-5);
600 }
601 director.seek(60);
602 let Drive::Directed(endpoint) = director.orientation("row") else {
603 panic!("missing orientation")
604 };
605 for probe in [Vec4::X, Vec4::Y, Vec4::Z, Vec4::W] {
606 assert!((endpoint.apply(probe) + probe).length() < 1e-5);
607 }
608 }
609 }
610
611 #[test]
612 fn invalid_rotor_keys_are_rejected() {
613 let half = std::f32::consts::FRAC_1_SQRT_2;
614 for rotor in [
615 Rotor4 {
616 xy: 0.5,
617 ..Rotor4::IDENTITY
618 },
619 Rotor4 {
620 xy: f32::NAN,
621 ..Rotor4::IDENTITY
622 },
623 Rotor4 {
624 xy: f32::INFINITY,
625 ..Rotor4::IDENTITY
626 },
627 Rotor4 {
628 s: half,
629 xyzw: half,
630 ..Rotor4::IDENTITY
631 },
632 Rotor4 {
633 s: 0.0,
634 xy: half,
635 zw: half,
636 ..Rotor4::IDENTITY
637 },
638 ] {
639 let timeline = Timeline {
640 fps: 60,
641 frames: 2,
642 w_slice: None,
643 bodies: vec![BodyTrack {
644 name: "row".to_owned(),
645 position: None,
646 orientation: Some(Track::new().key(0.0, rotor, Ease::Linear)),
647 }],
648 };
649 assert!(matches!(
650 Director::new(timeline).unwrap_err(),
651 TimelineError::NonUnitRotor { index: 0, .. }
652 ));
653 }
654 }
655
656 #[test]
657 fn invalid_timelines_are_rejected() {
658 let base = || Timeline {
659 fps: 60,
660 frames: 60,
661 w_slice: None,
662 bodies: Vec::new(),
663 };
664
665 let mut zero_fps = base();
666 zero_fps.fps = 0;
667 assert!(matches!(
668 Director::new(zero_fps).unwrap_err(),
669 TimelineError::ZeroFps
670 ));
671
672 let mut zero_frames = base();
673 zero_frames.frames = 0;
674 assert!(matches!(
675 Director::new(zero_frames).unwrap_err(),
676 TimelineError::ZeroFrames
677 ));
678
679 let mut empty = base();
680 empty.w_slice = Some(Track::new());
681 assert!(matches!(
682 Director::new(empty).unwrap_err(),
683 TimelineError::EmptyTrack { .. }
684 ));
685
686 for time in [0.5, 1.0] {
687 let mut unordered = base();
688 unordered.w_slice = Some(Track::new().key(1.0, 0.0, Ease::Linear).key(
689 time,
690 1.0,
691 Ease::Linear,
692 ));
693 assert!(matches!(
694 Director::new(unordered).unwrap_err(),
695 TimelineError::KeyOrder { index: 1, .. }
696 ));
697 }
698
699 for time in [-1.0, f32::NAN, f32::INFINITY] {
700 let mut invalid = base();
701 invalid.w_slice = Some(Track::new().key(time, 0.0, Ease::Linear));
702 assert!(matches!(
703 Director::new(invalid).unwrap_err(),
704 TimelineError::KeyTime { index: 0, .. }
705 ));
706 }
707
708 let mut duplicate = base();
709 duplicate.bodies = vec![
710 BodyTrack {
711 name: "row".to_owned(),
712 position: None,
713 orientation: None,
714 },
715 BodyTrack {
716 name: "row".to_owned(),
717 position: None,
718 orientation: None,
719 },
720 ];
721 assert!(matches!(
722 Director::new(duplicate).unwrap_err(),
723 TimelineError::DuplicateBody { .. }
724 ));
725 }
726
727 #[test]
728 fn ron_defaults_load_into_directed_channels() {
729 let text = r#"(
730 fps: 30,
731 frames: 90,
732 w_slice: Some([
733 (t: 0.0, value: -2.0),
734 (t: 3.0, value: 2.0, ease: InOutCubic),
735 ]),
736 bodies: [
737 (
738 name: "letter_l",
739 position: Some([
740 (t: 0.0, value: (0.0, 0.0, 0.0, -4.0)),
741 (t: 2.0, value: (0.0, 0.0, 0.0, 0.0), ease: OutCubic),
742 ]),
743 orientation: Some([
744 (t: 0.0, value: (s: 1.0, xy: 0.0, xz: 0.0, xw: 0.0,
745 yz: 0.0, yw: 0.0, zw: 0.0, xyzw: 0.0)),
746 ]),
747 ),
748 ],
749 )"#;
750 let director = Director::from_ron(text).unwrap();
751 assert_eq!(director.bodies().collect::<Vec<_>>(), ["letter_l"]);
752 assert_eq!(director.w_slice(), Drive::Directed(-2.0));
753 assert_eq!(
754 director.position("letter_l"),
755 Drive::Directed(Vec4::new(0.0, 0.0, 0.0, -4.0))
756 );
757 assert_eq!(
758 director.orientation("letter_l"),
759 Drive::Directed(Rotor4::IDENTITY)
760 );
761 }
762
763 #[test]
764 fn missing_channels_remain_host_driven() {
765 let timeline = Timeline {
766 fps: 60,
767 frames: 60,
768 w_slice: None,
769 bodies: vec![BodyTrack {
770 name: "row".to_owned(),
771 position: None,
772 orientation: Some(Track::new().key(0.0, Rotor4::IDENTITY, Ease::Linear)),
773 }],
774 };
775 let director = Director::new(timeline).unwrap();
776 assert_eq!(director.w_slice(), Drive::Host);
777 assert_eq!(director.position("row"), Drive::Host);
778 assert_eq!(director.orientation("unnamed"), Drive::Host);
779 assert!(matches!(director.orientation("row"), Drive::Directed(_)));
780 }
781}