1use 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
37pub enum PlayMode {
39 Machine {
41 id: MachineId,
42 instance: MachineInstance,
43 background: Playback,
44 },
45 Timeline { playback: Playback },
47 Scrub { frame: f64 },
49}
50
51pub struct Engine {
54 comp: CompId,
55 rate: FrameRate,
56 pub mode: PlayMode,
57 paused: bool,
58
59 ov: Overrides, host_ov: Overrides, scene: Scene, hover: Option<PointerHit>, pressed: Option<PointerHit>, events: Vec<String>, }
66
67#[derive(Clone, Copy, Debug, PartialEq, Eq)]
68struct PointerHit {
69 outer: NodeId,
70 leaf: NodeId,
71}
72
73impl Engine {
74 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 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 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 pub fn set_host_override(&mut self, id: NodeId, prop: PropPath, v: Value) {
197 self.host_ov.set(id, prop, v);
198 }
199
200 pub fn clear_host_overrides(&mut self) {
202 self.host_ov.clear();
203 }
204
205 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 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 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 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 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
491pub 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 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 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 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 pub fn scrub(&mut self, frame: f64) {
588 self.engine.scrub(&self.project, frame);
589 }
590
591 pub fn set_composition(&mut self, comp: CompId) -> bool {
593 self.engine.set_composition(&self.project, comp)
594 }
595
596 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 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 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 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 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 #[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); p.tick(0.25); 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); p.tick(0.5); assert!((center_x(p.scene()) - 50.0).abs() < 1.0);
822
823 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); 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); for _ in 0..5 {
874 p.tick(0.1);
875 }
876 assert!(center_x(p.scene()) > 10.0);
877 p.pointer_leave(); }
879
880 #[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}