Skip to main content

oxihuman_morph/
muscle_sim.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;
7use std::f32::consts::FRAC_PI_2;
8
9/// Direction of bulge per influenced vertex
10pub enum BulgeDirection {
11    /// Use vertex normal as bulge direction
12    VertexNormal,
13    /// Fixed world-space direction
14    Fixed([f32; 3]),
15    /// Radially outward from a center axis point
16    RadialFrom([f32; 3]),
17}
18
19/// A muscle definition: a named region that bulges when a joint flexes
20pub struct Muscle {
21    pub name: String,
22    /// Which joint drives this muscle
23    pub joint_name: String,
24    /// Flex angle that produces maximum bulge (radians, e.g., PI/2)
25    pub max_flex_angle: f32,
26    /// Peak bulge amplitude (world units)
27    pub bulge_amplitude: f32,
28    /// Vertex influences: (vertex_index, weight 0..1)
29    pub influences: Vec<(u32, f32)>,
30    /// Direction of bulge per influenced vertex (outward normal override)
31    pub bulge_direction: BulgeDirection,
32}
33
34impl Muscle {
35    pub fn new(name: impl Into<String>, joint_name: impl Into<String>) -> Self {
36        Self {
37            name: name.into(),
38            joint_name: joint_name.into(),
39            max_flex_angle: FRAC_PI_2,
40            bulge_amplitude: 0.02,
41            influences: Vec::new(),
42            bulge_direction: BulgeDirection::VertexNormal,
43        }
44    }
45
46    pub fn with_influences(mut self, influences: Vec<(u32, f32)>) -> Self {
47        self.influences = influences;
48        self
49    }
50
51    pub fn with_amplitude(mut self, amp: f32) -> Self {
52        self.bulge_amplitude = amp;
53        self
54    }
55
56    pub fn with_max_flex(mut self, angle: f32) -> Self {
57        self.max_flex_angle = angle;
58        self
59    }
60
61    /// Compute bulge weight `[0,1]` from joint angle using smoothstep
62    pub fn bulge_weight(&self, joint_angle: f32) -> f32 {
63        let t = if self.max_flex_angle == 0.0 {
64            0.0
65        } else {
66            (joint_angle / self.max_flex_angle).clamp(0.0, 1.0)
67        };
68        // smoothstep: t * t * (3 - 2*t)
69        t * t * (3.0 - 2.0 * t)
70    }
71
72    /// Compute per-vertex displacements for a given joint angle and mesh normals
73    pub fn compute_displacements(
74        &self,
75        joint_angle: f32,
76        positions: &[[f32; 3]],
77        normals: &[[f32; 3]],
78    ) -> Vec<(u32, [f32; 3])> {
79        let bw = self.bulge_weight(joint_angle);
80        let mut result = Vec::with_capacity(self.influences.len());
81
82        for &(vid, weight) in &self.influences {
83            let w = bw * weight;
84            let idx = vid as usize;
85
86            let dir = match &self.bulge_direction {
87                BulgeDirection::VertexNormal => {
88                    if idx < normals.len() {
89                        normalize(normals[idx])
90                    } else {
91                        [0.0, 1.0, 0.0]
92                    }
93                }
94                BulgeDirection::Fixed(d) => normalize(*d),
95                BulgeDirection::RadialFrom(center) => {
96                    if idx < positions.len() {
97                        let p = positions[idx];
98                        let v = [p[0] - center[0], p[1] - center[1], p[2] - center[2]];
99                        normalize(v)
100                    } else {
101                        [0.0, 1.0, 0.0]
102                    }
103                }
104            };
105
106            let disp = [
107                dir[0] * w * self.bulge_amplitude,
108                dir[1] * w * self.bulge_amplitude,
109                dir[2] * w * self.bulge_amplitude,
110            ];
111            result.push((vid, disp));
112        }
113
114        result
115    }
116}
117
118/// Normalize a 3D vector; returns [0,1,0] for zero-length vectors
119fn normalize(v: [f32; 3]) -> [f32; 3] {
120    let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
121    if len < 1e-10 {
122        [0.0, 1.0, 0.0]
123    } else {
124        [v[0] / len, v[1] / len, v[2] / len]
125    }
126}
127
128/// Simulate an isolated muscle (unit test helper)
129pub struct MuscleSimulator {
130    pub muscles: Vec<Muscle>,
131}
132
133impl MuscleSimulator {
134    pub fn new() -> Self {
135        Self {
136            muscles: Vec::new(),
137        }
138    }
139
140    pub fn add_muscle(&mut self, muscle: Muscle) {
141        self.muscles.push(muscle);
142    }
143
144    pub fn muscle_count(&self) -> usize {
145        self.muscles.len()
146    }
147
148    pub fn muscles_for_joint(&self, joint: &str) -> Vec<&Muscle> {
149        self.muscles
150            .iter()
151            .filter(|m| m.joint_name == joint)
152            .collect()
153    }
154
155    /// Apply all muscle bulges for given joint angles, return new vertex positions
156    pub fn apply(
157        &self,
158        positions: &[[f32; 3]],
159        normals: &[[f32; 3]],
160        joint_angles: &HashMap<String, f32>,
161    ) -> Vec<[f32; 3]> {
162        let mut result = positions.to_vec();
163
164        for muscle in &self.muscles {
165            let angle = match joint_angles.get(&muscle.joint_name) {
166                Some(&a) => a,
167                None => continue,
168            };
169
170            let displacements = muscle.compute_displacements(angle, positions, normals);
171            for (vid, disp) in displacements {
172                let idx = vid as usize;
173                if idx < result.len() {
174                    result[idx][0] += disp[0];
175                    result[idx][1] += disp[1];
176                    result[idx][2] += disp[2];
177                }
178            }
179        }
180
181        result
182    }
183}
184
185impl Default for MuscleSimulator {
186    fn default() -> Self {
187        Self::new()
188    }
189}
190
191/// Preset muscle definition for a bicep
192pub fn bicep_muscle(joint_name: impl Into<String>) -> Muscle {
193    Muscle {
194        name: "bicep".to_string(),
195        joint_name: joint_name.into(),
196        max_flex_angle: FRAC_PI_2,
197        bulge_amplitude: 0.02,
198        influences: Vec::new(),
199        bulge_direction: BulgeDirection::VertexNormal,
200    }
201}
202
203/// Preset muscle definition for a quadricep
204pub fn quadricep_muscle(joint_name: impl Into<String>) -> Muscle {
205    Muscle {
206        name: "quadricep".to_string(),
207        joint_name: joint_name.into(),
208        max_flex_angle: FRAC_PI_2,
209        bulge_amplitude: 0.025,
210        influences: Vec::new(),
211        bulge_direction: BulgeDirection::VertexNormal,
212    }
213}
214
215/// Preset muscle definition for a calf muscle
216pub fn calf_muscle(joint_name: impl Into<String>) -> Muscle {
217    Muscle {
218        name: "calf".to_string(),
219        joint_name: joint_name.into(),
220        max_flex_angle: FRAC_PI_2,
221        bulge_amplitude: 0.018,
222        influences: Vec::new(),
223        bulge_direction: BulgeDirection::VertexNormal,
224    }
225}
226
227/// Build a muscle from vertex group: all vertices within distance of center
228pub fn muscle_from_region(
229    name: impl Into<String>,
230    joint_name: impl Into<String>,
231    positions: &[[f32; 3]],
232    center: [f32; 3],
233    radius: f32,
234    amplitude: f32,
235) -> Muscle {
236    let mut influences = Vec::new();
237
238    for (i, pos) in positions.iter().enumerate() {
239        let dx = pos[0] - center[0];
240        let dy = pos[1] - center[1];
241        let dz = pos[2] - center[2];
242        let dist = (dx * dx + dy * dy + dz * dz).sqrt();
243
244        if dist <= radius {
245            // Weight by (1 - dist/radius) — linear falloff (Gaussian-like)
246            let weight = if radius > 0.0 {
247                (1.0 - dist / radius).clamp(0.0, 1.0)
248            } else {
249                1.0
250            };
251            influences.push((i as u32, weight));
252        }
253    }
254
255    Muscle {
256        name: name.into(),
257        joint_name: joint_name.into(),
258        max_flex_angle: FRAC_PI_2,
259        bulge_amplitude: amplitude,
260        influences,
261        bulge_direction: BulgeDirection::RadialFrom(center),
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268    use std::f32::consts::{FRAC_PI_2, PI};
269
270    #[test]
271    fn test_muscle_new() {
272        let m = Muscle::new("bicep", "elbow");
273        assert_eq!(m.name, "bicep");
274        assert_eq!(m.joint_name, "elbow");
275        assert!((m.max_flex_angle - FRAC_PI_2).abs() < 1e-6);
276        assert!((m.bulge_amplitude - 0.02).abs() < 1e-6);
277        assert!(m.influences.is_empty());
278    }
279
280    #[test]
281    fn test_bulge_weight_zero_angle() {
282        let m = Muscle::new("m", "j");
283        let w = m.bulge_weight(0.0);
284        assert!(
285            w.abs() < 1e-6,
286            "bulge weight at 0 angle should be 0, got {w}"
287        );
288    }
289
290    #[test]
291    fn test_bulge_weight_max_angle() {
292        let m = Muscle::new("m", "j");
293        let w = m.bulge_weight(FRAC_PI_2);
294        assert!(
295            (w - 1.0).abs() < 1e-6,
296            "bulge weight at max_flex_angle should be 1, got {w}"
297        );
298    }
299
300    #[test]
301    fn test_bulge_weight_half() {
302        let m = Muscle::new("m", "j");
303        // At half of max_flex_angle, t = 0.5, smoothstep(0.5) = 0.5
304        let w = m.bulge_weight(FRAC_PI_2 / 2.0);
305        // smoothstep(0.5) = 0.5 * 0.5 * (3 - 2*0.5) = 0.25 * 2.0 = 0.5
306        assert!(
307            (w - 0.5).abs() < 1e-5,
308            "bulge weight at half max angle should be 0.5, got {w}"
309        );
310    }
311
312    #[test]
313    fn test_bulge_weight_clamped() {
314        let m = Muscle::new("m", "j");
315        // Above max_flex_angle should clamp to 1.0
316        let w_over = m.bulge_weight(PI * 10.0);
317        assert!(
318            (w_over - 1.0).abs() < 1e-6,
319            "bulge weight above max should clamp to 1, got {w_over}"
320        );
321        // Below 0 should clamp to 0.0
322        let w_under = m.bulge_weight(-1.0);
323        assert!(
324            w_under.abs() < 1e-6,
325            "bulge weight below 0 should clamp to 0, got {w_under}"
326        );
327    }
328
329    #[test]
330    fn test_compute_displacements_vertex_normal() {
331        let mut m = Muscle::new("m", "j");
332        m.influences = vec![(0, 1.0)];
333        m.bulge_amplitude = 1.0;
334        m.bulge_direction = BulgeDirection::VertexNormal;
335
336        let positions = vec![[0.0_f32, 0.0, 0.0]];
337        let normals = vec![[0.0_f32, 1.0, 0.0]];
338
339        // At max_flex_angle, w = 1.0, influence = 1.0, dir = (0,1,0)
340        let disps = m.compute_displacements(FRAC_PI_2, &positions, &normals);
341        assert_eq!(disps.len(), 1);
342        let (vid, d) = disps[0];
343        assert_eq!(vid, 0);
344        assert!((d[0]).abs() < 1e-6);
345        assert!(
346            (d[1] - 1.0).abs() < 1e-6,
347            "y displacement should be 1.0, got {}",
348            d[1]
349        );
350        assert!((d[2]).abs() < 1e-6);
351    }
352
353    #[test]
354    fn test_compute_displacements_fixed() {
355        let mut m = Muscle::new("m", "j");
356        m.influences = vec![(0, 1.0)];
357        m.bulge_amplitude = 1.0;
358        m.bulge_direction = BulgeDirection::Fixed([1.0, 0.0, 0.0]);
359
360        let positions = vec![[0.0_f32, 0.0, 0.0]];
361        let normals = vec![[0.0_f32, 1.0, 0.0]];
362
363        let disps = m.compute_displacements(FRAC_PI_2, &positions, &normals);
364        assert_eq!(disps.len(), 1);
365        let (vid, d) = disps[0];
366        assert_eq!(vid, 0);
367        // Fixed direction [1,0,0], amplitude 1.0, full weight => x = 1.0
368        assert!(
369            (d[0] - 1.0).abs() < 1e-6,
370            "x displacement should be 1.0, got {}",
371            d[0]
372        );
373        assert!((d[1]).abs() < 1e-6);
374        assert!((d[2]).abs() < 1e-6);
375    }
376
377    #[test]
378    fn test_compute_displacements_radial() {
379        let mut m = Muscle::new("m", "j");
380        m.influences = vec![(0, 1.0)];
381        m.bulge_amplitude = 1.0;
382        m.bulge_direction = BulgeDirection::RadialFrom([0.0, 0.0, 0.0]);
383
384        // Vertex at [1, 0, 0] from center [0,0,0] => radial dir = [1,0,0]
385        let positions = vec![[1.0_f32, 0.0, 0.0]];
386        let normals = vec![[0.0_f32, 1.0, 0.0]];
387
388        let disps = m.compute_displacements(FRAC_PI_2, &positions, &normals);
389        assert_eq!(disps.len(), 1);
390        let (vid, d) = disps[0];
391        assert_eq!(vid, 0);
392        assert!(
393            (d[0] - 1.0).abs() < 1e-6,
394            "x displacement should be 1.0, got {}",
395            d[0]
396        );
397        assert!((d[1]).abs() < 1e-6);
398        assert!((d[2]).abs() < 1e-6);
399    }
400
401    #[test]
402    fn test_simulator_add_muscle() {
403        let mut sim = MuscleSimulator::new();
404        assert_eq!(sim.muscle_count(), 0);
405        sim.add_muscle(Muscle::new("bicep", "elbow"));
406        sim.add_muscle(Muscle::new("tricep", "elbow"));
407        assert_eq!(sim.muscle_count(), 2);
408    }
409
410    #[test]
411    fn test_simulator_muscles_for_joint() {
412        let mut sim = MuscleSimulator::new();
413        sim.add_muscle(Muscle::new("bicep", "elbow"));
414        sim.add_muscle(Muscle::new("tricep", "elbow"));
415        sim.add_muscle(Muscle::new("quad", "knee"));
416
417        let elbow_muscles = sim.muscles_for_joint("elbow");
418        assert_eq!(elbow_muscles.len(), 2);
419
420        let knee_muscles = sim.muscles_for_joint("knee");
421        assert_eq!(knee_muscles.len(), 1);
422
423        let hip_muscles = sim.muscles_for_joint("hip");
424        assert!(hip_muscles.is_empty());
425    }
426
427    #[test]
428    fn test_simulator_apply() {
429        let mut sim = MuscleSimulator::new();
430        let mut m = Muscle::new("bicep", "elbow");
431        m.influences = vec![(0, 1.0)];
432        m.bulge_amplitude = 1.0;
433        m.bulge_direction = BulgeDirection::VertexNormal;
434        sim.add_muscle(m);
435
436        let positions = vec![[0.0_f32, 0.0, 0.0], [1.0, 0.0, 0.0]];
437        let normals = vec![[0.0_f32, 1.0, 0.0], [0.0, 1.0, 0.0]];
438        let mut joint_angles = HashMap::new();
439        joint_angles.insert("elbow".to_string(), FRAC_PI_2);
440
441        let result = sim.apply(&positions, &normals, &joint_angles);
442        assert_eq!(result.len(), 2);
443        // Vertex 0 should be displaced by 1.0 in y
444        assert!(
445            (result[0][1] - 1.0).abs() < 1e-5,
446            "vertex 0 y should be ~1.0, got {}",
447            result[0][1]
448        );
449        // Vertex 1 should be unchanged
450        assert!((result[1][0] - 1.0).abs() < 1e-6);
451        assert!((result[1][1]).abs() < 1e-6);
452    }
453
454    #[test]
455    fn test_muscle_from_region() {
456        // 5 vertices in a row along x
457        let positions: Vec<[f32; 3]> = (0..5).map(|i| [i as f32 * 0.1, 0.0, 0.0]).collect();
458        let center = [0.2_f32, 0.0, 0.0];
459        let radius = 0.15;
460
461        let m = muscle_from_region("test_muscle", "hip", &positions, center, radius, 0.05);
462        assert_eq!(m.name, "test_muscle");
463        assert_eq!(m.joint_name, "hip");
464        assert!((m.bulge_amplitude - 0.05).abs() < 1e-6);
465
466        // Vertices at x=0.1 (dist=0.1), x=0.2 (dist=0.0), x=0.3 (dist=0.1) are within radius=0.15
467        // Vertices at x=0.0 (dist=0.2) and x=0.4 (dist=0.2) are outside
468        assert!(!m.influences.is_empty());
469        // Center vertex (x=0.2, dist=0) should have weight ~1.0
470        let center_inf = m.influences.iter().find(|&&(vi, _)| vi == 2);
471        assert!(center_inf.is_some());
472        let (_, w) = center_inf.expect("should succeed");
473        assert!(
474            (*w - 1.0).abs() < 1e-5,
475            "center vertex weight should be 1.0, got {w}"
476        );
477    }
478
479    #[test]
480    fn test_preset_muscles() {
481        let bicep = bicep_muscle("elbow_L");
482        assert_eq!(bicep.name, "bicep");
483        assert_eq!(bicep.joint_name, "elbow_L");
484        assert!((bicep.max_flex_angle - FRAC_PI_2).abs() < 1e-6);
485        assert!((bicep.bulge_amplitude - 0.02).abs() < 1e-6);
486
487        let quad = quadricep_muscle("knee_R");
488        assert_eq!(quad.name, "quadricep");
489        assert_eq!(quad.joint_name, "knee_R");
490        assert!((quad.bulge_amplitude - 0.025).abs() < 1e-6);
491
492        let calf = calf_muscle("ankle_L");
493        assert_eq!(calf.name, "calf");
494        assert_eq!(calf.joint_name, "ankle_L");
495        assert!((calf.bulge_amplitude - 0.018).abs() < 1e-6);
496    }
497}