Skip to main content

oxihuman_morph/
cheek_sag_control.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan) / SPDX-License-Identifier: Apache-2.0
2#![allow(dead_code)]
3
4//! Cheek sag control — inferior gravitational ptosis of cheek soft tissue.
5
6/// Side of the face.
7#[allow(dead_code)]
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum SagSide {
10    Left,
11    Right,
12}
13
14/// Config for cheek sag.
15#[allow(dead_code)]
16#[derive(Debug, Clone, PartialEq)]
17pub struct CheekSagConfig {
18    pub max_sag_m: f32,
19}
20
21impl Default for CheekSagConfig {
22    fn default() -> Self {
23        Self { max_sag_m: 0.012 }
24    }
25}
26
27/// Runtime state.
28#[allow(dead_code)]
29#[derive(Debug, Clone, Default)]
30pub struct CheekSagState {
31    pub left: f32,
32    pub right: f32,
33}
34
35#[allow(dead_code)]
36pub fn new_cheek_sag_state() -> CheekSagState {
37    CheekSagState::default()
38}
39
40#[allow(dead_code)]
41pub fn default_cheek_sag_config() -> CheekSagConfig {
42    CheekSagConfig::default()
43}
44
45/// Set sag for one side, 0..=1.
46#[allow(dead_code)]
47pub fn csag_set(state: &mut CheekSagState, side: SagSide, v: f32) {
48    let v = v.clamp(0.0, 1.0);
49    match side {
50        SagSide::Left => state.left = v,
51        SagSide::Right => state.right = v,
52    }
53}
54
55/// Set both sides.
56#[allow(dead_code)]
57pub fn csag_set_both(state: &mut CheekSagState, v: f32) {
58    let v = v.clamp(0.0, 1.0);
59    state.left = v;
60    state.right = v;
61}
62
63/// Reset.
64#[allow(dead_code)]
65pub fn csag_reset(state: &mut CheekSagState) {
66    *state = CheekSagState::default();
67}
68
69/// Whether neutral.
70#[allow(dead_code)]
71pub fn csag_is_neutral(state: &CheekSagState) -> bool {
72    state.left < 1e-4 && state.right < 1e-4
73}
74
75/// Asymmetry between left and right.
76#[allow(dead_code)]
77pub fn csag_asymmetry(state: &CheekSagState) -> f32 {
78    (state.left - state.right).abs()
79}
80
81/// Average sag.
82#[allow(dead_code)]
83pub fn csag_average(state: &CheekSagState) -> f32 {
84    (state.left + state.right) * 0.5
85}
86
87/// Downward displacement in metres.
88#[allow(dead_code)]
89pub fn csag_to_weights(state: &CheekSagState, cfg: &CheekSagConfig) -> [f32; 2] {
90    [state.left * cfg.max_sag_m, state.right * cfg.max_sag_m]
91}
92
93/// Blend two states.
94#[allow(dead_code)]
95pub fn csag_blend(a: &CheekSagState, b: &CheekSagState, t: f32) -> CheekSagState {
96    let t = t.clamp(0.0, 1.0);
97    let inv = 1.0 - t;
98    CheekSagState {
99        left: a.left * inv + b.left * t,
100        right: a.right * inv + b.right * t,
101    }
102}
103
104/// JSON string.
105#[allow(dead_code)]
106pub fn csag_to_json(state: &CheekSagState) -> String {
107    format!(
108        "{{\"left\":{:.4},\"right\":{:.4}}}",
109        state.left, state.right
110    )
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn default_is_neutral() {
119        assert!(csag_is_neutral(&new_cheek_sag_state()));
120    }
121
122    #[test]
123    fn set_clamps_above_one() {
124        let mut s = new_cheek_sag_state();
125        csag_set(&mut s, SagSide::Left, 5.0);
126        assert!((s.left - 1.0).abs() < 1e-6);
127    }
128
129    #[test]
130    fn set_clamps_below_zero() {
131        let mut s = new_cheek_sag_state();
132        csag_set(&mut s, SagSide::Right, -1.0);
133        assert!(s.right < 1e-6);
134    }
135
136    #[test]
137    fn reset_clears() {
138        let mut s = new_cheek_sag_state();
139        csag_set_both(&mut s, 0.8);
140        csag_reset(&mut s);
141        assert!(csag_is_neutral(&s));
142    }
143
144    #[test]
145    fn asymmetry_when_unequal() {
146        let mut s = new_cheek_sag_state();
147        csag_set(&mut s, SagSide::Left, 1.0);
148        csag_set(&mut s, SagSide::Right, 0.0);
149        assert!(csag_asymmetry(&s) > 0.9);
150    }
151
152    #[test]
153    fn average_midpoint() {
154        let mut s = new_cheek_sag_state();
155        csag_set_both(&mut s, 0.4);
156        assert!((csag_average(&s) - 0.4).abs() < 1e-6);
157    }
158
159    #[test]
160    fn weights_proportional() {
161        let cfg = default_cheek_sag_config();
162        let mut s = new_cheek_sag_state();
163        csag_set_both(&mut s, 1.0);
164        let w = csag_to_weights(&s, &cfg);
165        assert!((w[0] - cfg.max_sag_m).abs() < 1e-6);
166    }
167
168    #[test]
169    fn blend_midpoint() {
170        let mut a = new_cheek_sag_state();
171        let mut b = new_cheek_sag_state();
172        csag_set_both(&mut a, 0.0);
173        csag_set_both(&mut b, 1.0);
174        let r = csag_blend(&a, &b, 0.5);
175        assert!((r.left - 0.5).abs() < 1e-5);
176    }
177
178    #[test]
179    fn json_has_keys() {
180        let j = csag_to_json(&new_cheek_sag_state());
181        assert!(j.contains("left") && j.contains("right"));
182    }
183
184    #[test]
185    fn set_both_equal() {
186        let mut s = new_cheek_sag_state();
187        csag_set_both(&mut s, 0.6);
188        assert!((s.left - s.right).abs() < 1e-6);
189    }
190}