Skip to main content

renamite_machine/
lib.rs

1//! Clips (named timelines) + state machines. Pure, deterministic, and WASM-safe.
2//!
3//! A `Clip` is a bag of (NodeId, PropPath) -> keyframe tracks. They are the same
4//! `KeyframeData` the history system already uses, so clip authoring reuses
5//! `EditorCommand` semantics later. A `Machine` turns inputs into an
6//! `Overrides` patch per tick. The host feeds that to `evaluate_with`.
7//!
8//! State-machine semantics (exit time, any-state, trigger consumption,
9//! crossfade) are implemented here from first principles / public
10//! documentation of how such runtimes behave generally (though no one asked).
11
12use renamite_animation::{Frame, LoopMode, Tween, ease_progress};
13use renamite_geometry::VectorPath;
14use renamite_model::{KeyframeData, NodeId, Overrides, PropPath, Value};
15use serde::{Deserialize, Serialize};
16use slotmap::SlotMap;
17use std::collections::HashMap;
18
19slotmap::new_key_type! { pub struct ClipId; pub struct MachineId; }
20pub type ClipMap = SlotMap<ClipId, Clip>;
21pub type MachineMap = SlotMap<MachineId, Machine>;
22
23#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
24pub struct Clip {
25    pub name: String,
26    pub range: (Frame, Frame),
27    pub tracks: Vec<Track>,
28    /// Named events fired when the playhead crosses `frame`.
29    pub events: Vec<EventKey>,
30}
31
32#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
33pub struct Track {
34    pub node: NodeId,
35    pub prop: PropPath,
36    /// Invariant: sorted by frame, unique frames.
37    pub keys: Vec<KeyframeData>,
38}
39
40#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
41pub struct EventKey {
42    pub frame: Frame,
43    pub name: String,
44}
45
46/// Tween two Values of the same variant; Hold otherwise (mirrors path rules).
47pub fn value_tween(a: &Value, b: &Value, t: f64) -> Value {
48    use Value::*;
49    match (a, b) {
50        (F64(x), F64(y)) => F64(f64::tween(x, y, t)),
51        (DVec2(x), DVec2(y)) => DVec2(glam::DVec2::tween(x, y, t)),
52        (Angle(x), Angle(y)) => Angle(renamite_animation::Angle::tween(x, y, t)),
53        (Color(x), Color(y)) => Color(renamite_model::Color::tween(x, y, t)),
54        (Path(x), Path(y)) => Path(VectorPath::tween(x, y, t)),
55        _ => {
56            if t < 1.0 {
57                a.clone()
58            } else {
59                b.clone()
60            }
61        }
62    }
63}
64
65impl Track {
66    pub fn value_at(&self, frame: f64) -> Option<Value> {
67        let ks = &self.keys;
68        if ks.is_empty() {
69            return None;
70        }
71        if frame <= ks[0].frame.0 as f64 {
72            return Some(ks[0].value.clone());
73        }
74        let last = ks.len() - 1;
75        if frame >= ks[last].frame.0 as f64 {
76            return Some(ks[last].value.clone());
77        }
78        let i = ks.partition_point(|k| (k.frame.0 as f64) <= frame) - 1;
79        let (a, b) = (&ks[i], &ks[i + 1]);
80        let u = (frame - a.frame.0 as f64) / (b.frame.0 - a.frame.0) as f64;
81        let y = ease_progress(a.interpolation, a.ease_out, a.ease_in, u);
82        Some(value_tween(&a.value, &b.value, y))
83    }
84}
85
86impl Clip {
87    pub fn len_frames(&self) -> f64 {
88        (self.range.1.0 - self.range.0.0).max(1) as f64
89    }
90
91    /// Map layer-local time to a clip frame; returns (frame, normalized 0..1).
92    pub fn local(&self, time: f64, loop_mode: LoopMode) -> (f64, f64) {
93        let (s, len) = (self.range.0.0 as f64, self.len_frames());
94        let t = match loop_mode {
95            LoopMode::Once => time.clamp(0.0, len),
96            LoopMode::Loop => time.rem_euclid(len),
97            LoopMode::PingPong => {
98                let c = time.rem_euclid(2.0 * len);
99                if c > len { 2.0 * len - c } else { c }
100            }
101        };
102        (s + t, t / len)
103    }
104
105    pub fn sample_into(&self, frame: f64, out: &mut HashMap<(NodeId, PropPath), Value>) {
106        for tr in &self.tracks {
107            if let Some(v) = tr.value_at(frame) {
108                out.insert((tr.node, tr.prop.clone()), v);
109            }
110        }
111    }
112}
113
114#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
115pub struct Machine {
116    pub name: String,
117    pub inputs: Vec<InputDef>,
118    pub layers: Vec<MachineLayer>,
119    /// Pointer interactions on scene nodes → input actions. Picking is free:
120    /// `SceneItem` already carries the shape `NodeId`.
121    pub listeners: Vec<Listener>,
122}
123
124#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
125pub struct InputDef {
126    pub name: String,
127    pub kind: InputKind,
128}
129
130#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
131pub enum InputKind {
132    Bool { default: bool },
133    Number { default: f64 },
134    Trigger,
135}
136
137#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
138pub struct MachineLayer {
139    pub name: String,
140    pub states: Vec<State>,
141    pub entry: usize,
142    /// Checked before per-state transitions, from any state (Rive-style Any).
143    pub any_transitions: Vec<Transition>,
144}
145
146#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
147pub struct State {
148    pub name: String,
149    pub kind: StateKind,
150    pub transitions: Vec<Transition>,
151    #[serde(default)]
152    pub graph_pos: Option<(f64, f64)>,
153}
154
155#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
156pub enum StateKind {
157    /// Play one clip.
158    Clip {
159        clip: ClipId,
160        speed: f64,
161        loop_mode: LoopMode,
162    },
163    /// 1D blend across clips by a Number input (walk/run style).
164    Blend1D {
165        input: usize,
166        children: Vec<BlendChild>,
167    },
168    /// No animation (rest pose = document values).
169    Empty,
170}
171
172#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
173pub struct BlendChild {
174    pub threshold: f64,
175    pub clip: ClipId,
176}
177
178#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
179pub struct Transition {
180    pub to: usize,
181    /// Crossfade length in frames (0 = hard cut).
182    pub duration: f64,
183    /// Require normalized state time >= this before firing (None = anytime).
184    pub exit_time: Option<f64>,
185    /// AND-combined. Empty + exit_time = "when finished".
186    pub conditions: Vec<Condition>,
187}
188
189#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
190pub enum Condition {
191    BoolIs { input: usize, value: bool },
192    NumberCmp { input: usize, op: CmpOp, value: f64 },
193    Triggered { input: usize },
194}
195
196#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
197pub enum CmpOp {
198    Eq,
199    Ne,
200    Lt,
201    Le,
202    Gt,
203    Ge,
204}
205
206#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
207pub struct Listener {
208    pub node: NodeId,
209    pub event: PointerEventKind,
210    pub action: ListenerAction,
211}
212
213#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
214pub enum PointerEventKind {
215    Down,
216    Up,
217    Click,
218    Enter,
219    Exit,
220}
221
222#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
223pub enum ListenerAction {
224    SetBool { input: usize, value: bool },
225    ToggleBool { input: usize },
226    SetNumber { input: usize, value: f64 },
227    FireTrigger { input: usize },
228}
229
230#[derive(Clone, Copy, Debug, PartialEq)]
231pub enum InputValue {
232    Bool(bool),
233    Number(f64),
234    Trigger { fired: bool },
235}
236
237#[derive(Clone, Debug)]
238struct LayerRt {
239    current: usize,
240    /// Frames spent in current state.
241    time: f64,
242    fade: Option<Fade>,
243}
244
245#[derive(Clone, Debug)]
246struct Fade {
247    from: usize,
248    from_time: f64,
249    t: f64,
250    duration: f64,
251}
252
253#[derive(Clone, Debug)]
254pub struct MachineInstance {
255    pub inputs: Vec<InputValue>,
256    layers: Vec<LayerRt>,
257}
258
259#[derive(Clone, Debug, Default)]
260pub struct TickOutput {
261    pub events: Vec<String>,
262}
263
264#[derive(Clone, Debug, thiserror::Error)]
265pub enum MachineError {
266    #[error("unknown input `{0}`")]
267    UnknownInput(String),
268    #[error("input type mismatch for `{0}`")]
269    InputType(String),
270}
271
272impl MachineInstance {
273    pub fn new(m: &Machine) -> Self {
274        Self {
275            inputs: m
276                .inputs
277                .iter()
278                .map(|i| match i.kind {
279                    InputKind::Bool { default } => InputValue::Bool(default),
280                    InputKind::Number { default } => InputValue::Number(default),
281                    InputKind::Trigger => InputValue::Trigger { fired: false },
282                })
283                .collect(),
284            layers: m
285                .layers
286                .iter()
287                .map(|l| LayerRt {
288                    current: l.entry.min(l.states.len().saturating_sub(1)),
289                    time: 0.0,
290                    fade: None,
291                })
292                .collect(),
293        }
294    }
295
296    pub fn input_index(m: &Machine, name: &str) -> Option<usize> {
297        m.inputs.iter().position(|i| i.name == name)
298    }
299    /// Current active state per layer, in layer order.
300    pub fn layer_states(&self) -> impl Iterator<Item = usize> + '_ {
301        self.layers.iter().map(|layer| layer.current)
302    }
303    pub fn set_bool(&mut self, idx: usize, v: bool) {
304        if let Some(InputValue::Bool(b)) = self.inputs.get_mut(idx) {
305            *b = v;
306        }
307    }
308    pub fn set_number(&mut self, idx: usize, v: f64) {
309        if let Some(InputValue::Number(n)) = self.inputs.get_mut(idx) {
310            *n = v;
311        }
312    }
313    pub fn fire(&mut self, idx: usize) {
314        if let Some(InputValue::Trigger { fired }) = self.inputs.get_mut(idx) {
315            *fired = true;
316        }
317    }
318
319    /// Route a pointer event on `node` (from Scene picking) through listeners.
320    pub fn pointer_event(&mut self, m: &Machine, node: NodeId, kind: PointerEventKind) {
321        for l in m
322            .listeners
323            .iter()
324            .filter(|l| l.node == node && l.event == kind)
325        {
326            match l.action {
327                ListenerAction::SetBool { input, value } => self.set_bool(input, value),
328                ListenerAction::ToggleBool { input } => {
329                    if let Some(InputValue::Bool(b)) = self.inputs.get_mut(input) {
330                        *b = !*b;
331                    }
332                }
333                ListenerAction::SetNumber { input, value } => self.set_number(input, value),
334                ListenerAction::FireTrigger { input } => self.fire(input),
335            }
336        }
337    }
338
339    /// Advance all layers by dt (frames), writing the merged Overrides patch.
340    /// Triggers are frame-scoped: consumed by firing transitions, cleared at end.
341    pub fn tick(
342        &mut self,
343        m: &Machine,
344        clips: &ClipMap,
345        dt_frames: f64,
346        out: &mut Overrides,
347    ) -> TickOutput {
348        let mut output = TickOutput::default();
349        for (li, layer) in m.layers.iter().enumerate() {
350            let rt = &mut self.layers[li];
351            let prev_time = rt.time;
352            rt.time += dt_frames;
353            if let Some(f) = &mut rt.fade {
354                f.from_time += dt_frames;
355                f.t = if f.duration <= 0.0 {
356                    1.0
357                } else {
358                    (f.t + dt_frames / f.duration).min(1.0)
359                };
360                if f.t >= 1.0 {
361                    rt.fade = None;
362                }
363            }
364
365            // transitions: Any first, then current state's, first match wins
366            let state = &layer.states[rt.current];
367            let norm = normalized_time(state, clips, rt.time);
368            let fired = layer
369                .any_transitions
370                .iter()
371                .chain(state.transitions.iter())
372                .find(|tr| transition_ready(tr, &self.inputs, norm));
373            if let Some(tr) = fired.cloned() {
374                consume_triggers(&tr, &mut self.inputs);
375                rt.fade = (tr.duration > 0.0).then_some(Fade {
376                    from: rt.current,
377                    from_time: rt.time,
378                    t: 0.0,
379                    duration: tr.duration,
380                });
381                rt.current = tr.to.min(layer.states.len() - 1);
382                rt.time = 0.0;
383            }
384
385            // sample
386            let mut b = HashMap::new();
387            let mut evs = Vec::new();
388            sample_state(
389                &layer.states[rt.current],
390                clips,
391                &self.inputs,
392                prev_if_same(prev_time, rt.time),
393                rt.time,
394                &mut b,
395                &mut evs,
396            );
397            if let Some(f) = &rt.fade {
398                let mut a = HashMap::new();
399                sample_state(
400                    &layer.states[f.from],
401                    clips,
402                    &self.inputs,
403                    f.from_time,
404                    f.from_time,
405                    &mut a,
406                    &mut Vec::new(),
407                );
408                for (k, va) in a {
409                    let merged = match b.get(&k) {
410                        Some(vb) => value_tween(&va, vb, f.t),
411                        None => va,
412                    };
413                    b.insert(k, merged);
414                }
415            }
416            for (k, v) in b {
417                out.set(k.0, k.1, v);
418            }
419            output.events.append(&mut evs);
420        }
421        // frame-scoped triggers
422        for i in &mut self.inputs {
423            if let InputValue::Trigger { fired } = i {
424                *fired = false;
425            }
426        }
427        output
428    }
429}
430
431fn prev_if_same(prev: f64, cur: f64) -> f64 {
432    if cur < prev { 0.0 } else { prev }
433}
434
435fn normalized_time(state: &State, clips: &ClipMap, time: f64) -> f64 {
436    match &state.kind {
437        StateKind::Clip {
438            clip,
439            speed,
440            loop_mode,
441        } => clips
442            .get(*clip)
443            .map(|c| c.local(time * speed.max(0.0), *loop_mode).1)
444            .unwrap_or(1.0),
445        StateKind::Blend1D { children, .. } => {
446            if children.is_empty() {
447                return 1.0;
448            }
449            children
450                .first()
451                .and_then(|ch| clips.get(ch.clip))
452                .map(|c| c.local(time, LoopMode::Loop).1)
453                .unwrap_or(1.0)
454        }
455        StateKind::Empty => 1.0,
456    }
457}
458
459fn transition_ready(tr: &Transition, inputs: &[InputValue], norm: f64) -> bool {
460    if let Some(et) = tr.exit_time
461        && norm < et
462    {
463        return false;
464    }
465    if tr.conditions.is_empty() && tr.exit_time.is_none() {
466        return false;
467    }
468    tr.conditions.iter().all(|c| match *c {
469        Condition::BoolIs { input, value } => {
470            matches!(inputs.get(input), Some(InputValue::Bool(b)) if *b == value)
471        }
472        Condition::NumberCmp { input, op, value } => match inputs.get(input) {
473            Some(InputValue::Number(n)) => match op {
474                CmpOp::Eq => (n - value).abs() < 1e-9,
475                CmpOp::Ne => (n - value).abs() >= 1e-9,
476                CmpOp::Lt => *n < value,
477                CmpOp::Le => *n <= value,
478                CmpOp::Gt => *n > value,
479                CmpOp::Ge => *n >= value,
480            },
481            _ => false,
482        },
483        Condition::Triggered { input } => {
484            matches!(inputs.get(input), Some(InputValue::Trigger { fired: true }))
485        }
486    })
487}
488
489fn consume_triggers(tr: &Transition, inputs: &mut [InputValue]) {
490    for c in &tr.conditions {
491        if let Condition::Triggered { input } = c
492            && let Some(InputValue::Trigger { fired }) = inputs.get_mut(*input)
493        {
494            *fired = false;
495        }
496    }
497}
498
499fn sample_state(
500    state: &State,
501    clips: &ClipMap,
502    inputs: &[InputValue],
503    prev_time: f64,
504    time: f64,
505    out: &mut HashMap<(NodeId, PropPath), Value>,
506    events: &mut Vec<String>,
507) {
508    match &state.kind {
509        StateKind::Empty => {}
510        StateKind::Clip {
511            clip,
512            speed,
513            loop_mode,
514        } => {
515            let Some(c) = clips.get(*clip) else { return };
516            let (frame, _) = c.local(time * speed.max(0.0), *loop_mode);
517            let (pframe, _) = c.local(prev_time * speed.max(0.0), *loop_mode);
518            c.sample_into(frame, out);
519            emit_events(c, pframe, frame, *loop_mode, events);
520        }
521        StateKind::Blend1D { input, children } => {
522            if children.is_empty() {
523                return;
524            }
525            let x = match inputs.get(*input) {
526                Some(InputValue::Number(n)) => *n,
527                _ => 0.0,
528            };
529            let (lo, hi, t) = bracket(children, x);
530            let mut a = HashMap::new();
531            if let Some(c) = clips.get(children[lo].clip) {
532                c.sample_into(c.local(time, LoopMode::Loop).0, &mut a);
533            }
534            if hi != lo {
535                let mut b = HashMap::new();
536                if let Some(c) = clips.get(children[hi].clip) {
537                    c.sample_into(c.local(time, LoopMode::Loop).0, &mut b);
538                }
539                for (k, va) in a {
540                    let v = match b.remove(&k) {
541                        Some(vb) => value_tween(&va, &vb, t),
542                        None => va,
543                    };
544                    out.insert(k, v);
545                }
546                out.extend(b);
547            } else {
548                out.extend(a);
549            }
550        }
551    }
552}
553
554/// Index pair + blend factor for x among sorted thresholds.
555fn bracket(children: &[BlendChild], x: f64) -> (usize, usize, f64) {
556    if children.is_empty() {
557        return (0, 0, 0.0);
558    }
559    if x <= children[0].threshold {
560        return (0, 0, 0.0);
561    }
562    let last = children.len() - 1;
563    if x >= children[last].threshold {
564        return (last, last, 0.0);
565    }
566    let hi = children.partition_point(|c| c.threshold <= x);
567    let (lo, hi) = (hi - 1, hi);
568    let span = children[hi].threshold - children[lo].threshold;
569    (
570        lo,
571        hi,
572        if span <= 0.0 {
573            0.0
574        } else {
575            (x - children[lo].threshold) / span
576        },
577    )
578}
579
580fn emit_events(c: &Clip, prev: f64, cur: f64, loop_mode: LoopMode, out: &mut Vec<String>) {
581    let hit = |a: f64, b: f64, out: &mut Vec<String>| {
582        for e in &c.events {
583            let f = e.frame.0 as f64;
584            if f > a && f <= b {
585                out.push(e.name.clone());
586            }
587        }
588    };
589    if cur >= prev {
590        hit(prev, cur, out);
591    } else if loop_mode == LoopMode::Loop {
592        hit(prev, c.range.1.0 as f64, out);
593        hit(c.range.0.0 as f64 - 1.0, cur, out);
594    }
595}
596
597#[cfg(test)]
598mod tests {
599    use super::*;
600    use renamite_animation::{EasingHandle, Interpolation};
601
602    fn key(f: i64, v: f64) -> KeyframeData {
603        KeyframeData {
604            frame: Frame(f),
605            value: Value::F64(v),
606            interpolation: Interpolation::Linear,
607            ease_out: EasingHandle::LINEAR_OUT,
608            ease_in: EasingHandle::LINEAR_IN,
609        }
610    }
611
612    fn world() -> (ClipMap, Machine, NodeId) {
613        let node = {
614            let mut doc = renamite_model::Document::empty();
615            doc.create_node(renamite_model::Node::new(
616                "n",
617                renamite_model::NodeKind::Group,
618            ))
619        };
620        let mut clips = ClipMap::default();
621        let up = clips.insert(Clip {
622            name: "up".into(),
623            range: (Frame(0), Frame(60)),
624            tracks: vec![Track {
625                node,
626                prop: PropPath::new("opacity"),
627                keys: vec![key(0, 0.0), key(60, 1.0)],
628            }],
629            events: vec![EventKey {
630                frame: Frame(30),
631                name: "half".into(),
632            }],
633        });
634        let down = clips.insert(Clip {
635            name: "down".into(),
636            range: (Frame(0), Frame(60)),
637            tracks: vec![Track {
638                node,
639                prop: PropPath::new("opacity"),
640                keys: vec![key(0, 1.0), key(60, 0.0)],
641            }],
642            events: vec![],
643        });
644        let m = Machine {
645            name: "hover".into(),
646            inputs: vec![InputDef {
647                name: "over".into(),
648                kind: InputKind::Bool { default: false },
649            }],
650            layers: vec![MachineLayer {
651                name: "base".into(),
652                entry: 0,
653                any_transitions: vec![],
654                states: vec![
655                    State {
656                        name: "Down".into(),
657                        kind: StateKind::Clip {
658                            clip: down,
659                            speed: 1.0,
660                            loop_mode: LoopMode::Once,
661                        },
662                        transitions: vec![Transition {
663                            to: 1,
664                            duration: 10.0,
665                            exit_time: None,
666                            conditions: vec![Condition::BoolIs {
667                                input: 0,
668                                value: true,
669                            }],
670                        }],
671                        graph_pos: None,
672                    },
673                    State {
674                        name: "Up".into(),
675                        kind: StateKind::Clip {
676                            clip: up,
677                            speed: 1.0,
678                            loop_mode: LoopMode::Once,
679                        },
680                        transitions: vec![Transition {
681                            to: 0,
682                            duration: 10.0,
683                            exit_time: None,
684                            conditions: vec![Condition::BoolIs {
685                                input: 0,
686                                value: false,
687                            }],
688                        }],
689                        graph_pos: None,
690                    },
691                ],
692            }],
693            listeners: vec![Listener {
694                node,
695                event: PointerEventKind::Enter,
696                action: ListenerAction::SetBool {
697                    input: 0,
698                    value: true,
699                },
700            }],
701        };
702        (clips, m, node)
703    }
704
705    #[test]
706    fn track_lerps() {
707        let (clips, _, node) = world();
708        let c = clips.values().find(|c| c.name == "up").unwrap();
709        let mut out = HashMap::new();
710        c.sample_into(30.0, &mut out);
711        assert_eq!(out[&(node, PropPath::new("opacity"))], Value::F64(0.5));
712    }
713
714    #[test]
715    fn bool_input_transitions_and_listener_sets_it() {
716        let (clips, m, node) = world();
717        let mut inst = MachineInstance::new(&m);
718        let mut ov = Overrides::default();
719        inst.tick(&m, &clips, 1.0, &mut ov);
720        assert_eq!(inst.layers[0].current, 0);
721        inst.pointer_event(&m, node, PointerEventKind::Enter);
722        inst.tick(&m, &clips, 1.0, &mut ov);
723        assert_eq!(inst.layers[0].current, 1);
724        assert!(inst.layers[0].fade.is_some());
725    }
726
727    #[test]
728    fn trigger_consumed_once() {
729        let (clips, mut m, _) = world();
730        m.inputs.push(InputDef {
731            name: "tap".into(),
732            kind: InputKind::Trigger,
733        });
734        m.layers[0].states[0].transitions[0].conditions = vec![Condition::Triggered { input: 1 }];
735        let mut inst = MachineInstance::new(&m);
736        let mut ov = Overrides::default();
737        inst.fire(1);
738        inst.tick(&m, &clips, 1.0, &mut ov);
739        assert_eq!(inst.layers[0].current, 1);
740        m.layers[0].states[1].transitions[0].conditions = vec![Condition::Triggered { input: 1 }];
741        inst.tick(&m, &clips, 1.0, &mut ov);
742        assert_eq!(inst.layers[0].current, 1);
743    }
744
745    #[test]
746    fn clip_event_crossing_fires_once() {
747        let (clips, m, _) = world();
748        let mut inst = MachineInstance::new(&m);
749        inst.set_bool(0, true);
750        let mut ov = Overrides::default();
751        inst.tick(&m, &clips, 1.0, &mut ov);
752        let mut names = Vec::new();
753        for _ in 0..40 {
754            let out = inst.tick(&m, &clips, 1.0, &mut ov);
755            names.extend(out.events);
756        }
757        assert_eq!(names.iter().filter(|n| *n == "half").count(), 1);
758    }
759
760    #[test]
761    fn crossfade_tweens_overlapping_props() {
762        let (clips, m, node) = world();
763        let mut inst = MachineInstance::new(&m);
764        let mut ov = Overrides::default();
765        inst.set_bool(0, true);
766        // First tick starts fade Down->Up (duration 10)
767        inst.tick(&m, &clips, 1.0, &mut ov);
768        assert!(inst.layers[0].fade.is_some());
769        // Mid-fade: opacity must be between down and up samples, not hard-cut to `b`
770        ov.clear();
771        inst.tick(&m, &clips, 4.0, &mut ov);
772        let Some(Value::F64(op)) = ov.get(node, "opacity").cloned() else {
773            panic!("expected f64 opacity");
774        };
775        assert!(op > 0.0 && op < 1.0, "got {op}");
776    }
777}