Skip to main content

oxihuman_morph/
motion_graph.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4#![allow(dead_code)]
5
6use std::collections::HashMap;
7
8// ---------------------------------------------------------------------------
9// TransitionCondition
10// ---------------------------------------------------------------------------
11
12/// Condition that must be satisfied for a state transition to fire.
13#[derive(Debug, Clone, PartialEq)]
14pub enum TransitionCondition {
15    /// Fire immediately on the next update tick.
16    Always,
17    /// Fire after the controller has spent at least N seconds in the current
18    /// state.
19    AfterSeconds(f32),
20    /// Fire when the named parameter is strictly above the threshold.
21    ParameterAbove(String, f32),
22    /// Fire when the named parameter is strictly below the threshold.
23    ParameterBelow(String, f32),
24    /// Fire when the named parameter is within ±0.05 of the threshold.
25    ParameterEqual(String, f32),
26    /// Fire when the current animation clip reaches its end (state_time >=
27    /// clip duration).  Only meaningful for non-looping states.
28    AtEnd,
29}
30
31// ---------------------------------------------------------------------------
32// MotionTransition
33// ---------------------------------------------------------------------------
34
35/// A directed edge in the motion graph.
36#[allow(dead_code)]
37pub struct MotionTransition {
38    /// Source state name.
39    pub from_state: String,
40    /// Destination state name.
41    pub to_state: String,
42    /// Condition that triggers the transition.
43    pub condition: TransitionCondition,
44    /// Cross-fade duration in seconds.
45    pub blend_duration: f32,
46    /// Higher priority transitions are evaluated first.
47    pub priority: i32,
48}
49
50// ---------------------------------------------------------------------------
51// MotionState
52// ---------------------------------------------------------------------------
53
54/// A single animation state bound to one clip.
55#[allow(dead_code)]
56pub struct MotionState {
57    /// Unique name of this state.
58    pub name: String,
59    /// Name of the animation clip (resolved externally).
60    pub clip_name: String,
61    /// Total clip duration in seconds.
62    pub duration: f32,
63    /// Whether the clip should loop.
64    pub loop_state: bool,
65    /// Playback speed multiplier (1.0 = normal speed).
66    pub speed: f32,
67    /// Base morph-target weights that apply while this state is active.
68    pub morph_weights: HashMap<String, f32>,
69}
70
71impl MotionState {
72    /// Create a state with sensible defaults.
73    pub fn new(name: impl Into<String>, clip_name: impl Into<String>, duration: f32) -> Self {
74        Self {
75            name: name.into(),
76            clip_name: clip_name.into(),
77            duration,
78            loop_state: true,
79            speed: 1.0,
80            morph_weights: HashMap::new(),
81        }
82    }
83
84    /// Fluent setter for loop_state.
85    pub fn with_loop(mut self, loop_state: bool) -> Self {
86        self.loop_state = loop_state;
87        self
88    }
89
90    /// Fluent setter for speed.
91    pub fn with_speed(mut self, speed: f32) -> Self {
92        self.speed = speed;
93        self
94    }
95
96    /// Fluent setter that adds one morph weight entry.
97    pub fn with_morph(mut self, key: impl Into<String>, value: f32) -> Self {
98        self.morph_weights.insert(key.into(), value);
99        self
100    }
101}
102
103// ---------------------------------------------------------------------------
104// MotionGraph
105// ---------------------------------------------------------------------------
106
107/// Animation state machine – a collection of states connected by transitions.
108pub struct MotionGraph {
109    states: HashMap<String, MotionState>,
110    transitions: Vec<MotionTransition>,
111    /// Name of the state to enter on startup.
112    pub entry_state: Option<String>,
113}
114
115impl Default for MotionGraph {
116    fn default() -> Self {
117        Self::new()
118    }
119}
120
121impl MotionGraph {
122    /// Create an empty motion graph.
123    pub fn new() -> Self {
124        Self {
125            states: HashMap::new(),
126            transitions: Vec::new(),
127            entry_state: None,
128        }
129    }
130
131    /// Register a state.  If this is the first state and no entry state has
132    /// been set, it becomes the entry state automatically.
133    pub fn add_state(&mut self, state: MotionState) {
134        if self.entry_state.is_none() {
135            self.entry_state = Some(state.name.clone());
136        }
137        self.states.insert(state.name.clone(), state);
138    }
139
140    /// Register a transition edge.
141    pub fn add_transition(&mut self, transition: MotionTransition) {
142        self.transitions.push(transition);
143    }
144
145    /// Total number of registered states.
146    pub fn state_count(&self) -> usize {
147        self.states.len()
148    }
149
150    /// Total number of registered transitions.
151    pub fn transition_count(&self) -> usize {
152        self.transitions.len()
153    }
154
155    /// Look up a state by name.
156    pub fn get_state(&self, name: &str) -> Option<&MotionState> {
157        self.states.get(name)
158    }
159
160    /// Return all transitions whose `from_state` matches `state`, sorted by
161    /// descending priority so that callers evaluate highest-priority first.
162    pub fn transitions_from(&self, state: &str) -> Vec<&MotionTransition> {
163        let mut ts: Vec<&MotionTransition> = self
164            .transitions
165            .iter()
166            .filter(|t| t.from_state == state)
167            .collect();
168        ts.sort_by(|a, b| b.priority.cmp(&a.priority));
169        ts
170    }
171
172    /// Build the canonical idle / walk / run locomotion graph.
173    pub fn default_graph() -> Self {
174        build_locomotion_graph()
175    }
176}
177
178// ---------------------------------------------------------------------------
179// MotionController
180// ---------------------------------------------------------------------------
181
182/// Runtime controller that drives a `MotionGraph`.
183pub struct MotionController {
184    /// The graph being controlled.
185    pub graph: MotionGraph,
186    /// Name of the currently active state.
187    pub current_state: String,
188    /// Seconds spent in the current state (resets on transition).
189    pub state_time: f32,
190    /// Name of the state being blended *into* (if any).
191    pub blend_state: Option<String>,
192    /// Seconds spent in the current blend.
193    pub blend_time: f32,
194    /// Total duration of the current blend.
195    pub blend_duration: f32,
196    /// Named runtime float parameters used by transition conditions.
197    pub parameters: HashMap<String, f32>,
198    /// Total time since the controller was created / reset.
199    pub total_time: f32,
200}
201
202impl MotionController {
203    /// Create a controller and start in the graph's entry state (or `"idle"`
204    /// as a fallback).
205    pub fn new(graph: MotionGraph) -> Self {
206        let current_state = graph
207            .entry_state
208            .clone()
209            .unwrap_or_else(|| "idle".to_string());
210        Self {
211            graph,
212            current_state,
213            state_time: 0.0,
214            blend_state: None,
215            blend_time: 0.0,
216            blend_duration: 0.0,
217            parameters: HashMap::new(),
218            total_time: 0.0,
219        }
220    }
221
222    /// Set (or overwrite) a named runtime parameter.
223    pub fn set_parameter(&mut self, name: &str, value: f32) {
224        self.parameters.insert(name.to_string(), value);
225    }
226
227    /// Read a named runtime parameter (0.0 if not set).
228    pub fn get_parameter(&self, name: &str) -> f32 {
229        self.parameters.get(name).copied().unwrap_or(0.0)
230    }
231
232    /// Advance the controller by `dt` seconds.
233    ///
234    /// 1. Advances `state_time` and `total_time`.
235    /// 2. Checks all transitions out of the current state in priority order.
236    /// 3. If a condition fires, starts a blend to the target state.
237    /// 4. Advances an in-progress blend; finalises it when complete.
238    pub fn update(&mut self, dt: f32) {
239        self.state_time += dt;
240        self.total_time += dt;
241
242        // Advance an in-progress blend.
243        if self.blend_state.is_some() {
244            self.blend_time += dt;
245            if self.blend_time >= self.blend_duration {
246                // Snap to destination.
247                let Some(dest) = self.blend_state.take() else {
248                    return;
249                };
250                self.current_state = dest;
251                self.state_time = 0.0;
252                self.blend_time = 0.0;
253                self.blend_duration = 0.0;
254            }
255            // While blending, do not evaluate new transitions.
256            return;
257        }
258
259        // Evaluate outgoing transitions.
260        let transitions: Vec<(String, f32)> = self
261            .graph
262            .transitions_from(&self.current_state.clone())
263            .iter()
264            .filter_map(|t| {
265                if self.check_condition(&t.condition) {
266                    Some((t.to_state.clone(), t.blend_duration))
267                } else {
268                    None
269                }
270            })
271            .collect();
272
273        if let Some((to_state, blend_dur)) = transitions.into_iter().next() {
274            self.transition_to(&to_state, blend_dur);
275        }
276    }
277
278    /// Force-start a transition to `state` with the given cross-fade duration.
279    /// If `blend_duration` is 0.0, the transition is instantaneous.
280    pub fn transition_to(&mut self, state: &str, blend_duration: f32) {
281        if blend_duration <= 0.0 {
282            self.current_state = state.to_string();
283            self.state_time = 0.0;
284            self.blend_state = None;
285            self.blend_time = 0.0;
286            self.blend_duration = 0.0;
287        } else {
288            self.blend_state = Some(state.to_string());
289            self.blend_time = 0.0;
290            self.blend_duration = blend_duration;
291        }
292    }
293
294    /// Current blend weight in [0, 1].
295    ///
296    /// * 0.0 → 100 % current state.
297    /// * 1.0 → 100 % destination state.
298    ///
299    /// Returns 0.0 when not blending.
300    pub fn blend_weight(&self) -> f32 {
301        if self.blend_duration <= 0.0 {
302            return 0.0;
303        }
304        (self.blend_time / self.blend_duration).clamp(0.0, 1.0)
305    }
306
307    /// Evaluate blended morph weights at the current instant.
308    ///
309    /// When not blending, returns the current state's morph weights unchanged.
310    /// When blending, returns a linear interpolation between the current and
311    /// destination state morph weights.
312    pub fn evaluate_morphs(&self) -> HashMap<String, f32> {
313        let current_morphs = self
314            .graph
315            .get_state(&self.current_state)
316            .map(|s| s.morph_weights.clone())
317            .unwrap_or_default();
318
319        match &self.blend_state {
320            None => current_morphs,
321            Some(dest_name) => {
322                let dest_morphs = self
323                    .graph
324                    .get_state(dest_name)
325                    .map(|s| s.morph_weights.clone())
326                    .unwrap_or_default();
327                blend_morph_maps(&current_morphs, &dest_morphs, self.blend_weight())
328            }
329        }
330    }
331
332    /// Check whether a single `TransitionCondition` is currently satisfied.
333    pub fn check_condition(&self, cond: &TransitionCondition) -> bool {
334        match cond {
335            TransitionCondition::Always => true,
336            TransitionCondition::AfterSeconds(secs) => self.state_time >= *secs,
337            TransitionCondition::ParameterAbove(name, threshold) => {
338                self.get_parameter(name) > *threshold
339            }
340            TransitionCondition::ParameterBelow(name, threshold) => {
341                self.get_parameter(name) < *threshold
342            }
343            TransitionCondition::ParameterEqual(name, threshold) => {
344                (self.get_parameter(name) - threshold).abs() <= 0.05
345            }
346            TransitionCondition::AtEnd => {
347                if let Some(state) = self.graph.get_state(&self.current_state) {
348                    let effective_dur = if state.speed > 0.0 {
349                        state.duration / state.speed
350                    } else {
351                        f32::MAX
352                    };
353                    self.state_time >= effective_dur
354                } else {
355                    false
356                }
357            }
358        }
359    }
360
361    /// Returns `true` while a cross-fade blend is in progress.
362    pub fn is_blending(&self) -> bool {
363        self.blend_state.is_some()
364    }
365
366    /// The name of the currently active state.
367    pub fn current_state_name(&self) -> &str {
368        &self.current_state
369    }
370}
371
372// ---------------------------------------------------------------------------
373// Convenience graph builders
374// ---------------------------------------------------------------------------
375
376/// Build the default idle → walk → run locomotion graph.
377///
378/// Parameters used:
379/// * `"speed"` (f32) – character movement speed in m/s.
380pub fn build_locomotion_graph() -> MotionGraph {
381    let mut graph = MotionGraph::new();
382
383    // States ----------------------------------------------------------------
384    let idle = MotionState::new("idle", "anim_idle", 2.0)
385        .with_loop(true)
386        .with_morph("body_relaxed", 1.0)
387        .with_morph("arms_down", 1.0);
388
389    let walk = MotionState::new("walk", "anim_walk", 1.2)
390        .with_loop(true)
391        .with_morph("body_relaxed", 0.5)
392        .with_morph("arms_swing", 0.8);
393
394    let run = MotionState::new("run", "anim_run", 0.8)
395        .with_loop(true)
396        .with_morph("body_tense", 0.7)
397        .with_morph("arms_swing", 1.0);
398
399    let land = MotionState::new("land", "anim_land", 0.5)
400        .with_loop(false)
401        .with_morph("legs_bent", 1.0);
402
403    graph.add_state(idle);
404    graph.add_state(walk);
405    graph.add_state(run);
406    graph.add_state(land);
407
408    // Transitions -----------------------------------------------------------
409    // idle → walk when speed > 0.5 m/s
410    graph.add_transition(MotionTransition {
411        from_state: "idle".into(),
412        to_state: "walk".into(),
413        condition: TransitionCondition::ParameterAbove("speed".into(), 0.5),
414        blend_duration: 0.3,
415        priority: 0,
416    });
417
418    // walk → idle when speed < 0.3 m/s
419    graph.add_transition(MotionTransition {
420        from_state: "walk".into(),
421        to_state: "idle".into(),
422        condition: TransitionCondition::ParameterBelow("speed".into(), 0.3),
423        blend_duration: 0.4,
424        priority: 0,
425    });
426
427    // walk → run when speed > 3.0 m/s
428    graph.add_transition(MotionTransition {
429        from_state: "walk".into(),
430        to_state: "run".into(),
431        condition: TransitionCondition::ParameterAbove("speed".into(), 3.0),
432        blend_duration: 0.3,
433        priority: 1,
434    });
435
436    // run → walk when speed < 2.5 m/s
437    graph.add_transition(MotionTransition {
438        from_state: "run".into(),
439        to_state: "walk".into(),
440        condition: TransitionCondition::ParameterBelow("speed".into(), 2.5),
441        blend_duration: 0.4,
442        priority: 0,
443    });
444
445    // land → idle when the landing clip ends
446    graph.add_transition(MotionTransition {
447        from_state: "land".into(),
448        to_state: "idle".into(),
449        condition: TransitionCondition::AtEnd,
450        blend_duration: 0.2,
451        priority: 0,
452    });
453
454    graph
455}
456
457/// Build a facial-expression motion graph.
458///
459/// Parameters used:
460/// * `"emotion"` (f32) – 0=neutral, 1=happy, 2=sad, 3=angry, 4=surprised.
461pub fn build_expression_graph() -> MotionGraph {
462    let mut graph = MotionGraph::new();
463
464    let neutral = MotionState::new("neutral", "expr_neutral", 1.0)
465        .with_loop(true)
466        .with_morph("mouth_closed", 1.0)
467        .with_morph("brow_neutral", 1.0);
468
469    let happy = MotionState::new("happy", "expr_happy", 1.0)
470        .with_loop(true)
471        .with_morph("mouth_smile", 1.0)
472        .with_morph("cheeks_raised", 0.7)
473        .with_morph("brow_raised", 0.2);
474
475    let sad = MotionState::new("sad", "expr_sad", 1.0)
476        .with_loop(true)
477        .with_morph("mouth_frown", 0.8)
478        .with_morph("brow_sad", 1.0)
479        .with_morph("eyes_half_closed", 0.5);
480
481    let angry = MotionState::new("angry", "expr_angry", 1.0)
482        .with_loop(true)
483        .with_morph("brow_furrow", 1.0)
484        .with_morph("mouth_tense", 0.6)
485        .with_morph("nostrils_flare", 0.4);
486
487    let surprised = MotionState::new("surprised", "expr_surprised", 1.0)
488        .with_loop(true)
489        .with_morph("mouth_open", 0.9)
490        .with_morph("brow_raised", 1.0)
491        .with_morph("eyes_wide", 1.0);
492
493    graph.add_state(neutral);
494    graph.add_state(happy);
495    graph.add_state(sad);
496    graph.add_state(angry);
497    graph.add_state(surprised);
498
499    // Transitions from neutral to each expression --------------------------
500    for (target, lo, hi) in [
501        ("happy", 0.5_f32, 1.5_f32),
502        ("sad", 1.5, 2.5),
503        ("angry", 2.5, 3.5),
504        ("surprised", 3.5, 4.5),
505    ] {
506        let mid = (lo + hi) * 0.5;
507        graph.add_transition(MotionTransition {
508            from_state: "neutral".into(),
509            to_state: target.into(),
510            condition: TransitionCondition::ParameterEqual("emotion".into(), mid),
511            blend_duration: 0.25,
512            priority: 0,
513        });
514    }
515
516    // Transitions back to neutral -------------------------------------------
517    for from in ["happy", "sad", "angry", "surprised"] {
518        graph.add_transition(MotionTransition {
519            from_state: from.into(),
520            to_state: "neutral".into(),
521            condition: TransitionCondition::ParameterEqual("emotion".into(), 0.0),
522            blend_duration: 0.35,
523            priority: 0,
524        });
525    }
526
527    graph
528}
529
530// ---------------------------------------------------------------------------
531// Utility: blend_morph_maps
532// ---------------------------------------------------------------------------
533
534/// Linear blend between two morph-weight maps.
535///
536/// * `t = 0.0` → result equals `a`.
537/// * `t = 1.0` → result equals `b`.
538///
539/// Keys present in only one map are treated as 0.0 in the other.
540pub fn blend_morph_maps(
541    a: &HashMap<String, f32>,
542    b: &HashMap<String, f32>,
543    t: f32,
544) -> HashMap<String, f32> {
545    let t = t.clamp(0.0, 1.0);
546    let mut result: HashMap<String, f32> = HashMap::new();
547
548    for (k, &va) in a {
549        let vb = b.get(k).copied().unwrap_or(0.0);
550        result.insert(k.clone(), va * (1.0 - t) + vb * t);
551    }
552    for (k, &vb) in b {
553        if !result.contains_key(k) {
554            result.insert(k.clone(), vb * t);
555        }
556    }
557    result
558}
559
560// ---------------------------------------------------------------------------
561// Tests
562// ---------------------------------------------------------------------------
563
564#[cfg(test)]
565mod tests {
566    use super::*;
567    use std::fs;
568
569    fn write_tmp(name: &str, content: &str) {
570        fs::write(format!("/tmp/{}", name), content).expect("should succeed");
571    }
572
573    // 1. build locomotion graph – state count
574    #[test]
575    fn test_locomotion_state_count() {
576        let g = build_locomotion_graph();
577        assert_eq!(g.state_count(), 4);
578        write_tmp(
579            "mg_locomotion_state_count.txt",
580            &g.state_count().to_string(),
581        );
582    }
583
584    // 2. build locomotion graph – transition count
585    #[test]
586    fn test_locomotion_transition_count() {
587        let g = build_locomotion_graph();
588        assert_eq!(g.transition_count(), 5);
589        write_tmp(
590            "mg_locomotion_transition_count.txt",
591            &g.transition_count().to_string(),
592        );
593    }
594
595    // 3. entry state defaults to first added state
596    #[test]
597    fn test_entry_state() {
598        let g = build_locomotion_graph();
599        assert_eq!(g.entry_state.as_deref(), Some("idle"));
600        write_tmp("mg_entry_state.txt", "ok");
601    }
602
603    // 4. get_state returns correct clip name
604    #[test]
605    fn test_get_state_clip_name() {
606        let g = build_locomotion_graph();
607        let state = g.get_state("walk").expect("should succeed");
608        assert_eq!(state.clip_name, "anim_walk");
609        write_tmp("mg_get_state_clip.txt", &state.clip_name);
610    }
611
612    // 5. transitions_from sorted by descending priority
613    #[test]
614    fn test_transitions_from_priority_order() {
615        let g = build_locomotion_graph();
616        let ts = g.transitions_from("walk");
617        assert!(ts.len() >= 2);
618        // Highest priority first.
619        assert!(ts[0].priority >= ts[ts.len() - 1].priority);
620        write_tmp("mg_transition_priority.txt", "ok");
621    }
622
623    // 6. MotionController starts in entry state
624    #[test]
625    fn test_controller_entry_state() {
626        let g = build_locomotion_graph();
627        let ctrl = MotionController::new(g);
628        assert_eq!(ctrl.current_state_name(), "idle");
629        write_tmp("mg_ctrl_entry.txt", "ok");
630    }
631
632    // 7. set/get parameter round-trip
633    #[test]
634    fn test_parameter_round_trip() {
635        let g = build_locomotion_graph();
636        let mut ctrl = MotionController::new(g);
637        ctrl.set_parameter("speed", 1.5);
638        assert!((ctrl.get_parameter("speed") - 1.5).abs() < 1e-5);
639        write_tmp("mg_param_round_trip.txt", "ok");
640    }
641
642    // 8. missing parameter returns 0.0
643    #[test]
644    fn test_missing_parameter_default() {
645        let g = build_locomotion_graph();
646        let ctrl = MotionController::new(g);
647        assert_eq!(ctrl.get_parameter("nonexistent"), 0.0);
648        write_tmp("mg_missing_param.txt", "ok");
649    }
650
651    // 9. transition_to instantly (blend_duration = 0.0)
652    #[test]
653    fn test_instant_transition() {
654        let g = build_locomotion_graph();
655        let mut ctrl = MotionController::new(g);
656        ctrl.transition_to("run", 0.0);
657        assert_eq!(ctrl.current_state_name(), "run");
658        assert!(!ctrl.is_blending());
659        write_tmp("mg_instant_transition.txt", "ok");
660    }
661
662    // 10. transition_to with blend starts a blend
663    #[test]
664    fn test_blend_transition() {
665        let g = build_locomotion_graph();
666        let mut ctrl = MotionController::new(g);
667        ctrl.transition_to("walk", 0.5);
668        assert!(ctrl.is_blending());
669        assert_eq!(ctrl.blend_state.as_deref(), Some("walk"));
670        assert!((ctrl.blend_duration - 0.5).abs() < 1e-6);
671        write_tmp("mg_blend_transition.txt", "ok");
672    }
673
674    // 11. blend finalises after enough updates
675    #[test]
676    fn test_blend_finalises() {
677        let g = build_locomotion_graph();
678        let mut ctrl = MotionController::new(g);
679        ctrl.transition_to("walk", 0.3);
680        ctrl.update(0.1);
681        assert!(ctrl.is_blending());
682        ctrl.update(0.25); // total 0.35 > 0.3
683        assert!(!ctrl.is_blending());
684        assert_eq!(ctrl.current_state_name(), "walk");
685        write_tmp("mg_blend_finalises.txt", "ok");
686    }
687
688    // 12. automatic idle → walk transition via parameter
689    #[test]
690    fn test_auto_idle_to_walk() {
691        let g = build_locomotion_graph();
692        let mut ctrl = MotionController::new(g);
693        ctrl.set_parameter("speed", 1.0); // > 0.5
694        ctrl.update(0.01);
695        // Should have started blending to walk.
696        assert_eq!(ctrl.blend_state.as_deref(), Some("walk"));
697        write_tmp("mg_auto_idle_walk.txt", "ok");
698    }
699
700    // 13. blend_morph_maps at t=0 returns a unchanged
701    #[test]
702    fn test_blend_morphs_t0() {
703        let mut a = HashMap::new();
704        a.insert("smile".to_string(), 0.8_f32);
705        a.insert("brow".to_string(), 0.3_f32);
706        let b: HashMap<String, f32> = HashMap::new();
707        let result = blend_morph_maps(&a, &b, 0.0);
708        assert!((result["smile"] - 0.8).abs() < 1e-6);
709        assert!((result["brow"] - 0.3).abs() < 1e-6);
710        write_tmp("mg_blend_t0.txt", "ok");
711    }
712
713    // 14. blend_morph_maps at t=1 returns b
714    #[test]
715    fn test_blend_morphs_t1() {
716        let a: HashMap<String, f32> = HashMap::new();
717        let mut b = HashMap::new();
718        b.insert("frown".to_string(), 0.9_f32);
719        let result = blend_morph_maps(&a, &b, 1.0);
720        assert!((result["frown"] - 0.9).abs() < 1e-6);
721        write_tmp("mg_blend_t1.txt", "ok");
722    }
723
724    // 15. blend_morph_maps at t=0.5 is midpoint
725    #[test]
726    fn test_blend_morphs_midpoint() {
727        let mut a = HashMap::new();
728        a.insert("key".to_string(), 0.0_f32);
729        let mut b = HashMap::new();
730        b.insert("key".to_string(), 1.0_f32);
731        let result = blend_morph_maps(&a, &b, 0.5);
732        assert!((result["key"] - 0.5).abs() < 1e-6);
733        write_tmp("mg_blend_midpoint.txt", "ok");
734    }
735
736    // 16. evaluate_morphs while blending is interpolated
737    #[test]
738    fn test_evaluate_morphs_blending() {
739        let g = build_locomotion_graph();
740        let mut ctrl = MotionController::new(g);
741        // Force to walk state and start blending to run
742        ctrl.transition_to("walk", 0.0);
743        ctrl.transition_to("run", 1.0);
744        ctrl.blend_time = 0.5; // halfway
745        let morphs = ctrl.evaluate_morphs();
746        // walk has arms_swing=0.8, run has arms_swing=1.0 → expect ~0.9
747        let w = morphs.get("arms_swing").copied().unwrap_or(0.0);
748        assert!((w - 0.9).abs() < 0.05, "arms_swing blend = {w}");
749        write_tmp("mg_evaluate_morphs_blend.txt", &w.to_string());
750    }
751
752    // 17. check_condition: Always is always true
753    #[test]
754    fn test_condition_always() {
755        let g = build_locomotion_graph();
756        let ctrl = MotionController::new(g);
757        assert!(ctrl.check_condition(&TransitionCondition::Always));
758        write_tmp("mg_cond_always.txt", "ok");
759    }
760
761    // 18. check_condition: AfterSeconds fires only when state_time is enough
762    #[test]
763    fn test_condition_after_seconds() {
764        let g = build_locomotion_graph();
765        let mut ctrl = MotionController::new(g);
766        ctrl.state_time = 0.5;
767        assert!(!ctrl.check_condition(&TransitionCondition::AfterSeconds(1.0)));
768        ctrl.state_time = 1.5;
769        assert!(ctrl.check_condition(&TransitionCondition::AfterSeconds(1.0)));
770        write_tmp("mg_cond_after_seconds.txt", "ok");
771    }
772
773    // 19. check_condition: ParameterEqual uses ±0.05 tolerance
774    #[test]
775    fn test_condition_parameter_equal_tolerance() {
776        let g = build_locomotion_graph();
777        let mut ctrl = MotionController::new(g);
778        ctrl.set_parameter("x", 1.03);
779        assert!(ctrl.check_condition(&TransitionCondition::ParameterEqual("x".into(), 1.0)));
780        ctrl.set_parameter("x", 1.1);
781        assert!(!ctrl.check_condition(&TransitionCondition::ParameterEqual("x".into(), 1.0)));
782        write_tmp("mg_cond_param_equal.txt", "ok");
783    }
784
785    // 20. check_condition: AtEnd fires when state_time >= effective duration
786    #[test]
787    fn test_condition_at_end() {
788        let g = build_locomotion_graph();
789        let mut ctrl = MotionController::new(g);
790        ctrl.transition_to("land", 0.0);
791        // land duration = 0.5, speed = 1.0
792        ctrl.state_time = 0.4;
793        assert!(!ctrl.check_condition(&TransitionCondition::AtEnd));
794        ctrl.state_time = 0.6;
795        assert!(ctrl.check_condition(&TransitionCondition::AtEnd));
796        write_tmp("mg_cond_at_end.txt", "ok");
797    }
798
799    // 21. expression graph – neutral is entry state
800    #[test]
801    fn test_expression_graph_entry() {
802        let g = build_expression_graph();
803        assert_eq!(g.entry_state.as_deref(), Some("neutral"));
804        write_tmp("mg_expr_entry.txt", "ok");
805    }
806
807    // 22. expression graph – morph weights present
808    #[test]
809    fn test_expression_graph_morph_weights() {
810        let g = build_expression_graph();
811        let happy = g.get_state("happy").expect("should succeed");
812        assert!(happy.morph_weights.contains_key("mouth_smile"));
813        write_tmp(
814            "mg_expr_morph_weights.txt",
815            &format!("{:?}", happy.morph_weights),
816        );
817    }
818
819    // 23. blend_weight returns 0 when not blending
820    #[test]
821    fn test_blend_weight_no_blend() {
822        let g = build_locomotion_graph();
823        let ctrl = MotionController::new(g);
824        assert_eq!(ctrl.blend_weight(), 0.0);
825        write_tmp("mg_blend_weight_none.txt", "0.0");
826    }
827
828    // 24. default_graph alias returns same as build_locomotion_graph
829    #[test]
830    fn test_default_graph_alias() {
831        let g1 = MotionGraph::default_graph();
832        let g2 = build_locomotion_graph();
833        assert_eq!(g1.state_count(), g2.state_count());
834        assert_eq!(g1.transition_count(), g2.transition_count());
835        write_tmp("mg_default_graph_alias.txt", "ok");
836    }
837
838    // 25. total_time accumulates across updates
839    #[test]
840    fn test_total_time_accumulates() {
841        let g = build_locomotion_graph();
842        let mut ctrl = MotionController::new(g);
843        ctrl.update(0.1);
844        ctrl.update(0.2);
845        assert!((ctrl.total_time - 0.3).abs() < 1e-6);
846        write_tmp("mg_total_time.txt", &ctrl.total_time.to_string());
847    }
848}