Skip to main content

oxihuman_morph/
cheek_jowl_control.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3#![allow(dead_code)]
4
5//! Cheek-jowl (lower cheek / mandibular fullness) control.
6
7/// Side enum.
8#[allow(dead_code)]
9#[derive(Clone, Copy, Debug, PartialEq, Eq)]
10pub enum JowlSide {
11    Left,
12    Right,
13    Both,
14}
15
16/// Jowl state.
17#[allow(dead_code)]
18#[derive(Clone, Debug)]
19pub struct CheekJowlState {
20    pub sag_left: f32,
21    pub sag_right: f32,
22    pub volume_left: f32,
23    pub volume_right: f32,
24}
25
26/// Config.
27#[allow(dead_code)]
28#[derive(Clone, Debug)]
29pub struct CheekJowlConfig {
30    pub max_sag: f32,
31    pub max_volume: f32,
32}
33
34impl Default for CheekJowlConfig {
35    fn default() -> Self {
36        Self {
37            max_sag: 1.0,
38            max_volume: 1.0,
39        }
40    }
41}
42
43impl Default for CheekJowlState {
44    fn default() -> Self {
45        Self {
46            sag_left: 0.0,
47            sag_right: 0.0,
48            volume_left: 0.0,
49            volume_right: 0.0,
50        }
51    }
52}
53
54#[allow(dead_code)]
55pub fn new_cheek_jowl_state() -> CheekJowlState {
56    CheekJowlState::default()
57}
58
59#[allow(dead_code)]
60pub fn default_cheek_jowl_config() -> CheekJowlConfig {
61    CheekJowlConfig::default()
62}
63
64#[allow(dead_code)]
65pub fn cj_set_sag(state: &mut CheekJowlState, cfg: &CheekJowlConfig, side: JowlSide, v: f32) {
66    let v = v.clamp(0.0, cfg.max_sag);
67    match side {
68        JowlSide::Left => state.sag_left = v,
69        JowlSide::Right => state.sag_right = v,
70        JowlSide::Both => {
71            state.sag_left = v;
72            state.sag_right = v;
73        }
74    }
75}
76
77#[allow(dead_code)]
78pub fn cj_set_volume(state: &mut CheekJowlState, cfg: &CheekJowlConfig, side: JowlSide, v: f32) {
79    let v = v.clamp(0.0, cfg.max_volume);
80    match side {
81        JowlSide::Left => state.volume_left = v,
82        JowlSide::Right => state.volume_right = v,
83        JowlSide::Both => {
84            state.volume_left = v;
85            state.volume_right = v;
86        }
87    }
88}
89
90#[allow(dead_code)]
91pub fn cj_reset(state: &mut CheekJowlState) {
92    *state = CheekJowlState::default();
93}
94
95#[allow(dead_code)]
96pub fn cj_is_neutral(state: &CheekJowlState) -> bool {
97    state.sag_left < 1e-4
98        && state.sag_right < 1e-4
99        && state.volume_left < 1e-4
100        && state.volume_right < 1e-4
101}
102
103#[allow(dead_code)]
104pub fn cj_blend(a: &CheekJowlState, b: &CheekJowlState, t: f32) -> CheekJowlState {
105    let t = t.clamp(0.0, 1.0);
106    CheekJowlState {
107        sag_left: a.sag_left + (b.sag_left - a.sag_left) * t,
108        sag_right: a.sag_right + (b.sag_right - a.sag_right) * t,
109        volume_left: a.volume_left + (b.volume_left - a.volume_left) * t,
110        volume_right: a.volume_right + (b.volume_right - a.volume_right) * t,
111    }
112}
113
114#[allow(dead_code)]
115pub fn cj_symmetry(state: &CheekJowlState) -> f32 {
116    1.0 - (state.sag_left - state.sag_right).abs().min(1.0)
117}
118
119#[allow(dead_code)]
120pub fn cj_total_volume(state: &CheekJowlState) -> f32 {
121    state.volume_left + state.volume_right
122}
123
124#[allow(dead_code)]
125pub fn cj_to_weights(state: &CheekJowlState) -> [f32; 4] {
126    [
127        state.sag_left,
128        state.sag_right,
129        state.volume_left,
130        state.volume_right,
131    ]
132}
133
134#[allow(dead_code)]
135pub fn cj_to_json(state: &CheekJowlState) -> String {
136    format!(
137        "{{\"sag_left\":{:.4},\"sag_right\":{:.4},\"vol_left\":{:.4},\"vol_right\":{:.4}}}",
138        state.sag_left, state.sag_right, state.volume_left, state.volume_right
139    )
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    #[test]
147    fn default_neutral() {
148        assert!(cj_is_neutral(&new_cheek_jowl_state()));
149    }
150
151    #[test]
152    fn set_sag_clamps() {
153        let mut s = new_cheek_jowl_state();
154        let cfg = default_cheek_jowl_config();
155        cj_set_sag(&mut s, &cfg, JowlSide::Left, 5.0);
156        assert!(s.sag_left <= cfg.max_sag);
157    }
158
159    #[test]
160    fn set_both_sides() {
161        let mut s = new_cheek_jowl_state();
162        let cfg = default_cheek_jowl_config();
163        cj_set_sag(&mut s, &cfg, JowlSide::Both, 0.7);
164        assert!((s.sag_left - 0.7).abs() < 1e-5);
165        assert!((s.sag_right - 0.7).abs() < 1e-5);
166    }
167
168    #[test]
169    fn reset_neutral() {
170        let mut s = new_cheek_jowl_state();
171        let cfg = default_cheek_jowl_config();
172        cj_set_sag(&mut s, &cfg, JowlSide::Both, 0.5);
173        cj_reset(&mut s);
174        assert!(cj_is_neutral(&s));
175    }
176
177    #[test]
178    fn blend_t0_is_a() {
179        let mut a = new_cheek_jowl_state();
180        let b = new_cheek_jowl_state();
181        let cfg = default_cheek_jowl_config();
182        cj_set_sag(&mut a, &cfg, JowlSide::Left, 0.6);
183        let r = cj_blend(&a, &b, 0.0);
184        assert!((r.sag_left - 0.6).abs() < 1e-5);
185    }
186
187    #[test]
188    fn symmetry_symmetric() {
189        let s = new_cheek_jowl_state();
190        assert!((cj_symmetry(&s) - 1.0).abs() < 1e-5);
191    }
192
193    #[test]
194    fn total_volume_zero_by_default() {
195        assert!((cj_total_volume(&new_cheek_jowl_state())).abs() < 1e-5);
196    }
197
198    #[test]
199    fn weights_len() {
200        assert_eq!(cj_to_weights(&new_cheek_jowl_state()).len(), 4);
201    }
202
203    #[test]
204    fn json_contains_sag() {
205        let s = new_cheek_jowl_state();
206        assert!(cj_to_json(&s).contains("sag"));
207    }
208
209    #[test]
210    fn volume_not_negative() {
211        let mut s = new_cheek_jowl_state();
212        let cfg = default_cheek_jowl_config();
213        cj_set_volume(&mut s, &cfg, JowlSide::Right, -1.0);
214        assert!(s.volume_right >= 0.0);
215    }
216}