Skip to main content

loam_time/
director.rs

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    // Geodesic R₀·exp(u·log(R₀⁻¹·R₁)), the SO(4) slerp (Shoemake 1985, §3).
54    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/// `t` in timeline seconds; `ease` applies to the span reaching this key.
61#[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/// Sampling outside the key range holds the nearest endpoint.
70#[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    /// Requires ordered keys; returns `None` for an empty track.
99    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    /// Zero `frames` reads as one.
243    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/// Paused and finished channels retain their sampled values.
294#[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    /// Preserves timeline order.
357    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}