Skip to main content

oxihuman_morph/
muscle_control.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4#![allow(dead_code)]
5#![allow(clippy::too_many_arguments)]
6
7//! Muscle-driven deformation control for OxiHuman morph system.
8//!
9//! Provides named muscles with flex/contract states that drive morph weights.
10//! Each [`MuscleDefinition`] maps to a set of morph targets with weighted influence.
11//! A [`MuscleRig`] aggregates multiple muscles and evaluates collective morph output.
12
13use std::collections::HashMap;
14
15// ---------------------------------------------------------------------------
16// MuscleGroup
17// ---------------------------------------------------------------------------
18
19/// Anatomical muscle group classification.
20#[derive(Debug, Clone, PartialEq, Eq, Hash)]
21pub enum MuscleGroup {
22    Chest,
23    Back,
24    Shoulder,
25    Bicep,
26    Tricep,
27    Forearm,
28    Abs,
29    Oblique,
30    Glute,
31    Hamstring,
32    Quad,
33    Calf,
34    Neck,
35    Face,
36}
37
38impl MuscleGroup {
39    /// Returns all muscle groups in anatomical order.
40    pub fn all() -> Vec<MuscleGroup> {
41        vec![
42            MuscleGroup::Chest,
43            MuscleGroup::Back,
44            MuscleGroup::Shoulder,
45            MuscleGroup::Bicep,
46            MuscleGroup::Tricep,
47            MuscleGroup::Forearm,
48            MuscleGroup::Abs,
49            MuscleGroup::Oblique,
50            MuscleGroup::Glute,
51            MuscleGroup::Hamstring,
52            MuscleGroup::Quad,
53            MuscleGroup::Calf,
54            MuscleGroup::Neck,
55            MuscleGroup::Face,
56        ]
57    }
58
59    /// Returns the human-readable name of this muscle group.
60    pub fn name(&self) -> &'static str {
61        match self {
62            MuscleGroup::Chest => "Chest",
63            MuscleGroup::Back => "Back",
64            MuscleGroup::Shoulder => "Shoulder",
65            MuscleGroup::Bicep => "Bicep",
66            MuscleGroup::Tricep => "Tricep",
67            MuscleGroup::Forearm => "Forearm",
68            MuscleGroup::Abs => "Abs",
69            MuscleGroup::Oblique => "Oblique",
70            MuscleGroup::Glute => "Glute",
71            MuscleGroup::Hamstring => "Hamstring",
72            MuscleGroup::Quad => "Quad",
73            MuscleGroup::Calf => "Calf",
74            MuscleGroup::Neck => "Neck",
75            MuscleGroup::Face => "Face",
76        }
77    }
78}
79
80// ---------------------------------------------------------------------------
81// Side
82// ---------------------------------------------------------------------------
83
84/// Lateral side discriminant.
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
86pub enum Side {
87    Left,
88    Right,
89    Center,
90}
91
92// ---------------------------------------------------------------------------
93// MuscleDefinition
94// ---------------------------------------------------------------------------
95
96/// A named muscle definition that drives one or more morph targets.
97pub struct MuscleDefinition {
98    /// Unique name for this muscle (e.g. "bicep_left").
99    pub name: String,
100    /// Anatomical group this muscle belongs to.
101    pub group: MuscleGroup,
102    /// Morph targets driven when this muscle flexes (0 = relaxed, 1 = full flex).
103    /// Each entry is `(morph_name, max_weight)`.
104    pub flex_morphs: Vec<(String, f32)>,
105    /// Morph targets driven when contracted (e.g., shortened).
106    pub contract_morphs: Vec<(String, f32)>,
107    /// If `true`, this muscle has a left/right counterpart.
108    pub symmetrical: bool,
109    /// Which side this muscle is on, if applicable.
110    pub side: Option<Side>,
111    /// Reference length in normalised units (0..1).
112    pub rest_length: f32,
113}
114
115impl MuscleDefinition {
116    /// Convenience constructor.
117    pub fn new(name: impl Into<String>, group: MuscleGroup) -> Self {
118        Self {
119            name: name.into(),
120            group,
121            flex_morphs: Vec::new(),
122            contract_morphs: Vec::new(),
123            symmetrical: false,
124            side: None,
125            rest_length: 1.0,
126        }
127    }
128
129    /// Builder: add a flex morph.
130    pub fn with_flex_morph(mut self, morph: impl Into<String>, max_weight: f32) -> Self {
131        self.flex_morphs.push((morph.into(), max_weight));
132        self
133    }
134
135    /// Builder: add a contract morph.
136    pub fn with_contract_morph(mut self, morph: impl Into<String>, max_weight: f32) -> Self {
137        self.contract_morphs.push((morph.into(), max_weight));
138        self
139    }
140
141    /// Builder: set symmetry and side.
142    pub fn with_side(mut self, side: Side) -> Self {
143        self.symmetrical = true;
144        self.side = Some(side);
145        self
146    }
147
148    /// Builder: set rest length.
149    pub fn with_rest_length(mut self, length: f32) -> Self {
150        self.rest_length = length.clamp(0.0, 1.0);
151        self
152    }
153}
154
155// ---------------------------------------------------------------------------
156// MuscleState
157// ---------------------------------------------------------------------------
158
159/// Current activation state of a single muscle.
160#[derive(Debug, Clone)]
161pub struct MuscleState {
162    /// Flex activation: 0 = relaxed, 1 = fully flexed.
163    pub flex: f32,
164    /// Contraction: 0 = rest length, 1 = fully contracted.
165    pub contract: f32,
166    /// Fatigue: 0 = fresh, 1 = fatigued (reduces output).
167    pub fatigue: f32,
168}
169
170impl Default for MuscleState {
171    fn default() -> Self {
172        Self {
173            flex: 0.0,
174            contract: 0.0,
175            fatigue: 0.0,
176        }
177    }
178}
179
180impl MuscleState {
181    /// Create a fully or partially flexed muscle state (no fatigue).
182    pub fn flexed(v: f32) -> Self {
183        Self {
184            flex: v.clamp(0.0, 1.0),
185            contract: 0.0,
186            fatigue: 0.0,
187        }
188    }
189
190    /// Create a completely relaxed muscle state.
191    pub fn relaxed() -> Self {
192        Self::default()
193    }
194
195    /// Effective flex output considering fatigue attenuation.
196    ///
197    /// Fatigue linearly reduces output: `effective = flex * (1 - fatigue)`.
198    pub fn effective_flex(&self) -> f32 {
199        (self.flex * (1.0 - self.fatigue)).clamp(0.0, 1.0)
200    }
201
202    /// Effective contract output considering fatigue attenuation.
203    pub fn effective_contract(&self) -> f32 {
204        (self.contract * (1.0 - self.fatigue)).clamp(0.0, 1.0)
205    }
206}
207
208// ---------------------------------------------------------------------------
209// MuscleRig
210// ---------------------------------------------------------------------------
211
212/// A complete muscle rig: a collection of [`MuscleDefinition`]s with current
213/// [`MuscleState`]s that can be evaluated into morph weights.
214pub struct MuscleRig {
215    muscles: Vec<MuscleDefinition>,
216    states: HashMap<String, MuscleState>,
217}
218
219impl Default for MuscleRig {
220    fn default() -> Self {
221        Self::new()
222    }
223}
224
225impl MuscleRig {
226    /// Create an empty muscle rig.
227    pub fn new() -> Self {
228        Self {
229            muscles: Vec::new(),
230            states: HashMap::new(),
231        }
232    }
233
234    /// Add a muscle definition to this rig.
235    /// The state is initialised to the relaxed default.
236    pub fn add_muscle(&mut self, def: MuscleDefinition) {
237        let name = def.name.clone();
238        self.muscles.push(def);
239        self.states.entry(name).or_default();
240    }
241
242    /// Set the current state for a named muscle.
243    pub fn set_state(&mut self, name: &str, state: MuscleState) {
244        self.states.insert(name.to_owned(), state);
245    }
246
247    /// Get the current state for a named muscle, if it exists.
248    pub fn get_state(&self, name: &str) -> Option<&MuscleState> {
249        self.states.get(name)
250    }
251
252    /// Return all muscle names in definition order.
253    pub fn muscle_names(&self) -> Vec<&str> {
254        self.muscles.iter().map(|m| m.name.as_str()).collect()
255    }
256
257    /// Return all muscle definitions belonging to a given group.
258    pub fn muscles_in_group(&self, group: &MuscleGroup) -> Vec<&MuscleDefinition> {
259        self.muscles.iter().filter(|m| &m.group == group).collect()
260    }
261
262    /// Total number of muscles in this rig.
263    pub fn count(&self) -> usize {
264        self.muscles.len()
265    }
266
267    /// Evaluate all muscle states and accumulate morph weights.
268    ///
269    /// Multiple muscles can drive the same morph target; weights are additive
270    /// and clamped to `[0, 1]` per morph.
271    pub fn evaluate(&self) -> HashMap<String, f32> {
272        let mut weights: HashMap<String, f32> = HashMap::new();
273
274        for muscle in &self.muscles {
275            let state = self.states.get(&muscle.name).cloned().unwrap_or_default();
276
277            let eff_flex = state.effective_flex();
278            let eff_contract = state.effective_contract();
279
280            for (morph_name, max_weight) in &muscle.flex_morphs {
281                let w = eff_flex * max_weight;
282                let entry = weights.entry(morph_name.clone()).or_insert(0.0);
283                *entry = (*entry + w).clamp(0.0, 1.0);
284            }
285
286            for (morph_name, max_weight) in &muscle.contract_morphs {
287                let w = eff_contract * max_weight;
288                let entry = weights.entry(morph_name.clone()).or_insert(0.0);
289                *entry = (*entry + w).clamp(0.0, 1.0);
290            }
291        }
292
293        weights
294    }
295
296    /// Flex all muscles in a group simultaneously to `amount` [0..1].
297    pub fn flex_group(&mut self, group: &MuscleGroup, amount: f32) {
298        let names: Vec<String> = self
299            .muscles
300            .iter()
301            .filter(|m| &m.group == group)
302            .map(|m| m.name.clone())
303            .collect();
304
305        let amount = amount.clamp(0.0, 1.0);
306        for name in names {
307            let state = self.states.entry(name).or_default();
308            state.flex = amount;
309        }
310    }
311
312    /// Set all muscle states to relaxed (zero flex, zero contract).
313    pub fn relax_all(&mut self) {
314        for state in self.states.values_mut() {
315            state.flex = 0.0;
316            state.contract = 0.0;
317        }
318    }
319
320    /// Build a standard human muscle rig with approximately 20 named muscles.
321    pub fn default_rig() -> Self {
322        let mut rig = Self::new();
323
324        // --- Chest ---
325        rig.add_muscle(
326            MuscleDefinition::new("pectoralis_major", MuscleGroup::Chest)
327                .with_flex_morph("chest_flex", 1.0)
328                .with_contract_morph("chest_contracted", 0.8)
329                .with_rest_length(0.9),
330        );
331
332        // --- Back ---
333        rig.add_muscle(
334            MuscleDefinition::new("latissimus_dorsi", MuscleGroup::Back)
335                .with_flex_morph("back_lat_flex", 1.0)
336                .with_contract_morph("back_contracted", 0.7)
337                .with_rest_length(0.95),
338        );
339        rig.add_muscle(
340            MuscleDefinition::new("trapezius", MuscleGroup::Back)
341                .with_flex_morph("trap_flex", 0.9)
342                .with_rest_length(0.85),
343        );
344
345        // --- Shoulder ---
346        rig.add_muscle(
347            MuscleDefinition::new("deltoid_left", MuscleGroup::Shoulder)
348                .with_flex_morph("shoulder_flex_left", 1.0)
349                .with_side(Side::Left)
350                .with_rest_length(0.8),
351        );
352        rig.add_muscle(
353            MuscleDefinition::new("deltoid_right", MuscleGroup::Shoulder)
354                .with_flex_morph("shoulder_flex_right", 1.0)
355                .with_side(Side::Right)
356                .with_rest_length(0.8),
357        );
358
359        // --- Bicep ---
360        rig.add_muscle(
361            MuscleDefinition::new("bicep_left", MuscleGroup::Bicep)
362                .with_flex_morph("bicep_flex_left", 1.0)
363                .with_contract_morph("bicep_contracted_left", 0.9)
364                .with_side(Side::Left)
365                .with_rest_length(0.75),
366        );
367        rig.add_muscle(
368            MuscleDefinition::new("bicep_right", MuscleGroup::Bicep)
369                .with_flex_morph("bicep_flex_right", 1.0)
370                .with_contract_morph("bicep_contracted_right", 0.9)
371                .with_side(Side::Right)
372                .with_rest_length(0.75),
373        );
374
375        // --- Tricep ---
376        rig.add_muscle(
377            MuscleDefinition::new("tricep_left", MuscleGroup::Tricep)
378                .with_flex_morph("tricep_flex_left", 0.85)
379                .with_side(Side::Left)
380                .with_rest_length(0.8),
381        );
382        rig.add_muscle(
383            MuscleDefinition::new("tricep_right", MuscleGroup::Tricep)
384                .with_flex_morph("tricep_flex_right", 0.85)
385                .with_side(Side::Right)
386                .with_rest_length(0.8),
387        );
388
389        // --- Forearm ---
390        rig.add_muscle(
391            MuscleDefinition::new("brachioradialis_left", MuscleGroup::Forearm)
392                .with_flex_morph("forearm_flex_left", 0.7)
393                .with_side(Side::Left)
394                .with_rest_length(0.9),
395        );
396        rig.add_muscle(
397            MuscleDefinition::new("brachioradialis_right", MuscleGroup::Forearm)
398                .with_flex_morph("forearm_flex_right", 0.7)
399                .with_side(Side::Right)
400                .with_rest_length(0.9),
401        );
402
403        // --- Abs ---
404        rig.add_muscle(
405            MuscleDefinition::new("rectus_abdominis", MuscleGroup::Abs)
406                .with_flex_morph("abs_flex", 1.0)
407                .with_contract_morph("abs_crunch", 0.8)
408                .with_rest_length(0.85),
409        );
410
411        // --- Oblique ---
412        rig.add_muscle(
413            MuscleDefinition::new("oblique_left", MuscleGroup::Oblique)
414                .with_flex_morph("oblique_flex_left", 0.8)
415                .with_side(Side::Left)
416                .with_rest_length(0.9),
417        );
418        rig.add_muscle(
419            MuscleDefinition::new("oblique_right", MuscleGroup::Oblique)
420                .with_flex_morph("oblique_flex_right", 0.8)
421                .with_side(Side::Right)
422                .with_rest_length(0.9),
423        );
424
425        // --- Glute ---
426        rig.add_muscle(
427            MuscleDefinition::new("gluteus_maximus", MuscleGroup::Glute)
428                .with_flex_morph("glute_flex", 1.0)
429                .with_rest_length(0.95),
430        );
431
432        // --- Quad ---
433        rig.add_muscle(
434            MuscleDefinition::new("quadricep_left", MuscleGroup::Quad)
435                .with_flex_morph("quad_flex_left", 1.0)
436                .with_side(Side::Left)
437                .with_rest_length(0.85),
438        );
439        rig.add_muscle(
440            MuscleDefinition::new("quadricep_right", MuscleGroup::Quad)
441                .with_flex_morph("quad_flex_right", 1.0)
442                .with_side(Side::Right)
443                .with_rest_length(0.85),
444        );
445
446        // --- Hamstring ---
447        rig.add_muscle(
448            MuscleDefinition::new("hamstring_left", MuscleGroup::Hamstring)
449                .with_flex_morph("hamstring_flex_left", 0.9)
450                .with_side(Side::Left)
451                .with_rest_length(0.9),
452        );
453
454        // --- Calf ---
455        rig.add_muscle(
456            MuscleDefinition::new("gastrocnemius_left", MuscleGroup::Calf)
457                .with_flex_morph("calf_flex_left", 0.9)
458                .with_contract_morph("calf_contracted_left", 0.7)
459                .with_side(Side::Left)
460                .with_rest_length(0.8),
461        );
462
463        // --- Neck ---
464        rig.add_muscle(
465            MuscleDefinition::new("sternocleidomastoid", MuscleGroup::Neck)
466                .with_flex_morph("neck_flex", 0.7)
467                .with_rest_length(0.85),
468        );
469
470        rig
471    }
472
473    /// Apply fatigue to muscles whose flex exceeds `threshold`.
474    ///
475    /// Each qualifying muscle has its fatigue increased by `fatigue_rate`,
476    /// clamped to `[0, 1]`.
477    pub fn apply_fatigue(&mut self, threshold: f32, fatigue_rate: f32) {
478        for state in self.states.values_mut() {
479            if state.flex > threshold {
480                state.fatigue = (state.fatigue + fatigue_rate).clamp(0.0, 1.0);
481            }
482        }
483    }
484}
485
486// ---------------------------------------------------------------------------
487// Free functions
488// ---------------------------------------------------------------------------
489
490/// Linearly blend two rig state maps by factor `t` (0 = all `a`, 1 = all `b`).
491///
492/// Keys present in only one map are included with the missing side treated as
493/// the default relaxed state.
494pub fn blend_rig_states(
495    a: &HashMap<String, MuscleState>,
496    b: &HashMap<String, MuscleState>,
497    t: f32,
498) -> HashMap<String, MuscleState> {
499    let t = t.clamp(0.0, 1.0);
500    let one_minus_t = 1.0 - t;
501
502    let mut result: HashMap<String, MuscleState> = HashMap::new();
503
504    // Collect all keys from both maps
505    let mut keys: std::collections::HashSet<&str> = std::collections::HashSet::new();
506    for k in a.keys() {
507        keys.insert(k.as_str());
508    }
509    for k in b.keys() {
510        keys.insert(k.as_str());
511    }
512
513    let default_state = MuscleState::default();
514
515    for key in keys {
516        let sa = a.get(key).unwrap_or(&default_state);
517        let sb = b.get(key).unwrap_or(&default_state);
518
519        result.insert(
520            key.to_owned(),
521            MuscleState {
522                flex: (sa.flex * one_minus_t + sb.flex * t).clamp(0.0, 1.0),
523                contract: (sa.contract * one_minus_t + sb.contract * t).clamp(0.0, 1.0),
524                fatigue: (sa.fatigue * one_minus_t + sb.fatigue * t).clamp(0.0, 1.0),
525            },
526        );
527    }
528
529    result
530}
531
532/// Convenience wrapper: evaluate a [`MuscleRig`] into morph weights.
533///
534/// Identical to [`MuscleRig::evaluate`] but available as a free function for
535/// ergonomic use in pipelines.
536pub fn rig_to_morphs(rig: &MuscleRig) -> HashMap<String, f32> {
537    rig.evaluate()
538}
539
540/// Estimate muscle activation from a normalised body parameter [0..1].
541///
542/// High `muscle_param` values (muscular body) result in higher activation,
543/// modulated by group-specific sensitivity factors.
544pub fn params_to_muscle_activation(muscle_param: f32, group: &MuscleGroup) -> f32 {
545    let param = muscle_param.clamp(0.0, 1.0);
546
547    // Group-specific sensitivity: some groups respond more strongly to the
548    // general muscularity parameter.
549    let sensitivity = match group {
550        MuscleGroup::Bicep => 1.0,
551        MuscleGroup::Tricep => 0.95,
552        MuscleGroup::Chest => 0.9,
553        MuscleGroup::Back => 0.9,
554        MuscleGroup::Shoulder => 0.85,
555        MuscleGroup::Forearm => 0.8,
556        MuscleGroup::Abs => 0.85,
557        MuscleGroup::Oblique => 0.75,
558        MuscleGroup::Glute => 0.8,
559        MuscleGroup::Quad => 0.9,
560        MuscleGroup::Hamstring => 0.85,
561        MuscleGroup::Calf => 0.8,
562        MuscleGroup::Neck => 0.7,
563        MuscleGroup::Face => 0.3,
564    };
565
566    (param * sensitivity).clamp(0.0, 1.0)
567}
568
569// ---------------------------------------------------------------------------
570// Tests
571// ---------------------------------------------------------------------------
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576    use std::fs;
577
578    // Helper: write a string to a temp file in /tmp/
579    fn write_tmp(filename: &str, content: &str) {
580        fs::write(format!("/tmp/{}", filename), content).expect("write /tmp/ file");
581    }
582
583    // -----------------------------------------------------------------------
584    // MuscleGroup tests
585    // -----------------------------------------------------------------------
586
587    #[test]
588    fn test_muscle_group_all_count() {
589        let all = MuscleGroup::all();
590        assert_eq!(all.len(), 14);
591        write_tmp("muscle_group_all.txt", &format!("{} groups", all.len()));
592    }
593
594    #[test]
595    fn test_muscle_group_names() {
596        assert_eq!(MuscleGroup::Bicep.name(), "Bicep");
597        assert_eq!(MuscleGroup::Face.name(), "Face");
598        assert_eq!(MuscleGroup::Abs.name(), "Abs");
599        write_tmp("muscle_group_names.txt", "OK");
600    }
601
602    #[test]
603    fn test_muscle_group_all_unique_names() {
604        let names: Vec<&str> = MuscleGroup::all().iter().map(|g| g.name()).collect();
605        let unique: std::collections::HashSet<&str> = names.iter().copied().collect();
606        assert_eq!(names.len(), unique.len(), "all group names must be unique");
607        write_tmp("muscle_group_unique.txt", "OK");
608    }
609
610    // -----------------------------------------------------------------------
611    // MuscleState tests
612    // -----------------------------------------------------------------------
613
614    #[test]
615    fn test_muscle_state_default_is_relaxed() {
616        let s = MuscleState::default();
617        assert_eq!(s.flex, 0.0);
618        assert_eq!(s.contract, 0.0);
619        assert_eq!(s.fatigue, 0.0);
620        write_tmp("muscle_state_default.txt", "OK");
621    }
622
623    #[test]
624    fn test_muscle_state_flexed() {
625        let s = MuscleState::flexed(0.7);
626        assert!((s.flex - 0.7).abs() < 1e-6);
627        assert_eq!(s.fatigue, 0.0);
628        write_tmp("muscle_state_flexed.txt", "OK");
629    }
630
631    #[test]
632    fn test_muscle_state_effective_flex_with_fatigue() {
633        let mut s = MuscleState::flexed(1.0);
634        s.fatigue = 0.5;
635        let eff = s.effective_flex();
636        assert!((eff - 0.5).abs() < 1e-6, "eff={eff}");
637        write_tmp("muscle_state_fatigue.txt", &format!("eff={eff}"));
638    }
639
640    #[test]
641    fn test_muscle_state_relaxed() {
642        let s = MuscleState::relaxed();
643        assert_eq!(s.effective_flex(), 0.0);
644        write_tmp("muscle_state_relaxed.txt", "OK");
645    }
646
647    // -----------------------------------------------------------------------
648    // MuscleDefinition tests
649    // -----------------------------------------------------------------------
650
651    #[test]
652    fn test_muscle_definition_builder() {
653        let def = MuscleDefinition::new("bicep_test", MuscleGroup::Bicep)
654            .with_flex_morph("flex_shape", 1.0)
655            .with_contract_morph("contract_shape", 0.5)
656            .with_side(Side::Left)
657            .with_rest_length(0.8);
658
659        assert_eq!(def.name, "bicep_test");
660        assert_eq!(def.group, MuscleGroup::Bicep);
661        assert_eq!(def.flex_morphs.len(), 1);
662        assert_eq!(def.contract_morphs.len(), 1);
663        assert!(def.symmetrical);
664        assert_eq!(def.side, Some(Side::Left));
665        assert!((def.rest_length - 0.8).abs() < 1e-6);
666        write_tmp("muscle_definition_builder.txt", "OK");
667    }
668
669    // -----------------------------------------------------------------------
670    // MuscleRig tests
671    // -----------------------------------------------------------------------
672
673    #[test]
674    fn test_rig_add_and_count() {
675        let mut rig = MuscleRig::new();
676        rig.add_muscle(MuscleDefinition::new("m1", MuscleGroup::Bicep));
677        rig.add_muscle(MuscleDefinition::new("m2", MuscleGroup::Tricep));
678        assert_eq!(rig.count(), 2);
679        write_tmp("rig_count.txt", "OK");
680    }
681
682    #[test]
683    fn test_rig_set_and_get_state() {
684        let mut rig = MuscleRig::new();
685        rig.add_muscle(MuscleDefinition::new("test_muscle", MuscleGroup::Abs));
686        rig.set_state("test_muscle", MuscleState::flexed(0.6));
687        let state = rig.get_state("test_muscle").expect("state must exist");
688        assert!((state.flex - 0.6).abs() < 1e-6);
689        write_tmp("rig_state.txt", "OK");
690    }
691
692    #[test]
693    fn test_rig_evaluate_flex() {
694        let mut rig = MuscleRig::new();
695        rig.add_muscle(
696            MuscleDefinition::new("bicep_l", MuscleGroup::Bicep)
697                .with_flex_morph("bicep_shape", 1.0),
698        );
699        rig.set_state("bicep_l", MuscleState::flexed(0.8));
700        let weights = rig.evaluate();
701        let w = weights.get("bicep_shape").copied().unwrap_or(0.0);
702        assert!((w - 0.8).abs() < 1e-6, "w={w}");
703        write_tmp("rig_evaluate_flex.txt", &format!("w={w}"));
704    }
705
706    #[test]
707    fn test_rig_evaluate_multiple_morphs() {
708        let mut rig = MuscleRig::new();
709        rig.add_muscle(
710            MuscleDefinition::new("chest", MuscleGroup::Chest)
711                .with_flex_morph("chest_shape_a", 0.5)
712                .with_flex_morph("chest_shape_b", 0.8),
713        );
714        rig.set_state("chest", MuscleState::flexed(1.0));
715        let weights = rig.evaluate();
716        assert!((weights["chest_shape_a"] - 0.5).abs() < 1e-6);
717        assert!((weights["chest_shape_b"] - 0.8).abs() < 1e-6);
718        write_tmp("rig_evaluate_multi.txt", "OK");
719    }
720
721    #[test]
722    fn test_rig_relax_all() {
723        let mut rig = MuscleRig::new();
724        rig.add_muscle(MuscleDefinition::new("m1", MuscleGroup::Glute));
725        rig.set_state("m1", MuscleState::flexed(1.0));
726        rig.relax_all();
727        let state = rig.get_state("m1").expect("state");
728        assert_eq!(state.flex, 0.0);
729        write_tmp("rig_relax_all.txt", "OK");
730    }
731
732    #[test]
733    fn test_rig_muscles_in_group() {
734        let rig = MuscleRig::default_rig();
735        let biceps = rig.muscles_in_group(&MuscleGroup::Bicep);
736        assert!(!biceps.is_empty(), "default rig should have biceps");
737        write_tmp("rig_in_group.txt", &format!("biceps={}", biceps.len()));
738    }
739
740    #[test]
741    fn test_rig_flex_group() {
742        let mut rig = MuscleRig::default_rig();
743        rig.flex_group(&MuscleGroup::Bicep, 1.0);
744        let bicep_names: Vec<String> = rig
745            .muscles_in_group(&MuscleGroup::Bicep)
746            .iter()
747            .map(|m| m.name.clone())
748            .collect();
749        for name in &bicep_names {
750            let state = rig.get_state(name).expect("state");
751            assert!((state.flex - 1.0).abs() < 1e-6, "{name} flex should be 1.0");
752        }
753        write_tmp("rig_flex_group.txt", "OK");
754    }
755
756    #[test]
757    fn test_rig_default_count() {
758        let rig = MuscleRig::default_rig();
759        assert!(
760            rig.count() >= 20,
761            "default rig must have ≥20 muscles, got {}",
762            rig.count()
763        );
764        write_tmp("rig_default_count.txt", &format!("{}", rig.count()));
765    }
766
767    #[test]
768    fn test_rig_apply_fatigue() {
769        let mut rig = MuscleRig::new();
770        rig.add_muscle(MuscleDefinition::new("fatigued", MuscleGroup::Quad));
771        rig.set_state("fatigued", MuscleState::flexed(1.0));
772        rig.apply_fatigue(0.5, 0.3);
773        let state = rig.get_state("fatigued").expect("state");
774        assert!(
775            (state.fatigue - 0.3).abs() < 1e-6,
776            "fatigue={}",
777            state.fatigue
778        );
779        write_tmp("rig_apply_fatigue.txt", "OK");
780    }
781
782    #[test]
783    fn test_rig_apply_fatigue_below_threshold() {
784        let mut rig = MuscleRig::new();
785        rig.add_muscle(MuscleDefinition::new("resting", MuscleGroup::Calf));
786        rig.set_state("resting", MuscleState::flexed(0.2));
787        rig.apply_fatigue(0.5, 0.3);
788        let state = rig.get_state("resting").expect("state");
789        assert_eq!(
790            state.fatigue, 0.0,
791            "below-threshold muscle must not fatigue"
792        );
793        write_tmp("rig_no_fatigue.txt", "OK");
794    }
795
796    // -----------------------------------------------------------------------
797    // blend_rig_states tests
798    // -----------------------------------------------------------------------
799
800    #[test]
801    fn test_blend_rig_states_midpoint() {
802        let mut a: HashMap<String, MuscleState> = HashMap::new();
803        a.insert(
804            "m".to_owned(),
805            MuscleState {
806                flex: 0.0,
807                contract: 0.0,
808                fatigue: 0.0,
809            },
810        );
811        let mut b: HashMap<String, MuscleState> = HashMap::new();
812        b.insert(
813            "m".to_owned(),
814            MuscleState {
815                flex: 1.0,
816                contract: 0.0,
817                fatigue: 0.0,
818            },
819        );
820
821        let blended = blend_rig_states(&a, &b, 0.5);
822        let s = &blended["m"];
823        assert!((s.flex - 0.5).abs() < 1e-6, "flex={}", s.flex);
824        write_tmp("blend_states_midpoint.txt", "OK");
825    }
826
827    #[test]
828    fn test_blend_rig_states_t0_equals_a() {
829        let mut a: HashMap<String, MuscleState> = HashMap::new();
830        a.insert("x".to_owned(), MuscleState::flexed(0.7));
831        let b: HashMap<String, MuscleState> = HashMap::new();
832
833        let blended = blend_rig_states(&a, &b, 0.0);
834        let s = &blended["x"];
835        assert!((s.flex - 0.7).abs() < 1e-6);
836        write_tmp("blend_states_t0.txt", "OK");
837    }
838
839    #[test]
840    fn test_blend_rig_states_missing_key_treated_as_default() {
841        let a: HashMap<String, MuscleState> = HashMap::new();
842        let mut b: HashMap<String, MuscleState> = HashMap::new();
843        b.insert("only_in_b".to_owned(), MuscleState::flexed(1.0));
844
845        let blended = blend_rig_states(&a, &b, 1.0);
846        let s = &blended["only_in_b"];
847        assert!((s.flex - 1.0).abs() < 1e-6);
848        write_tmp("blend_missing_key.txt", "OK");
849    }
850
851    // -----------------------------------------------------------------------
852    // rig_to_morphs / params_to_muscle_activation tests
853    // -----------------------------------------------------------------------
854
855    #[test]
856    fn test_rig_to_morphs_convenience() {
857        let mut rig = MuscleRig::new();
858        rig.add_muscle(
859            MuscleDefinition::new("neck", MuscleGroup::Neck).with_flex_morph("neck_shape", 0.6),
860        );
861        rig.set_state("neck", MuscleState::flexed(1.0));
862        let morphs = rig_to_morphs(&rig);
863        assert!((morphs["neck_shape"] - 0.6).abs() < 1e-6);
864        write_tmp("rig_to_morphs.txt", "OK");
865    }
866
867    #[test]
868    fn test_params_to_activation_range() {
869        for group in MuscleGroup::all() {
870            let low = params_to_muscle_activation(0.0, &group);
871            let high = params_to_muscle_activation(1.0, &group);
872            assert!((0.0..=1.0).contains(&low), "{} low={low}", group.name());
873            assert!((0.0..=1.0).contains(&high), "{} high={high}", group.name());
874        }
875        write_tmp("params_activation_range.txt", "OK");
876    }
877
878    #[test]
879    fn test_params_activation_monotone() {
880        // Higher muscle_param must produce >= activation across all groups
881        for group in MuscleGroup::all() {
882            let a = params_to_muscle_activation(0.3, &group);
883            let b = params_to_muscle_activation(0.8, &group);
884            assert!(b >= a, "{} must be monotone (a={a} b={b})", group.name());
885        }
886        write_tmp("params_activation_monotone.txt", "OK");
887    }
888
889    #[test]
890    fn test_muscle_names_list() {
891        let rig = MuscleRig::default_rig();
892        let names = rig.muscle_names();
893        assert_eq!(names.len(), rig.count());
894        assert!(
895            names.contains(&"bicep_left"),
896            "expected bicep_left in names"
897        );
898        write_tmp("muscle_names_list.txt", &names.join("\n"));
899    }
900
901    #[test]
902    fn test_evaluate_relaxed_rig_all_zero() {
903        let rig = MuscleRig::default_rig();
904        // Default rig starts relaxed
905        let weights = rig.evaluate();
906        for (k, v) in &weights {
907            assert_eq!(*v, 0.0, "relaxed rig: {k} should be 0 but is {v}");
908        }
909        write_tmp("evaluate_relaxed_all_zero.txt", "OK");
910    }
911
912    #[test]
913    fn test_side_equality() {
914        assert_eq!(Side::Left, Side::Left);
915        assert_ne!(Side::Left, Side::Right);
916        assert_ne!(Side::Center, Side::Left);
917        write_tmp("side_equality.txt", "OK");
918    }
919}