Skip to main content

oxihuman_morph/
neck_tendon_control.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3#![allow(dead_code)]
4
5//! Neck tendon control — sternocleidomastoid and platysma definition morphs.
6
7use std::f32::consts::FRAC_1_SQRT_2;
8
9/// Neck tendon configuration.
10#[derive(Debug, Clone, PartialEq)]
11#[allow(dead_code)]
12pub struct NeckTendonConfig {
13    pub definition_min: f32,
14    pub definition_max: f32,
15}
16
17impl Default for NeckTendonConfig {
18    fn default() -> Self {
19        Self {
20            definition_min: 0.0,
21            definition_max: 1.0,
22        }
23    }
24}
25
26/// Neck tendon state.
27#[derive(Debug, Clone, PartialEq, Default)]
28#[allow(dead_code)]
29pub struct NeckTendonState {
30    pub scm_left: f32,
31    pub scm_right: f32,
32    pub platysma: f32,
33    pub atlas_protrusion: f32,
34}
35
36/// Morph weight output.
37#[derive(Debug, Clone, PartialEq, Default)]
38#[allow(dead_code)]
39pub struct NeckTendonWeights {
40    pub scm_definition_l: f32,
41    pub scm_definition_r: f32,
42    pub platysma_weight: f32,
43    pub atlas_weight: f32,
44}
45
46/// Create default config.
47#[allow(dead_code)]
48pub fn default_neck_tendon_config() -> NeckTendonConfig {
49    NeckTendonConfig::default()
50}
51
52/// Create new state.
53#[allow(dead_code)]
54pub fn new_neck_tendon_state() -> NeckTendonState {
55    NeckTendonState::default()
56}
57
58/// Set SCM left definition.
59#[allow(dead_code)]
60pub fn nt_set_scm_left(s: &mut NeckTendonState, cfg: &NeckTendonConfig, v: f32) {
61    s.scm_left = v.clamp(cfg.definition_min, cfg.definition_max);
62}
63
64/// Set SCM right definition.
65#[allow(dead_code)]
66pub fn nt_set_scm_right(s: &mut NeckTendonState, cfg: &NeckTendonConfig, v: f32) {
67    s.scm_right = v.clamp(cfg.definition_min, cfg.definition_max);
68}
69
70/// Set both SCM sides equally.
71#[allow(dead_code)]
72pub fn nt_set_scm_both(s: &mut NeckTendonState, cfg: &NeckTendonConfig, v: f32) {
73    let v = v.clamp(cfg.definition_min, cfg.definition_max);
74    s.scm_left = v;
75    s.scm_right = v;
76}
77
78/// Set platysma definition.
79#[allow(dead_code)]
80pub fn nt_set_platysma(s: &mut NeckTendonState, v: f32) {
81    s.platysma = v.clamp(0.0, 1.0);
82}
83
84/// Set atlas protrusion.
85#[allow(dead_code)]
86pub fn nt_set_atlas(s: &mut NeckTendonState, v: f32) {
87    s.atlas_protrusion = v.clamp(0.0, 1.0);
88}
89
90/// Reset to defaults.
91#[allow(dead_code)]
92pub fn nt_reset(s: &mut NeckTendonState) {
93    *s = NeckTendonState::default();
94}
95
96/// Blend two states.
97#[allow(dead_code)]
98pub fn nt_blend(a: &NeckTendonState, b: &NeckTendonState, t: f32) -> NeckTendonState {
99    let t = t.clamp(0.0, 1.0);
100    NeckTendonState {
101        scm_left: a.scm_left + (b.scm_left - a.scm_left) * t,
102        scm_right: a.scm_right + (b.scm_right - a.scm_right) * t,
103        platysma: a.platysma + (b.platysma - a.platysma) * t,
104        atlas_protrusion: a.atlas_protrusion + (b.atlas_protrusion - a.atlas_protrusion) * t,
105    }
106}
107
108/// Convert state to weights.
109#[allow(dead_code)]
110pub fn nt_to_weights(s: &NeckTendonState) -> NeckTendonWeights {
111    NeckTendonWeights {
112        scm_definition_l: s.scm_left,
113        scm_definition_r: s.scm_right,
114        platysma_weight: s.platysma,
115        atlas_weight: s.atlas_protrusion,
116    }
117}
118
119/// Bilateral asymmetry score using FRAC_1_SQRT_2 as normalization.
120#[allow(dead_code)]
121pub fn nt_asymmetry(s: &NeckTendonState) -> f32 {
122    ((s.scm_left - s.scm_right).abs() * FRAC_1_SQRT_2).min(1.0)
123}
124
125/// Export to JSON-like string.
126#[allow(dead_code)]
127pub fn nt_to_json(s: &NeckTendonState) -> String {
128    format!(
129        r#"{{"scm_left":{:.4},"scm_right":{:.4},"platysma":{:.4},"atlas":{:.4}}}"#,
130        s.scm_left, s.scm_right, s.platysma, s.atlas_protrusion
131    )
132}
133
134/// Check if state is neutral.
135#[allow(dead_code)]
136pub fn nt_is_neutral(s: &NeckTendonState) -> bool {
137    [s.scm_left, s.scm_right, s.platysma, s.atlas_protrusion]
138        .iter()
139        .all(|v| v.abs() < 1e-6)
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    #[test]
147    fn default_is_neutral() {
148        assert!(nt_is_neutral(&new_neck_tendon_state()));
149    }
150
151    #[test]
152    fn set_scm_left_clamped() {
153        let cfg = default_neck_tendon_config();
154        let mut s = new_neck_tendon_state();
155        nt_set_scm_left(&mut s, &cfg, 2.0);
156        assert!((s.scm_left - 1.0).abs() < 1e-6);
157    }
158
159    #[test]
160    fn set_scm_both() {
161        let cfg = default_neck_tendon_config();
162        let mut s = new_neck_tendon_state();
163        nt_set_scm_both(&mut s, &cfg, 0.6);
164        assert!((s.scm_left - 0.6).abs() < 1e-6);
165        assert!((s.scm_right - 0.6).abs() < 1e-6);
166    }
167
168    #[test]
169    fn platysma_clamped() {
170        let mut s = new_neck_tendon_state();
171        nt_set_platysma(&mut s, 1.5);
172        assert!((s.platysma - 1.0).abs() < 1e-6);
173    }
174
175    #[test]
176    fn reset_works() {
177        let cfg = default_neck_tendon_config();
178        let mut s = new_neck_tendon_state();
179        nt_set_scm_left(&mut s, &cfg, 0.8);
180        nt_reset(&mut s);
181        assert!(nt_is_neutral(&s));
182    }
183
184    #[test]
185    fn blend_midpoint() {
186        let a = NeckTendonState {
187            scm_left: 0.0,
188            scm_right: 0.0,
189            platysma: 0.0,
190            atlas_protrusion: 0.0,
191        };
192        let b = NeckTendonState {
193            scm_left: 1.0,
194            scm_right: 1.0,
195            platysma: 1.0,
196            atlas_protrusion: 1.0,
197        };
198        let m = nt_blend(&a, &b, 0.5);
199        assert!((m.scm_left - 0.5).abs() < 1e-5);
200    }
201
202    #[test]
203    fn weights_correct() {
204        let s = NeckTendonState {
205            scm_left: 0.3,
206            scm_right: 0.7,
207            platysma: 0.5,
208            atlas_protrusion: 0.2,
209        };
210        let w = nt_to_weights(&s);
211        assert!((w.scm_definition_l - 0.3).abs() < 1e-6);
212        assert!((w.scm_definition_r - 0.7).abs() < 1e-6);
213    }
214
215    #[test]
216    fn asymmetry_symmetric_is_zero() {
217        let s = NeckTendonState {
218            scm_left: 0.5,
219            scm_right: 0.5,
220            platysma: 0.0,
221            atlas_protrusion: 0.0,
222        };
223        assert!(nt_asymmetry(&s) < 1e-6);
224    }
225
226    #[test]
227    fn asymmetry_uses_frac1sqrt2() {
228        let s = NeckTendonState {
229            scm_left: 1.0,
230            scm_right: 0.0,
231            platysma: 0.0,
232            atlas_protrusion: 0.0,
233        };
234        let a = nt_asymmetry(&s);
235        assert!((a - FRAC_1_SQRT_2).abs() < 1e-5);
236    }
237
238    #[test]
239    fn json_contains_platysma() {
240        let s = NeckTendonState {
241            scm_left: 0.0,
242            scm_right: 0.0,
243            platysma: 0.4,
244            atlas_protrusion: 0.0,
245        };
246        assert!(nt_to_json(&s).contains("platysma"));
247    }
248
249    #[test]
250    fn contains_range_check() {
251        let v = 0.7f32;
252        assert!((0.0..=1.0).contains(&v));
253    }
254}