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            let fired = fired.cloned().filter(|tr| {
374                let to = tr.to.min(layer.states.len() - 1);
375                if to != rt.current {
376                    return true;
377                }
378                tr.conditions
379                    .iter()
380                    .any(|c| matches!(c, Condition::Triggered { .. }))
381            });
382            let mut transitioned_from: Option<(usize, f64, f64)> = None;
383            if let Some(tr) = fired {
384                consume_triggers(&tr, &mut self.inputs);
385                transitioned_from = Some((rt.current, prev_time, rt.time));
386                rt.fade = (tr.duration > 0.0).then_some(Fade {
387                    from: rt.current,
388                    from_time: prev_time,
389                    t: 0.0,
390                    duration: tr.duration,
391                });
392                rt.current = tr.to.min(layer.states.len() - 1);
393                rt.time = 0.0;
394            }
395
396            // sample
397            let mut b = HashMap::new();
398            let mut evs = Vec::new();
399            if let Some((from_idx, from_prev, from_cur)) = transitioned_from {
400                let mut old_evs = Vec::new();
401                let mut old_vals = HashMap::new();
402                sample_state(
403                    &layer.states[from_idx],
404                    clips,
405                    &self.inputs,
406                    from_prev,
407                    from_cur,
408                    &mut old_vals,
409                    &mut old_evs,
410                );
411                evs.append(&mut old_evs);
412            }
413            let sample_prev = if transitioned_from.is_some() {
414                rt.time
415            } else {
416                prev_if_same(prev_time, rt.time)
417            };
418            sample_state(
419                &layer.states[rt.current],
420                clips,
421                &self.inputs,
422                sample_prev,
423                rt.time,
424                &mut b,
425                &mut evs,
426            );
427            if let Some(f) = &rt.fade {
428                let mut a = HashMap::new();
429                let mut fade_evs = Vec::new();
430                let fade_prev = (f.from_time - dt_frames).max(0.0);
431                sample_state(
432                    &layer.states[f.from],
433                    clips,
434                    &self.inputs,
435                    fade_prev,
436                    f.from_time,
437                    &mut a,
438                    &mut fade_evs,
439                );
440                evs.append(&mut fade_evs);
441                for (k, va) in a {
442                    let merged = match b.get(&k) {
443                        Some(vb) => value_tween(&va, vb, f.t),
444                        None => va,
445                    };
446                    b.insert(k, merged);
447                }
448            }
449            for (k, v) in b {
450                out.set(k.0, k.1, v);
451            }
452            output.events.append(&mut evs);
453        }
454        // frame-scoped triggers
455        for i in &mut self.inputs {
456            if let InputValue::Trigger { fired } = i {
457                *fired = false;
458            }
459        }
460        output
461    }
462}
463
464fn prev_if_same(prev: f64, cur: f64) -> f64 {
465    if cur < prev { 0.0 } else { prev }
466}
467
468fn normalized_time(state: &State, clips: &ClipMap, time: f64) -> f64 {
469    match &state.kind {
470        StateKind::Clip {
471            clip,
472            speed,
473            loop_mode,
474        } => clips
475            .get(*clip)
476            .map(|c| c.local(time * speed.max(0.0), *loop_mode).1)
477            .unwrap_or(1.0),
478        StateKind::Blend1D { children, .. } => {
479            if children.is_empty() {
480                return 1.0;
481            }
482            children
483                .first()
484                .and_then(|ch| clips.get(ch.clip))
485                .map(|c| c.local(time, LoopMode::Loop).1)
486                .unwrap_or(1.0)
487        }
488        StateKind::Empty => 1.0,
489    }
490}
491
492fn transition_ready(tr: &Transition, inputs: &[InputValue], norm: f64) -> bool {
493    if let Some(et) = tr.exit_time
494        && norm < et
495    {
496        return false;
497    }
498    if tr.conditions.is_empty() && tr.exit_time.is_none() {
499        return false;
500    }
501    tr.conditions.iter().all(|c| match *c {
502        Condition::BoolIs { input, value } => {
503            matches!(inputs.get(input), Some(InputValue::Bool(b)) if *b == value)
504        }
505        Condition::NumberCmp { input, op, value } => match inputs.get(input) {
506            Some(InputValue::Number(n)) => match op {
507                CmpOp::Eq => (n - value).abs() < 1e-9,
508                CmpOp::Ne => (n - value).abs() >= 1e-9,
509                CmpOp::Lt => *n < value,
510                CmpOp::Le => *n <= value,
511                CmpOp::Gt => *n > value,
512                CmpOp::Ge => *n >= value,
513            },
514            _ => false,
515        },
516        Condition::Triggered { input } => {
517            matches!(inputs.get(input), Some(InputValue::Trigger { fired: true }))
518        }
519    })
520}
521
522fn consume_triggers(tr: &Transition, inputs: &mut [InputValue]) {
523    for c in &tr.conditions {
524        if let Condition::Triggered { input } = c
525            && let Some(InputValue::Trigger { fired }) = inputs.get_mut(*input)
526        {
527            *fired = false;
528        }
529    }
530}
531
532fn sample_state(
533    state: &State,
534    clips: &ClipMap,
535    inputs: &[InputValue],
536    prev_time: f64,
537    time: f64,
538    out: &mut HashMap<(NodeId, PropPath), Value>,
539    events: &mut Vec<String>,
540) {
541    match &state.kind {
542        StateKind::Empty => {}
543        StateKind::Clip {
544            clip,
545            speed,
546            loop_mode,
547        } => {
548            let Some(c) = clips.get(*clip) else { return };
549            let (frame, _) = c.local(time * speed.max(0.0), *loop_mode);
550            let (pframe, _) = c.local(prev_time * speed.max(0.0), *loop_mode);
551            c.sample_into(frame, out);
552            emit_events(c, pframe, frame, *loop_mode, events);
553        }
554        StateKind::Blend1D { input, children } => {
555            if children.is_empty() {
556                return;
557            }
558            let x = match inputs.get(*input) {
559                Some(InputValue::Number(n)) => *n,
560                _ => 0.0,
561            };
562            let (lo, hi, t) = bracket(children, x);
563            let mut a = HashMap::new();
564            if let Some(c) = clips.get(children[lo].clip) {
565                c.sample_into(c.local(time, LoopMode::Loop).0, &mut a);
566            }
567            if hi != lo {
568                let mut b = HashMap::new();
569                if let Some(c) = clips.get(children[hi].clip) {
570                    c.sample_into(c.local(time, LoopMode::Loop).0, &mut b);
571                }
572                for (k, va) in a {
573                    let v = match b.remove(&k) {
574                        Some(vb) => value_tween(&va, &vb, t),
575                        None => va,
576                    };
577                    out.insert(k, v);
578                }
579                out.extend(b);
580            } else {
581                out.extend(a);
582            }
583        }
584    }
585}
586
587/// Index pair + blend factor for x among sorted thresholds.
588fn bracket(children: &[BlendChild], x: f64) -> (usize, usize, f64) {
589    if children.is_empty() {
590        return (0, 0, 0.0);
591    }
592    if x <= children[0].threshold {
593        return (0, 0, 0.0);
594    }
595    let last = children.len() - 1;
596    if x >= children[last].threshold {
597        return (last, last, 0.0);
598    }
599    let hi = children.partition_point(|c| c.threshold <= x);
600    let (lo, hi) = (hi - 1, hi);
601    let span = children[hi].threshold - children[lo].threshold;
602    (
603        lo,
604        hi,
605        if span <= 0.0 {
606            0.0
607        } else {
608            (x - children[lo].threshold) / span
609        },
610    )
611}
612
613fn emit_events(c: &Clip, prev: f64, cur: f64, loop_mode: LoopMode, out: &mut Vec<String>) {
614    let hit = |a: f64, b: f64, out: &mut Vec<String>| {
615        for e in &c.events {
616            let f = e.frame.0 as f64;
617            if f > a && f <= b {
618                out.push(e.name.clone());
619            }
620        }
621    };
622    if cur >= prev {
623        hit(prev, cur, out);
624    } else if loop_mode == LoopMode::Loop {
625        hit(prev, c.range.1.0 as f64, out);
626        hit(c.range.0.0 as f64 - 1.0, cur, out);
627    } else if loop_mode == LoopMode::PingPong {
628        hit(cur, prev, out);
629    }
630}
631
632#[cfg(test)]
633mod tests {
634    use super::*;
635    use renamite_animation::{EasingHandle, Interpolation};
636
637    fn key(f: i64, v: f64) -> KeyframeData {
638        KeyframeData {
639            frame: Frame(f),
640            value: Value::F64(v),
641            interpolation: Interpolation::Linear,
642            ease_out: EasingHandle::LINEAR_OUT,
643            ease_in: EasingHandle::LINEAR_IN,
644        }
645    }
646
647    fn world() -> (ClipMap, Machine, NodeId) {
648        let node = {
649            let mut doc = renamite_model::Document::empty();
650            doc.create_node(renamite_model::Node::new(
651                "n",
652                renamite_model::NodeKind::Group,
653            ))
654        };
655        let mut clips = ClipMap::default();
656        let up = clips.insert(Clip {
657            name: "up".into(),
658            range: (Frame(0), Frame(60)),
659            tracks: vec![Track {
660                node,
661                prop: PropPath::new("opacity"),
662                keys: vec![key(0, 0.0), key(60, 1.0)],
663            }],
664            events: vec![EventKey {
665                frame: Frame(30),
666                name: "half".into(),
667            }],
668        });
669        let down = clips.insert(Clip {
670            name: "down".into(),
671            range: (Frame(0), Frame(60)),
672            tracks: vec![Track {
673                node,
674                prop: PropPath::new("opacity"),
675                keys: vec![key(0, 1.0), key(60, 0.0)],
676            }],
677            events: vec![],
678        });
679        let m = Machine {
680            name: "hover".into(),
681            inputs: vec![InputDef {
682                name: "over".into(),
683                kind: InputKind::Bool { default: false },
684            }],
685            layers: vec![MachineLayer {
686                name: "base".into(),
687                entry: 0,
688                any_transitions: vec![],
689                states: vec![
690                    State {
691                        name: "Down".into(),
692                        kind: StateKind::Clip {
693                            clip: down,
694                            speed: 1.0,
695                            loop_mode: LoopMode::Once,
696                        },
697                        transitions: vec![Transition {
698                            to: 1,
699                            duration: 10.0,
700                            exit_time: None,
701                            conditions: vec![Condition::BoolIs {
702                                input: 0,
703                                value: true,
704                            }],
705                        }],
706                        graph_pos: None,
707                    },
708                    State {
709                        name: "Up".into(),
710                        kind: StateKind::Clip {
711                            clip: up,
712                            speed: 1.0,
713                            loop_mode: LoopMode::Once,
714                        },
715                        transitions: vec![Transition {
716                            to: 0,
717                            duration: 10.0,
718                            exit_time: None,
719                            conditions: vec![Condition::BoolIs {
720                                input: 0,
721                                value: false,
722                            }],
723                        }],
724                        graph_pos: None,
725                    },
726                ],
727            }],
728            listeners: vec![Listener {
729                node,
730                event: PointerEventKind::Enter,
731                action: ListenerAction::SetBool {
732                    input: 0,
733                    value: true,
734                },
735            }],
736        };
737        (clips, m, node)
738    }
739
740    #[test]
741    fn track_lerps() {
742        let (clips, _, node) = world();
743        let c = clips.values().find(|c| c.name == "up").unwrap();
744        let mut out = HashMap::new();
745        c.sample_into(30.0, &mut out);
746        assert_eq!(out[&(node, PropPath::new("opacity"))], Value::F64(0.5));
747    }
748
749    #[test]
750    fn bool_input_transitions_and_listener_sets_it() {
751        let (clips, m, node) = world();
752        let mut inst = MachineInstance::new(&m);
753        let mut ov = Overrides::default();
754        inst.tick(&m, &clips, 1.0, &mut ov);
755        assert_eq!(inst.layers[0].current, 0);
756        inst.pointer_event(&m, node, PointerEventKind::Enter);
757        inst.tick(&m, &clips, 1.0, &mut ov);
758        assert_eq!(inst.layers[0].current, 1);
759        assert!(inst.layers[0].fade.is_some());
760    }
761
762    #[test]
763    fn trigger_consumed_once() {
764        let (clips, mut m, _) = world();
765        m.inputs.push(InputDef {
766            name: "tap".into(),
767            kind: InputKind::Trigger,
768        });
769        m.layers[0].states[0].transitions[0].conditions = vec![Condition::Triggered { input: 1 }];
770        let mut inst = MachineInstance::new(&m);
771        let mut ov = Overrides::default();
772        inst.fire(1);
773        inst.tick(&m, &clips, 1.0, &mut ov);
774        assert_eq!(inst.layers[0].current, 1);
775        m.layers[0].states[1].transitions[0].conditions = vec![Condition::Triggered { input: 1 }];
776        inst.tick(&m, &clips, 1.0, &mut ov);
777        assert_eq!(inst.layers[0].current, 1);
778    }
779
780    #[test]
781    fn clip_event_crossing_fires_once() {
782        let (clips, m, _) = world();
783        let mut inst = MachineInstance::new(&m);
784        inst.set_bool(0, true);
785        let mut ov = Overrides::default();
786        inst.tick(&m, &clips, 1.0, &mut ov);
787        let mut names = Vec::new();
788        for _ in 0..40 {
789            let out = inst.tick(&m, &clips, 1.0, &mut ov);
790            names.extend(out.events);
791        }
792        assert_eq!(names.iter().filter(|n| *n == "half").count(), 1);
793    }
794
795    #[test]
796    fn crossfade_tweens_overlapping_props() {
797        let (clips, m, node) = world();
798        let mut inst = MachineInstance::new(&m);
799        let mut ov = Overrides::default();
800        inst.set_bool(0, true);
801        // First tick starts fade Down->Up (duration 10)
802        inst.tick(&m, &clips, 1.0, &mut ov);
803        assert!(inst.layers[0].fade.is_some());
804        // Mid-fade: opacity must be between down and up samples, not hard-cut to `b`
805        ov.clear();
806        inst.tick(&m, &clips, 4.0, &mut ov);
807        let Some(Value::F64(op)) = ov.get(node, "opacity").cloned() else {
808            panic!("expected f64 opacity");
809        };
810        assert!(op > 0.0 && op < 1.0, "got {op}");
811    }
812}