Skip to main content

oxihuman_morph/
skin_deform.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4#![allow(dead_code)]
5
6use crate::params::ParamState;
7use std::collections::HashMap;
8
9/// Morph weight map: morph name → blend weight.
10pub type MorphMap = HashMap<String, f32>;
11
12// ---------------------------------------------------------------------------
13// SkinDeformPattern
14// ---------------------------------------------------------------------------
15
16/// A skin deformation pattern: maps input parameters to morph weight contributions.
17pub struct SkinDeformPattern {
18    pub name: String,
19    /// Body region, e.g. "forearm", "cheek", "belly".
20    pub region: String,
21    /// Which `ParamState` fields drive this pattern.
22    pub driver_params: Vec<String>,
23    /// Morph weights at zero deformation.
24    pub base_weights: MorphMap,
25    /// Morph weights at full deformation.
26    pub max_weights: MorphMap,
27}
28
29impl SkinDeformPattern {
30    /// Linearly interpolate between `base_weights` and `max_weights` by `t ∈ [0,1]`.
31    pub fn evaluate(&self, t: f32) -> MorphMap {
32        let t = t.clamp(0.0, 1.0);
33        let mut out: MorphMap = self.base_weights.clone();
34        for (k, v_max) in &self.max_weights {
35            let v_base = self.base_weights.get(k).copied().unwrap_or(0.0);
36            out.insert(k.clone(), v_base + (v_max - v_base) * t);
37        }
38        out
39    }
40
41    pub fn name(&self) -> &str {
42        &self.name
43    }
44
45    pub fn region(&self) -> &str {
46        &self.region
47    }
48}
49
50// ---------------------------------------------------------------------------
51// SkinDeformSystem
52// ---------------------------------------------------------------------------
53
54/// Collection of `SkinDeformPattern`s with bulk evaluation.
55pub struct SkinDeformSystem {
56    patterns: Vec<SkinDeformPattern>,
57}
58
59impl SkinDeformSystem {
60    pub fn new() -> Self {
61        SkinDeformSystem {
62            patterns: Vec::new(),
63        }
64    }
65
66    pub fn add_pattern(&mut self, p: SkinDeformPattern) {
67        self.patterns.push(p);
68    }
69
70    /// For each pattern, compute the average driver value from `params`,
71    /// call `evaluate(t)`, and blend results additively (clamped to `[0,1]`).
72    pub fn evaluate_all(&self, params: &ParamState) -> MorphMap {
73        let mut out: MorphMap = HashMap::new();
74        for pat in &self.patterns {
75            let t = if pat.driver_params.is_empty() {
76                0.0_f32
77            } else {
78                let sum: f32 = pat
79                    .driver_params
80                    .iter()
81                    .map(|k| driver_value(params, k))
82                    .sum();
83                (sum / pat.driver_params.len() as f32).clamp(0.0, 1.0)
84            };
85            for (k, w) in pat.evaluate(t) {
86                let entry = out.entry(k).or_insert(0.0);
87                *entry = (*entry + w).clamp(0.0, 1.0);
88            }
89        }
90        out
91    }
92
93    pub fn pattern_count(&self) -> usize {
94        self.patterns.len()
95    }
96
97    pub fn find_pattern(&self, name: &str) -> Option<&SkinDeformPattern> {
98        self.patterns.iter().find(|p| p.name == name)
99    }
100}
101
102impl Default for SkinDeformSystem {
103    fn default() -> Self {
104        Self::new()
105    }
106}
107
108// ---------------------------------------------------------------------------
109// Driver extraction
110// ---------------------------------------------------------------------------
111
112fn driver_value(params: &ParamState, key: &str) -> f32 {
113    match key {
114        "muscle" => params.muscle,
115        "weight" => params.weight,
116        "age" => params.age,
117        _ => params.extra.get(key).copied().unwrap_or(0.0),
118    }
119}
120
121// ---------------------------------------------------------------------------
122// Standalone helper functions
123// ---------------------------------------------------------------------------
124
125/// Generate wrinkle morph weights for a joint bend.
126///
127/// Produces entries like `"<morph_prefix>_inner"` and `"<morph_prefix>_outer"`,
128/// with intensity proportional to `bend_angle / max_angle`.
129pub fn wrinkle_weights(bend_angle: f32, max_angle: f32, morph_prefix: &str) -> MorphMap {
130    let mut map = MorphMap::new();
131    if max_angle <= 0.0 {
132        return map;
133    }
134    let t = (bend_angle / max_angle).clamp(0.0, 1.0);
135    // Smooth the wrinkle curve: more pronounced at higher bend angles.
136    let inner = t * t;
137    let outer = t * (1.0 - t * 0.5);
138    map.insert(format!("{morph_prefix}_inner"), inner);
139    map.insert(format!("{morph_prefix}_outer"), outer);
140    map.insert(format!("{morph_prefix}_crease"), t);
141    map
142}
143
144/// Generate muscle bulge morph weights for a given region and activation level.
145///
146/// `muscle_activation` ∈ [0, 1], `region` is a string like `"bicep"`, `"calf"`.
147#[allow(clippy::too_many_arguments)]
148pub fn bulge_weights(muscle_activation: f32, region: &str) -> MorphMap {
149    let mut map = MorphMap::new();
150    let t = muscle_activation.clamp(0.0, 1.0);
151    // Peak bulge is strongest at ~0.75 activation.
152    let bulge = (t * 1.333).clamp(0.0, 1.0);
153    let stretch = t * 0.4;
154    map.insert(format!("{region}_bulge"), bulge);
155    map.insert(format!("{region}_stretch"), stretch);
156    map.insert(format!("{region}_vein"), (t - 0.6).max(0.0) * 2.5);
157    map
158}
159
160/// Generate fat/gravity sag morph weights.
161///
162/// `bmi` and `age` are normalised [0, 1] parameters.
163/// `gravity_axis`: 0 = Y-down (standing), 1 = X (leaning), 2 = Z (prone).
164pub fn sag_weights(bmi: f32, age: f32, gravity_axis: u8) -> MorphMap {
165    let mut map = MorphMap::new();
166    let b = bmi.clamp(0.0, 1.0);
167    let a = age.clamp(0.0, 1.0);
168    // Combined sag factor — fat mass × age-related skin laxity.
169    let sag = b * 0.6 + a * 0.4;
170    let axis_tag = match gravity_axis {
171        0 => "down",
172        1 => "side",
173        _ => "prone",
174    };
175    map.insert(format!("belly_sag_{axis_tag}"), sag);
176    map.insert(format!("chest_sag_{axis_tag}"), sag * 0.7);
177    map.insert(
178        format!("jowl_sag_{axis_tag}"),
179        (a * 0.5 + b * 0.3).clamp(0.0, 1.0),
180    );
181    map.insert(format!("buttock_sag_{axis_tag}"), sag * 0.8);
182    map
183}
184
185/// Blend two `MorphMap`s: `result[k] = lerp(a[k], b[k], t)`.
186pub fn blend_skin_maps(a: &MorphMap, b: &MorphMap, t: f32) -> MorphMap {
187    let t = t.clamp(0.0, 1.0);
188    let mut out = MorphMap::new();
189    // Keys from `a`
190    for (k, va) in a {
191        let vb = b.get(k).copied().unwrap_or(0.0);
192        out.insert(k.clone(), va + (vb - va) * t);
193    }
194    // Keys only in `b`
195    for (k, vb) in b {
196        if !a.contains_key(k) {
197            out.insert(k.clone(), vb * t);
198        }
199    }
200    out
201}
202
203/// Clamp every weight in a `MorphMap` to `[lo, hi]`.
204pub fn clamp_skin_map(map: &MorphMap, lo: f32, hi: f32) -> MorphMap {
205    map.iter()
206        .map(|(k, v)| (k.clone(), v.clamp(lo, hi)))
207        .collect()
208}
209
210// ---------------------------------------------------------------------------
211// Helpers for building patterns
212// ---------------------------------------------------------------------------
213
214fn make_pattern(
215    name: &str,
216    region: &str,
217    drivers: &[&str],
218    base: &[(&str, f32)],
219    max: &[(&str, f32)],
220) -> SkinDeformPattern {
221    SkinDeformPattern {
222        name: name.to_string(),
223        region: region.to_string(),
224        driver_params: drivers.iter().map(|s| s.to_string()).collect(),
225        base_weights: base.iter().map(|(k, v)| (k.to_string(), *v)).collect(),
226        max_weights: max.iter().map(|(k, v)| (k.to_string(), *v)).collect(),
227    }
228}
229
230/// Build a `SkinDeformSystem` pre-loaded with 8 patterns covering the most
231/// common skin-deformation scenarios.
232pub fn default_skin_system() -> SkinDeformSystem {
233    let mut sys = SkinDeformSystem::new();
234
235    // 1. Elbow bend wrinkles
236    sys.add_pattern(make_pattern(
237        "elbow_bend",
238        "forearm",
239        &["elbow_flex"],
240        &[("elbow_wrinkle_inner", 0.0), ("elbow_wrinkle_outer", 0.0)],
241        &[("elbow_wrinkle_inner", 1.0), ("elbow_wrinkle_outer", 0.6)],
242    ));
243
244    // 2. Knee bend wrinkles
245    sys.add_pattern(make_pattern(
246        "knee_bend",
247        "lower_leg",
248        &["knee_flex"],
249        &[("knee_wrinkle_inner", 0.0), ("knee_wrinkle_outer", 0.0)],
250        &[("knee_wrinkle_inner", 1.0), ("knee_wrinkle_outer", 0.5)],
251    ));
252
253    // 3. Cheek squash (smile / puff)
254    sys.add_pattern(make_pattern(
255        "cheek_squash",
256        "cheek",
257        &["cheek_squash"],
258        &[("cheek_bulge", 0.0), ("nasolabial_fold", 0.0)],
259        &[("cheek_bulge", 0.9), ("nasolabial_fold", 0.7)],
260    ));
261
262    // 4. Belly sag (weight + age)
263    sys.add_pattern(make_pattern(
264        "belly_sag",
265        "belly",
266        &["weight", "age"],
267        &[("belly_sag_down", 0.0), ("belly_overhang", 0.0)],
268        &[("belly_sag_down", 1.0), ("belly_overhang", 0.8)],
269    ));
270
271    // 5. Bicep bulge (muscle activation)
272    sys.add_pattern(make_pattern(
273        "bicep_bulge",
274        "upper_arm",
275        &["muscle"],
276        &[("bicep_bulge", 0.0), ("bicep_vein", 0.0)],
277        &[("bicep_bulge", 1.0), ("bicep_vein", 0.6)],
278    ));
279
280    // 6. Shoulder wrinkle (arm raise)
281    sys.add_pattern(make_pattern(
282        "shoulder_wrinkle",
283        "shoulder",
284        &["shoulder_raise"],
285        &[("shoulder_wrinkle_top", 0.0), ("deltoid_crease", 0.0)],
286        &[("shoulder_wrinkle_top", 0.8), ("deltoid_crease", 0.5)],
287    ));
288
289    // 7. Neck wrinkle (age-driven)
290    sys.add_pattern(make_pattern(
291        "neck_wrinkle",
292        "neck",
293        &["age"],
294        &[("neck_wrinkle_h", 0.0), ("neck_wrinkle_v", 0.0)],
295        &[("neck_wrinkle_h", 0.9), ("neck_wrinkle_v", 0.5)],
296    ));
297
298    // 8. Brow compression (frown)
299    sys.add_pattern(make_pattern(
300        "brow_compression",
301        "forehead",
302        &["brow_compress"],
303        &[("glabellar_crease", 0.0), ("brow_furrow", 0.0)],
304        &[("glabellar_crease", 1.0), ("brow_furrow", 0.8)],
305    ));
306
307    sys
308}
309
310// ---------------------------------------------------------------------------
311// Tests
312// ---------------------------------------------------------------------------
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    fn make_param(muscle: f32, weight: f32, age: f32) -> ParamState {
319        ParamState::new(0.5, weight, muscle, age)
320    }
321
322    // --- SkinDeformPattern::evaluate ---
323
324    #[test]
325    fn evaluate_at_zero_returns_base() {
326        let pat = make_pattern("test", "arm", &[], &[("a", 0.2)], &[("a", 0.8)]);
327        let result = pat.evaluate(0.0);
328        let v = *result.get("a").expect("should succeed");
329        assert!((v - 0.2).abs() < 1e-5, "expected 0.2, got {v}");
330    }
331
332    #[test]
333    fn evaluate_at_one_returns_max() {
334        let pat = make_pattern("test", "arm", &[], &[("a", 0.2)], &[("a", 0.8)]);
335        let result = pat.evaluate(1.0);
336        let v = *result.get("a").expect("should succeed");
337        assert!((v - 0.8).abs() < 1e-5, "expected 0.8, got {v}");
338    }
339
340    #[test]
341    fn evaluate_at_half_is_midpoint() {
342        let pat = make_pattern("test", "leg", &[], &[("x", 0.0)], &[("x", 1.0)]);
343        let result = pat.evaluate(0.5);
344        let v = *result.get("x").expect("should succeed");
345        assert!((v - 0.5).abs() < 1e-5);
346    }
347
348    #[test]
349    fn evaluate_clamps_t_above_one() {
350        let pat = make_pattern("test", "leg", &[], &[("x", 0.0)], &[("x", 1.0)]);
351        let result = pat.evaluate(2.0);
352        let v = *result.get("x").expect("should succeed");
353        assert!((v - 1.0).abs() < 1e-5, "should clamp to 1.0");
354    }
355
356    #[test]
357    fn evaluate_clamps_t_below_zero() {
358        let pat = make_pattern("test", "leg", &[], &[("x", 0.0)], &[("x", 1.0)]);
359        let result = pat.evaluate(-1.0);
360        let v = *result.get("x").expect("should succeed");
361        assert!((v - 0.0).abs() < 1e-5, "should clamp to 0.0");
362    }
363
364    #[test]
365    fn evaluate_includes_base_only_keys() {
366        let pat = make_pattern("test", "face", &[], &[("base_only", 0.3)], &[]);
367        let result = pat.evaluate(0.5);
368        // base_only stays at 0.3 (no max entry → lerp(0.3, 0.3, 0.5))
369        let v = *result.get("base_only").expect("should succeed");
370        assert!((v - 0.3).abs() < 1e-5);
371    }
372
373    // --- SkinDeformSystem ---
374
375    #[test]
376    fn system_starts_empty() {
377        let sys = SkinDeformSystem::new();
378        assert_eq!(sys.pattern_count(), 0);
379    }
380
381    #[test]
382    fn add_pattern_increments_count() {
383        let mut sys = SkinDeformSystem::new();
384        let pat = make_pattern("p1", "arm", &[], &[], &[]);
385        sys.add_pattern(pat);
386        assert_eq!(sys.pattern_count(), 1);
387    }
388
389    #[test]
390    fn find_pattern_returns_none_for_missing() {
391        let sys = SkinDeformSystem::new();
392        assert!(sys.find_pattern("ghost").is_none());
393    }
394
395    #[test]
396    fn find_pattern_returns_correct_region() {
397        let mut sys = SkinDeformSystem::new();
398        sys.add_pattern(make_pattern("elbow", "forearm", &[], &[], &[]));
399        let found = sys.find_pattern("elbow").expect("should succeed");
400        assert_eq!(found.region(), "forearm");
401    }
402
403    #[test]
404    fn evaluate_all_zero_drivers_yields_base_weights() {
405        let mut sys = SkinDeformSystem::new();
406        sys.add_pattern(make_pattern(
407            "neck",
408            "neck",
409            &["age"],
410            &[("neck_crease", 0.1)],
411            &[("neck_crease", 0.9)],
412        ));
413        let params = make_param(0.0, 0.0, 0.0); // age=0 → t=0
414        let result = sys.evaluate_all(&params);
415        let v = *result.get("neck_crease").expect("should succeed");
416        assert!((v - 0.1).abs() < 1e-5, "expected base=0.1, got {v}");
417    }
418
419    #[test]
420    fn evaluate_all_full_muscle_gives_max_bicep() {
421        let mut sys = SkinDeformSystem::new();
422        sys.add_pattern(make_pattern(
423            "bicep",
424            "upper_arm",
425            &["muscle"],
426            &[("bicep_bulge", 0.0)],
427            &[("bicep_bulge", 1.0)],
428        ));
429        let params = make_param(1.0, 0.5, 0.5);
430        let result = sys.evaluate_all(&params);
431        let v = *result.get("bicep_bulge").expect("should succeed");
432        assert!((v - 1.0).abs() < 1e-5);
433    }
434
435    // --- wrinkle_weights ---
436
437    #[test]
438    fn wrinkle_weights_zero_bend_is_zero() {
439        let w = wrinkle_weights(0.0, 180.0, "elbow_wrinkle");
440        let inner = *w.get("elbow_wrinkle_inner").expect("should succeed");
441        assert!(inner.abs() < 1e-6);
442    }
443
444    #[test]
445    fn wrinkle_weights_full_bend_inner_is_one() {
446        let w = wrinkle_weights(180.0, 180.0, "elbow_wrinkle");
447        let inner = *w.get("elbow_wrinkle_inner").expect("should succeed");
448        assert!((inner - 1.0).abs() < 1e-5);
449    }
450
451    #[test]
452    fn wrinkle_weights_max_angle_zero_returns_empty() {
453        let w = wrinkle_weights(90.0, 0.0, "x");
454        assert!(w.is_empty());
455    }
456
457    // --- bulge_weights ---
458
459    #[test]
460    fn bulge_weights_zero_activation() {
461        let w = bulge_weights(0.0, "bicep");
462        let b = *w.get("bicep_bulge").expect("should succeed");
463        assert!(b.abs() < 1e-6);
464    }
465
466    #[test]
467    fn bulge_weights_full_activation_clamps_to_one() {
468        let w = bulge_weights(1.0, "bicep");
469        let b = *w.get("bicep_bulge").expect("should succeed");
470        assert!(b <= 1.0 + 1e-6);
471    }
472
473    // --- sag_weights ---
474
475    #[test]
476    fn sag_weights_zero_params_minimal_sag() {
477        let w = sag_weights(0.0, 0.0, 0);
478        let sag = *w.get("belly_sag_down").expect("should succeed");
479        assert!(sag.abs() < 1e-6);
480    }
481
482    #[test]
483    fn sag_weights_keys_contain_axis_tag() {
484        let w = sag_weights(0.5, 0.5, 1);
485        assert!(w.contains_key("belly_sag_side"));
486        assert!(w.contains_key("chest_sag_side"));
487    }
488
489    // --- blend_skin_maps ---
490
491    #[test]
492    fn blend_skin_maps_at_zero_returns_a() {
493        let a: MorphMap = [("k".to_string(), 0.2)].into();
494        let b: MorphMap = [("k".to_string(), 0.8)].into();
495        let r = blend_skin_maps(&a, &b, 0.0);
496        assert!((r["k"] - 0.2).abs() < 1e-5);
497    }
498
499    #[test]
500    fn blend_skin_maps_at_one_returns_b() {
501        let a: MorphMap = [("k".to_string(), 0.2)].into();
502        let b: MorphMap = [("k".to_string(), 0.8)].into();
503        let r = blend_skin_maps(&a, &b, 1.0);
504        assert!((r["k"] - 0.8).abs() < 1e-5);
505    }
506
507    #[test]
508    fn blend_skin_maps_b_only_key_scales_by_t() {
509        let a: MorphMap = HashMap::new();
510        let b: MorphMap = [("only_b".to_string(), 1.0)].into();
511        let r = blend_skin_maps(&a, &b, 0.5);
512        assert!((r["only_b"] - 0.5).abs() < 1e-5);
513    }
514
515    // --- clamp_skin_map ---
516
517    #[test]
518    fn clamp_skin_map_clamps_above_hi() {
519        let m: MorphMap = [("a".to_string(), 2.0)].into();
520        let c = clamp_skin_map(&m, 0.0, 1.0);
521        assert!((c["a"] - 1.0).abs() < 1e-5);
522    }
523
524    #[test]
525    fn clamp_skin_map_clamps_below_lo() {
526        let m: MorphMap = [("a".to_string(), -1.0)].into();
527        let c = clamp_skin_map(&m, 0.0, 1.0);
528        assert!((c["a"] - 0.0).abs() < 1e-5);
529    }
530
531    // --- default_skin_system ---
532
533    #[test]
534    fn default_skin_system_has_eight_patterns() {
535        let sys = default_skin_system();
536        assert_eq!(sys.pattern_count(), 8);
537    }
538
539    #[test]
540    fn default_skin_system_contains_belly_sag() {
541        let sys = default_skin_system();
542        assert!(sys.find_pattern("belly_sag").is_some());
543    }
544
545    #[test]
546    fn default_skin_system_evaluate_all_no_panic() {
547        let sys = default_skin_system();
548        let params = ParamState::default();
549        let result = sys.evaluate_all(&params);
550        // All weights should be in [0,1]
551        for v in result.values() {
552            assert!(*v >= 0.0 && *v <= 1.0, "weight out of range: {v}");
553        }
554    }
555}