Skip to main content

renamite_player/
lib.rs

1//! Headless runtime for `.ren` projects.
2//!
3//! Two entry points, one code path:
4//!
5//! * [`Engine`] - playback state only. Borrows `&RenFile` per call. This is what
6//!   the editor embeds: History can mutate the document between ticks and the
7//!   engine keeps its machine state, hover, and playhead.
8//! * [`Player`] - owns a `RenFile` and wraps an `Engine`. Drop-in for apps,
9//!   games, and the future web embed.
10//!
11//! Compositing rule (locked): in machine mode the document's main timeline
12//! plays as a background layer and machine `Overrides` win on top of it -
13//! which `evaluate_with` already guarantees, since overrides beat keyframes.
14
15use glam::DVec2;
16use renamite_animation::{FrameRate, LoopMode, PlayState, Playback};
17use renamite_io_ren::RenFile;
18use renamite_machine::{InputValue, MachineId, MachineInstance, PointerEventKind};
19use renamite_model::{
20    CompId, NodeId, Overrides, PropPath, Scene, Value, evaluate_with, pick_selectable_with_leaf,
21};
22
23pub use renamite_model::{nodes_bounds, pick_box};
24
25#[derive(Debug, thiserror::Error)]
26pub enum PlayerError {
27    #[error(transparent)]
28    Ren(#[from] renamite_io_ren::RenError),
29    #[error("composition not found in project")]
30    MissingComp,
31    #[error("binary .renb support requires the `binary` feature")]
32    BinaryDisabled,
33    #[error("input is not valid UTF-8 .ren text")]
34    InvalidUtf8,
35}
36
37/// What drives the evaluated frame and the override patch.
38pub enum PlayMode {
39    /// Machine overrides composited over a playing background timeline.
40    Machine {
41        id: MachineId,
42        instance: MachineInstance,
43        background: Playback,
44    },
45    /// Plain timeline playback.
46    Timeline { playback: Playback },
47    /// Editor scrub: locked to an exact frame. Ticking does not advance.
48    Scrub { frame: f64 },
49}
50
51/// Playback state with no document data. Borrows `&RenFile` per call, so the
52/// owner (editor or `Player`) is free to mutate the project between calls.
53pub struct Engine {
54    comp: CompId,
55    rate: FrameRate,
56    pub mode: PlayMode,
57    paused: bool,
58
59    ov: Overrides,               // scratch, reused every tick
60    host_ov: Overrides,          // durable host-driven patch; wins over machine/timeline
61    scene: Scene,                // last evaluated frame; also the pick surface
62    hover: Option<PointerHit>,   // Enter/Exit synthesis
63    pressed: Option<PointerHit>, // Click synthesis
64    events: Vec<String>,         // fired this tick, read by host
65}
66
67#[derive(Clone, Copy, Debug, PartialEq, Eq)]
68struct PointerHit {
69    outer: NodeId,
70    leaf: NodeId,
71}
72
73impl Engine {
74    /// Machine mode iff `start_machine` names a real machine. Else timeline.
75    pub fn new(project: &RenFile) -> Result<Self, PlayerError> {
76        let comp = project.document.main;
77        let c = project
78            .document
79            .compositions
80            .get(comp)
81            .ok_or(PlayerError::MissingComp)?;
82        let playing = Playback {
83            state: PlayState::Playing,
84            head: c.range.0.0 as f64,
85            loop_mode: LoopMode::Loop,
86            range: c.range,
87            dir: 1.0,
88        };
89        let mode = match project.start_machine {
90            Some(id) if project.machines.contains_key(id) => PlayMode::Machine {
91                id,
92                instance: MachineInstance::new(&project.machines[id]),
93                background: playing,
94            },
95            _ => PlayMode::Timeline { playback: playing },
96        };
97        let mut e = Self {
98            comp,
99            rate: c.rate,
100            mode,
101            paused: false,
102            ov: Overrides::default(),
103            host_ov: Overrides::default(),
104            scene: Scene::default(),
105            hover: None,
106            pressed: None,
107            events: Vec::new(),
108        };
109        e.reevaluate(project);
110        Ok(e)
111    }
112
113    /// Advance by `dt_secs`, re-evaluate, return events fired this tick.
114    ///
115    /// While paused, positive-dt ticks are no-ops (the scene is left as-is).
116    pub fn tick(&mut self, project: &RenFile, dt_secs: f64) -> &[String] {
117        self.events.clear();
118        if !dt_secs.is_finite() || dt_secs < 0.0 {
119            return &self.events;
120        }
121        if self.paused {
122            if dt_secs == 0.0 {
123                self.ov.clear();
124                if let PlayMode::Machine { id, instance, .. } = &mut self.mode
125                    && let Some(m) = project.machines.get(*id)
126                {
127                    let out = instance.tick(m, &project.clips, 0.0, &mut self.ov);
128                    self.events.extend(out.events);
129                }
130                self.apply_host_overrides();
131                let head = self.head();
132                self.scene = evaluate_with(&project.document, self.comp, head, &self.ov);
133            }
134            return &self.events;
135        }
136        if let Some(c) = project.document.compositions.get(self.comp) {
137            self.rate = c.rate;
138        }
139        let dt_frames = dt_secs * self.rate.fps();
140        if !dt_frames.is_finite() {
141            return &self.events;
142        }
143        self.ov.clear();
144
145        let frame = match &mut self.mode {
146            PlayMode::Machine {
147                id,
148                instance,
149                background,
150            } => {
151                background.advance(dt_secs, self.rate);
152                if let Some(m) = project.machines.get(*id) {
153                    let out = instance.tick(m, &project.clips, dt_frames, &mut self.ov);
154                    self.events.extend(out.events);
155                }
156                background.head
157            }
158            PlayMode::Timeline { playback } => {
159                playback.advance(dt_secs, self.rate);
160                playback.head
161            }
162            PlayMode::Scrub { frame } => *frame,
163        };
164
165        self.apply_host_overrides();
166        self.scene = evaluate_with(&project.document, self.comp, frame, &self.ov);
167        &self.events
168    }
169
170    fn apply_host_overrides(&mut self) {
171        for (id, prop, value) in self.host_ov.iter() {
172            self.ov.set_str(id, prop, value.clone());
173        }
174    }
175
176    /// Re-evaluate at the current head without advancing time. Call after any
177    /// editor mutation of the document (History apply/undo/redo).
178    pub fn reevaluate(&mut self, project: &RenFile) {
179        self.events.clear();
180        if let Some(c) = project.document.compositions.get(self.comp) {
181            self.rate = c.rate;
182        }
183        self.ov.clear();
184        if let PlayMode::Machine { id, instance, .. } = &mut self.mode
185            && let Some(m) = project.machines.get(*id)
186        {
187            let _ = instance.reevaluate(m, &project.clips, &mut self.ov);
188        }
189        self.apply_host_overrides();
190        self.scene = evaluate_with(&project.document, self.comp, self.head(), &self.ov);
191    }
192
193    /// Patch a property for this frame and onward (e.g. game tint / bow stretch).
194    /// Host overrides survive `tick` and win over machine overrides. Call
195    /// [`Player::set_host_override`] to have the scene update immediately.
196    pub fn set_host_override(&mut self, id: NodeId, prop: PropPath, v: Value) {
197        self.host_ov.set(id, prop, v);
198    }
199
200    /// Drop every host override patch.
201    pub fn clear_host_overrides(&mut self) {
202        self.host_ov.clear();
203    }
204
205    /// Deterministic capture: N frames at fixed dt (golden tests, export).
206    pub fn bake(&mut self, project: &RenFile, frames: usize, dt_secs: f64) -> Vec<Scene> {
207        (0..frames)
208            .map(|_| {
209                self.tick(project, dt_secs);
210                self.scene.clone()
211            })
212            .collect()
213    }
214
215    pub fn play_machine(&mut self, project: &RenFile, id: MachineId) -> bool {
216        if !project.machines.contains_key(id) {
217            return false;
218        }
219        let background = self.playing_playback(project);
220        self.mode = PlayMode::Machine {
221            id,
222            instance: MachineInstance::new(&project.machines[id]),
223            background,
224        };
225        self.hover = None;
226        self.pressed = None;
227        true
228    }
229
230    pub fn play_timeline(&mut self, project: &RenFile, loop_mode: LoopMode) {
231        let Some(range) = project
232            .document
233            .compositions
234            .get(self.comp)
235            .map(|composition| composition.range)
236        else {
237            return;
238        };
239        let head = match &self.mode {
240            PlayMode::Timeline { playback } if playback.range == range => playback
241                .head
242                .clamp(range.0.0 as f64, (range.1.0 as f64).max(range.0.0 as f64)),
243            PlayMode::Machine { background, .. } if background.range == range => background
244                .head
245                .clamp(range.0.0 as f64, (range.1.0 as f64).max(range.0.0 as f64)),
246            _ => range.0.0 as f64,
247        };
248        self.mode = PlayMode::Timeline {
249            playback: Playback {
250                state: PlayState::Playing,
251                head,
252                loop_mode,
253                range,
254                dir: 1.0,
255            },
256        };
257        self.hover = None;
258        self.pressed = None;
259    }
260
261    pub fn set_timeline_playback(&mut self, pb: Playback) {
262        match &mut self.mode {
263            PlayMode::Timeline { playback } => *playback = pb,
264            PlayMode::Scrub { .. } => {
265                self.mode = PlayMode::Timeline { playback: pb };
266                self.hover = None;
267                self.pressed = None;
268            }
269            PlayMode::Machine { .. } => {}
270        }
271    }
272
273    /// Lock to a frame (editor scrubbing). Machine state is discarded.
274    pub fn scrub(&mut self, project: &RenFile, frame: f64) {
275        if !frame.is_finite() {
276            return;
277        }
278        self.mode = PlayMode::Scrub { frame };
279        self.reevaluate(project);
280    }
281
282    /// Switch the active composition (artboard). Resets playback to its range.
283    pub fn set_composition(&mut self, project: &RenFile, comp: CompId) -> bool {
284        let Some(c) = project.document.compositions.get(comp) else {
285            return false;
286        };
287        self.comp = comp;
288        self.rate = c.rate;
289        self.play_timeline(project, LoopMode::Loop);
290        self.reevaluate(project);
291        true
292    }
293
294    pub fn pause(&mut self) {
295        self.paused = true;
296    }
297    pub fn resume(&mut self) {
298        self.paused = false;
299    }
300    pub fn is_paused(&self) -> bool {
301        self.paused
302    }
303
304    fn playing_playback(&self, project: &RenFile) -> Playback {
305        let range = project
306            .document
307            .compositions
308            .get(self.comp)
309            .map(|composition| composition.range)
310            .unwrap_or((renamite_animation::Frame(0), renamite_animation::Frame(180)));
311        let head = match &self.mode {
312            PlayMode::Timeline { playback } if playback.range == range => playback.head,
313            PlayMode::Machine { background, .. } if background.range == range => background.head,
314            PlayMode::Scrub { frame } => *frame,
315            _ => range.0.0 as f64,
316        };
317        let head = head.clamp(range.0.0 as f64, (range.1.0 as f64).max(range.0.0 as f64));
318        Playback {
319            state: PlayState::Playing,
320            head,
321            loop_mode: LoopMode::Loop,
322            range,
323            dir: 1.0,
324        }
325    }
326
327    pub fn scene(&self) -> &Scene {
328        &self.scene
329    }
330    pub fn events(&self) -> &[String] {
331        &self.events
332    }
333    pub fn rate(&self) -> FrameRate {
334        self.rate
335    }
336    pub fn composition(&self) -> CompId {
337        self.comp
338    }
339
340    pub fn head(&self) -> f64 {
341        match &self.mode {
342            PlayMode::Machine { background, .. } => background.head,
343            PlayMode::Timeline { playback } => playback.head,
344            PlayMode::Scrub { frame } => *frame,
345        }
346    }
347
348    /// Active state index per machine layer, or `None` when not in machine mode.
349    pub fn active_machine_states(&self) -> Option<Vec<usize>> {
350        let PlayMode::Machine { instance, .. } = &self.mode else {
351            return None;
352        };
353
354        Some(instance.layer_states().collect())
355    }
356
357    pub fn machine_inputs(&self) -> Option<&[InputValue]> {
358        match &self.mode {
359            PlayMode::Machine { instance, .. } => Some(&instance.inputs),
360            _ => None,
361        }
362    }
363
364    pub fn apply_machine_inputs(&mut self, inputs: &[InputValue]) {
365        let PlayMode::Machine { instance, .. } = &mut self.mode else {
366            return;
367        };
368        for (dst, src) in instance.inputs.iter_mut().zip(inputs.iter()) {
369            match (dst, src) {
370                (InputValue::Bool(a), InputValue::Bool(b)) => *a = *b,
371                (InputValue::Number(a), InputValue::Number(b)) => *a = *b,
372                (InputValue::Trigger { fired: a }, InputValue::Trigger { fired: b }) => *a = *b,
373                _ => {}
374            }
375        }
376    }
377
378    pub fn playing_machine_id(&self) -> Option<MachineId> {
379        match &self.mode {
380            PlayMode::Machine { id, .. } => Some(*id),
381            _ => None,
382        }
383    }
384
385    pub fn set_bool(&mut self, project: &RenFile, name: &str, v: bool) -> bool {
386        self.with_input(project, name, |inst, i| inst.set_bool(i, v))
387    }
388    pub fn set_number(&mut self, project: &RenFile, name: &str, v: f64) -> bool {
389        self.with_input(project, name, |inst, i| inst.set_number(i, v))
390    }
391    pub fn fire(&mut self, project: &RenFile, name: &str) -> bool {
392        self.with_input(project, name, |inst, i| inst.fire(i))
393    }
394
395    fn with_input(
396        &mut self,
397        project: &RenFile,
398        name: &str,
399        f: impl FnOnce(&mut MachineInstance, usize),
400    ) -> bool {
401        if let PlayMode::Machine { id, instance, .. } = &mut self.mode
402            && let Some(m) = project.machines.get(*id)
403            && let Some(i) = MachineInstance::input_index(m, name)
404        {
405            f(instance, i);
406            return true;
407        }
408        false
409    }
410
411    pub fn pointer_move(&mut self, project: &RenFile, pt: DVec2) {
412        let hit = self.pointer_hit(project, pt);
413        if hit != self.hover {
414            if let Some(prev) = self.hover {
415                self.route_hit(project, prev, PointerEventKind::Exit);
416            }
417            if let Some(now) = hit {
418                self.route_hit(project, now, PointerEventKind::Enter);
419            }
420            self.hover = hit;
421        }
422    }
423
424    pub fn pointer_down(&mut self, project: &RenFile, pt: DVec2) {
425        self.pressed = self.pointer_hit(project, pt);
426        if let Some(hit) = self.pressed {
427            self.route_hit(project, hit, PointerEventKind::Down);
428        }
429    }
430
431    pub fn pointer_up(&mut self, project: &RenFile, pt: DVec2) {
432        let release_hit = self.pointer_hit(project, pt);
433        if let Some(pressed) = self.pressed {
434            self.route_hit(project, pressed, PointerEventKind::Up);
435            if release_hit == Some(pressed) {
436                self.route_hit(project, pressed, PointerEventKind::Click);
437            }
438        }
439        self.pressed = None;
440    }
441
442    /// Pointer left the surface: synthesize Exit, clear press state.
443    /// A captured press still gets Up (drag released off-surface).
444    pub fn pointer_leave(&mut self, project: &RenFile) {
445        if let Some(prev) = self.hover.take() {
446            self.route_hit(project, prev, PointerEventKind::Exit);
447        }
448        if let Some(pressed) = self.pressed.take() {
449            self.route_hit(project, pressed, PointerEventKind::Up);
450        }
451    }
452
453    fn pointer_hit(&self, project: &RenFile, pt: DVec2) -> Option<PointerHit> {
454        pick_selectable_with_leaf(&project.document, &self.scene, self.comp, pt)
455            .map(|(outer, leaf)| PointerHit { outer, leaf })
456    }
457
458    fn route_hit(&mut self, project: &RenFile, hit: PointerHit, kind: PointerEventKind) {
459        if let PlayMode::Machine { id, instance, .. } = &mut self.mode
460            && let Some(m) = project.machines.get(*id)
461        {
462            let mut current = Some(hit.leaf);
463            let mut seen = std::collections::HashSet::new();
464            let mut chain = Vec::new();
465            while let Some(node) = current {
466                if !seen.insert(node) {
467                    return;
468                }
469                let Some(entry) = project.document.nodes.get(node) else {
470                    return;
471                };
472                if entry.locked {
473                    return;
474                }
475                chain.push(node);
476                if node == hit.outer {
477                    break;
478                }
479                current = entry.parent;
480            }
481            if !seen.contains(&hit.outer) {
482                return;
483            }
484            for node in chain {
485                instance.pointer_event(m, node, kind);
486            }
487        }
488    }
489}
490
491/// Topmost pickable item under `pt` (world space). Moved to `renamite-model`
492/// so canvas behaviors can hit-test without pulling the player in.
493/// See [`renamite_model::pick`].
494/// Self-contained player: owns the project, hides the borrow plumbing.
495pub struct Player {
496    pub project: RenFile,
497    pub engine: Engine,
498}
499
500impl Player {
501    pub fn new(project: RenFile) -> Result<Self, PlayerError> {
502        let engine = Engine::new(&project)?;
503        Ok(Self { project, engine })
504    }
505
506    pub fn from_ren_str(text: &str) -> Result<Self, PlayerError> {
507        Self::new(renamite_io_ren::open(text)?)
508    }
509
510    #[cfg(feature = "binary")]
511    pub fn from_ren_bytes(bytes: &[u8]) -> Result<Self, PlayerError> {
512        Self::new(renamite_io_ren::open_binary(bytes)?)
513    }
514
515    /// Load from raw bytes, auto-detecting `.ren` (text) vs `.renb` (binary)
516    /// by magic header. `.renb` requires the `binary` feature.
517    pub fn from_bytes(bytes: &[u8]) -> Result<Self, PlayerError> {
518        if renamite_io_ren::is_binary(bytes) {
519            #[cfg(feature = "binary")]
520            {
521                return Self::from_ren_bytes(bytes);
522            }
523            #[cfg(not(feature = "binary"))]
524            {
525                return Err(PlayerError::BinaryDisabled);
526            }
527        }
528        if bytes.len() > renamite_io_ren::MAX_TEXT_BYTES {
529            return Err(renamite_io_ren::RenError::InputLimit("text input is too large").into());
530        }
531        let text = std::str::from_utf8(bytes).map_err(|_| PlayerError::InvalidUtf8)?;
532        Self::from_ren_str(text)
533    }
534
535    pub fn tick(&mut self, dt_secs: f64) -> &[String] {
536        self.engine.tick(&self.project, dt_secs)
537    }
538    pub fn bake(&mut self, frames: usize, dt_secs: f64) -> Vec<Scene> {
539        self.engine.bake(&self.project, frames, dt_secs)
540    }
541    pub fn scene(&self) -> &Scene {
542        self.engine.scene()
543    }
544    pub fn events(&self) -> &[String] {
545        self.engine.events()
546    }
547    pub fn head(&self) -> f64 {
548        self.engine.head()
549    }
550    pub fn rate(&self) -> FrameRate {
551        self.engine.rate()
552    }
553    pub fn composition(&self) -> CompId {
554        self.engine.composition()
555    }
556    pub fn active_machine_states(&self) -> Option<Vec<usize>> {
557        self.engine.active_machine_states()
558    }
559
560    /// Pixel size of the active composition's artboard.
561    pub fn artboard_size(&self) -> (u32, u32) {
562        self.project
563            .document
564            .compositions
565            .get(self.engine.composition())
566            .map(|composition| composition.size)
567            .unwrap_or((1, 1))
568    }
569
570    /// Re-evaluate at the current head without advancing time. Call after any
571    /// mutation of `self.project` (e.g. live edits from the embed host).
572    pub fn reevaluate(&mut self) {
573        self.engine.reevaluate(&self.project);
574    }
575
576    pub fn pause(&mut self) {
577        self.engine.pause();
578    }
579    pub fn resume(&mut self) {
580        self.engine.resume();
581    }
582    pub fn is_paused(&self) -> bool {
583        self.engine.is_paused()
584    }
585
586    /// Lock to a frame. Machine state is discarded.
587    pub fn scrub(&mut self, frame: f64) {
588        self.engine.scrub(&self.project, frame);
589    }
590
591    /// Switch the active composition (artboard). Resets playback to its range.
592    pub fn set_composition(&mut self, comp: CompId) -> bool {
593        self.engine.set_composition(&self.project, comp)
594    }
595
596    /// Patch a property for this frame and onward (e.g. game tint / bow stretch).
597    /// Host overrides survive `tick`, win over machine overrides, and take
598    /// effect immediately (the scene is re-evaluated here).
599    pub fn set_host_override(&mut self, id: NodeId, prop: PropPath, v: Value) {
600        self.engine.set_host_override(id, prop, v);
601        self.engine.reevaluate(&self.project);
602    }
603
604    /// Drop every host override patch and re-evaluate.
605    pub fn clear_host_overrides(&mut self) {
606        self.engine.clear_host_overrides();
607        self.engine.reevaluate(&self.project);
608    }
609
610    pub fn set_bool(&mut self, name: &str, v: bool) -> bool {
611        self.engine.set_bool(&self.project, name, v)
612    }
613    pub fn set_number(&mut self, name: &str, v: f64) -> bool {
614        self.engine.set_number(&self.project, name, v)
615    }
616    pub fn fire(&mut self, name: &str) -> bool {
617        self.engine.fire(&self.project, name)
618    }
619
620    /// Pointer events apply machine listener side-effects immediately: each
621    /// routes the pick, then re-evaluates with a zero-dt tick (editor parity).
622    pub fn pointer_move(&mut self, pt: DVec2) {
623        self.engine.pointer_move(&self.project, pt);
624        let _ = self.engine.tick(&self.project, 0.0);
625    }
626    pub fn pointer_down(&mut self, pt: DVec2) {
627        self.engine.pointer_down(&self.project, pt);
628        let _ = self.engine.tick(&self.project, 0.0);
629    }
630    pub fn pointer_up(&mut self, pt: DVec2) {
631        self.engine.pointer_up(&self.project, pt);
632        let _ = self.engine.tick(&self.project, 0.0);
633    }
634    pub fn pointer_leave(&mut self) {
635        self.engine.pointer_leave(&self.project);
636        let _ = self.engine.tick(&self.project, 0.0);
637    }
638
639    pub fn play_machine(&mut self, id: MachineId) -> bool {
640        self.engine.play_machine(&self.project, id)
641    }
642    pub fn play_timeline(&mut self, loop_mode: LoopMode) {
643        self.engine.play_timeline(&self.project, loop_mode)
644    }
645    pub fn set_timeline_playback(&mut self, pb: Playback) {
646        self.engine.set_timeline_playback(pb)
647    }
648}
649
650#[cfg(test)]
651mod tests {
652    use super::*;
653    use kurbo::Shape;
654    use renamite_animation::{Animated, EasingHandle, Frame, Interpolation};
655    use renamite_machine::{
656        Clip, ClipMap, Condition, InputDef, InputKind, Listener, ListenerAction, Machine,
657        MachineLayer, MachineMap, State, StateKind, Track, Transition,
658    };
659    use renamite_model::{
660        Color, Document, FillRule, KeyframeData, Node, NodeKind, Parent, PropPath, ScenePaint,
661        ShapeKind, StyleKind, StylePaint, Value,
662    };
663
664    fn rect_kind(size: f64) -> NodeKind {
665        NodeKind::Shape(ShapeKind::Rect {
666            pos: Animated::new(DVec2::ZERO),
667            size: Animated::new(DVec2::splat(size)),
668            rounded: Animated::new(0.0),
669        })
670    }
671    fn fill_kind() -> NodeKind {
672        NodeKind::Style(StyleKind::Fill {
673            paint: StylePaint::solid(Color::BLACK),
674            rule: FillRule::NonZero,
675        })
676    }
677    fn key_v2(f: i64, x: f64, y: f64) -> KeyframeData {
678        KeyframeData {
679            frame: Frame(f),
680            value: Value::DVec2(DVec2::new(x, y)),
681            interpolation: Interpolation::Linear,
682            ease_out: EasingHandle::LINEAR_OUT,
683            ease_in: EasingHandle::LINEAR_IN,
684        }
685    }
686
687    fn center_x(s: &Scene) -> f64 {
688        s.items[0].path.bounding_box().center().x
689    }
690
691    /// 100x100 box + fill under main. Returns (project, shape, fill).
692    fn static_box() -> (RenFile, NodeId, NodeId) {
693        let mut doc = Document::empty();
694        let comp = doc.main;
695        let shape = doc.create_node(Node::new("box", rect_kind(100.0)));
696        doc.attach(shape, Parent::Comp(comp), 0).unwrap();
697        let fill = doc.create_node(Node::new("fill", fill_kind()));
698        doc.attach(fill, Parent::Comp(comp), 1).unwrap();
699        (RenFile::new(doc, "static"), shape, fill)
700    }
701
702    /// static_box + machine: `go` moves the box to x=50 over 10 frames.
703    fn moving_box() -> (RenFile, NodeId, NodeId) {
704        let (mut f, shape, fill) = static_box();
705        let mut clips = ClipMap::default();
706        let mv = clips.insert(Clip {
707            name: "move".into(),
708            range: (Frame(0), Frame(10)),
709            tracks: vec![Track {
710                node: shape,
711                prop: PropPath::new("transform.position"),
712                keys: vec![key_v2(0, 0.0, 0.0), key_v2(10, 50.0, 0.0)],
713            }],
714            events: vec![],
715        });
716        let machine = Machine {
717            name: "hover".into(),
718            inputs: vec![InputDef {
719                name: "go".into(),
720                kind: InputKind::Bool { default: false },
721            }],
722            layers: vec![MachineLayer {
723                name: "base".into(),
724                entry: 0,
725                any_transitions: vec![],
726                blend: Default::default(),
727                states: vec![
728                    State {
729                        name: "Idle".into(),
730                        kind: StateKind::Empty,
731                        transitions: vec![Transition {
732                            to: 1,
733                            duration: 0.0,
734                            exit_time: None,
735                            conditions: vec![Condition::BoolIs {
736                                input: 0,
737                                value: true,
738                            }],
739                        }],
740                        graph_pos: None,
741                        sync_group: None,
742                    },
743                    State {
744                        name: "Move".into(),
745                        kind: StateKind::Clip {
746                            clip: mv,
747                            speed: 1.0,
748                            loop_mode: LoopMode::Once,
749                        },
750                        transitions: vec![],
751                        graph_pos: None,
752                        sync_group: None,
753                    },
754                ],
755            }],
756            listeners: vec![Listener {
757                node: shape,
758                event: PointerEventKind::Enter,
759                action: ListenerAction::SetBool {
760                    input: 0,
761                    value: true,
762                },
763            }],
764            root_motion: None,
765        };
766        let mut machines = MachineMap::default();
767        let id = machines.insert(machine);
768        f.clips = clips;
769        f.machines = machines;
770        f.start_machine = Some(id);
771        (f, shape, fill)
772    }
773
774    #[test]
775    fn timeline_plays_and_renders() {
776        let (proj, _, _) = static_box();
777        let mut p = Player::new(proj).unwrap();
778        assert_eq!(p.scene().items.len(), 1);
779        let h0 = p.head();
780        p.tick(0.5);
781        assert!(p.head() > h0);
782    }
783
784    /// THE design-pinning test: document timeline animates opacity underneath,
785    /// machine overrides position on top - both visible in the same item.
786    #[test]
787    fn machine_composites_over_background_timeline() {
788        let (mut proj, _, fill) = moving_box();
789        let prop = PropPath::new("opacity");
790        proj.document
791            .add_keyframe(fill, &prop, Frame(0), &Value::F64(0.0))
792            .unwrap();
793        proj.document
794            .add_keyframe(fill, &prop, Frame(60), &Value::F64(1.0))
795            .unwrap();
796
797        let mut p = Player::new(proj).unwrap();
798        assert!(p.set_bool("go", true));
799        p.tick(0.25); // transition fires, background head=15
800        p.tick(0.25); // machine time 15 > clip len → clamped x=50; head=30
801
802        let item = &p.scene().items[0];
803        assert!(
804            (center_x(p.scene()) - 50.0).abs() < 1.0,
805            "machine override applied"
806        );
807        assert!(
808            (item.opacity - 0.5).abs() < 0.02,
809            "background timeline still animating (opacity={})",
810            item.opacity
811        );
812    }
813
814    #[test]
815    fn host_override_wins_over_machine_and_survives_tick() {
816        let (proj, shape, _) = moving_box();
817        let mut p = Player::new(proj).unwrap();
818        assert!(p.set_bool("go", true));
819        p.tick(0.5); // transition fires, machine starts
820        p.tick(0.5); // machine time > clip len → clamped x=50
821        assert!((center_x(p.scene()) - 50.0).abs() < 1.0);
822
823        // Host pins position; must win over the machine's override next tick.
824        p.set_host_override(
825            shape,
826            PropPath::new("transform.position"),
827            Value::DVec2(DVec2::new(120.0, 0.0)),
828        );
829        assert!(
830            (center_x(p.scene()) - 120.0).abs() < 1e-6,
831            "set_host_override re-evaluates immediately"
832        );
833        p.tick(0.25);
834        assert!(
835            (center_x(p.scene()) - 120.0).abs() < 1e-6,
836            "host override survives tick"
837        );
838
839        p.clear_host_overrides();
840        p.tick(0.25);
841        assert!(
842            (center_x(p.scene()) - 50.0).abs() < 1.0,
843            "machine override returns after clear"
844        );
845    }
846
847    #[test]
848    fn scrub_locks_frame() {
849        let (mut proj, _, fill) = static_box();
850        let prop = PropPath::new("opacity");
851        proj.document
852            .add_keyframe(fill, &prop, Frame(0), &Value::F64(0.0))
853            .unwrap();
854        proj.document
855            .add_keyframe(fill, &prop, Frame(60), &Value::F64(1.0))
856            .unwrap();
857
858        let mut e = Engine::new(&proj).unwrap();
859        e.scrub(&proj, 30.0);
860        e.tick(&proj, 1.0); // must not advance
861        assert_eq!(e.head(), 30.0);
862        assert!((e.scene().items[0].opacity - 0.5).abs() < 1e-9);
863    }
864
865    #[test]
866    fn hover_enter_drives_listener_and_leave_exits() {
867        let (proj, _, _) = moving_box();
868        let mut p = Player::new(proj).unwrap();
869        p.tick(0.1);
870        assert!(center_x(p.scene()).abs() < 1e-6);
871
872        p.pointer_move(DVec2::ZERO); // Enter → go=true
873        for _ in 0..5 {
874            p.tick(0.1);
875        }
876        assert!(center_x(p.scene()) > 10.0);
877        p.pointer_leave(); // must not panic / must clear hover state
878    }
879
880    /// THE ownership-pinning test: mutate the document mid-playback. The
881    /// engine keeps machine state and reflects the edit on the next tick.
882    #[test]
883    fn live_edit_between_ticks_preserves_machine_state() {
884        let (mut proj, _, fill) = moving_box();
885        let mut e = Engine::new(&proj).unwrap();
886        e.set_bool(&proj, "go", true);
887        e.tick(&proj, 0.25);
888        e.tick(&proj, 0.25);
889        assert!((center_x(e.scene()) - 50.0).abs() < 1.0);
890
891        let red = Color::rgba(1.0, 0.0, 0.0, 1.0);
892        proj.document
893            .set_static(fill, &PropPath::new("fill.color"), &Value::Color(red))
894            .unwrap();
895
896        e.tick(&proj, 0.01);
897        let item = &e.scene().items[0];
898        assert_eq!(
899            item.paint,
900            ScenePaint::Solid(red),
901            "edit visible immediately"
902        );
903        assert!(
904            (center_x(e.scene()) - 50.0).abs() < 1.0,
905            "machine override survived the edit"
906        );
907    }
908
909    #[test]
910    fn zero_dt_tick_applies_host_override_while_paused() {
911        let (proj, shape, _) = static_box();
912        let mut p = Player::new(proj).unwrap();
913        p.pause();
914        assert!(center_x(p.scene()).abs() < 1e-6);
915
916        p.engine.set_host_override(
917            shape,
918            PropPath::new("transform.position"),
919            Value::DVec2(DVec2::new(120.0, 0.0)),
920        );
921        p.tick(0.0);
922        assert!(
923            (center_x(p.scene()) - 120.0).abs() < 1e-6,
924            "paused zero-dt tick must re-evaluate, x={}",
925            center_x(p.scene())
926        );
927
928        p.tick(0.5);
929        assert!((center_x(p.scene()) - 120.0).abs() < 1e-6);
930    }
931
932    #[test]
933    fn pointer_transition_applies_while_paused() {
934        let (proj, _, _) = moving_box();
935        let mut p = Player::new(proj).unwrap();
936        p.pause();
937        assert_eq!(p.active_machine_states(), Some(vec![0]));
938
939        p.pointer_move(DVec2::ZERO);
940        assert_eq!(
941            p.active_machine_states(),
942            Some(vec![1]),
943            "transition must apply while paused"
944        );
945    }
946}