Skip to main content

oxihuman_morph/
jaw_protrusion_control.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3#![allow(dead_code)]
4
5//! Jaw protrusion (prognathism / retrognathism) control.
6
7use std::f32::consts::FRAC_PI_8;
8
9/// State.
10#[allow(dead_code)]
11#[derive(Clone, Debug)]
12pub struct JawProtrusionState {
13    /// Protrusion amount (-1 = receded, 0 = neutral, 1 = protruded).
14    pub protrusion: f32,
15    /// Vertical inclination of mandibular plane, degrees.
16    pub mandibular_plane_deg: f32,
17    /// Asymmetric shift (-1 left, 0 neutral, 1 right).
18    pub lateral_shift: f32,
19}
20
21/// Config.
22#[allow(dead_code)]
23#[derive(Clone, Debug)]
24pub struct JawProtrusionConfig {
25    pub max_protrusion: f32,
26    pub max_plane_deg: f32,
27}
28
29impl Default for JawProtrusionConfig {
30    fn default() -> Self {
31        Self {
32            max_protrusion: 1.0,
33            max_plane_deg: 15.0,
34        }
35    }
36}
37impl Default for JawProtrusionState {
38    fn default() -> Self {
39        Self {
40            protrusion: 0.0,
41            mandibular_plane_deg: 0.0,
42            lateral_shift: 0.0,
43        }
44    }
45}
46
47#[allow(dead_code)]
48pub fn new_jaw_protrusion_state() -> JawProtrusionState {
49    JawProtrusionState::default()
50}
51
52#[allow(dead_code)]
53pub fn default_jaw_protrusion_config() -> JawProtrusionConfig {
54    JawProtrusionConfig::default()
55}
56
57#[allow(dead_code)]
58pub fn jp_set_protrusion(state: &mut JawProtrusionState, cfg: &JawProtrusionConfig, v: f32) {
59    state.protrusion = v.clamp(-cfg.max_protrusion, cfg.max_protrusion);
60}
61
62#[allow(dead_code)]
63pub fn jp_set_plane(state: &mut JawProtrusionState, cfg: &JawProtrusionConfig, deg: f32) {
64    state.mandibular_plane_deg = deg.clamp(-cfg.max_plane_deg, cfg.max_plane_deg);
65}
66
67#[allow(dead_code)]
68pub fn jp_set_lateral(state: &mut JawProtrusionState, v: f32) {
69    state.lateral_shift = v.clamp(-1.0, 1.0);
70}
71
72#[allow(dead_code)]
73pub fn jp_reset(state: &mut JawProtrusionState) {
74    *state = JawProtrusionState::default();
75}
76
77#[allow(dead_code)]
78pub fn jp_is_neutral(state: &JawProtrusionState) -> bool {
79    state.protrusion.abs() < 1e-4
80        && state.mandibular_plane_deg.abs() < 1e-4
81        && state.lateral_shift.abs() < 1e-4
82}
83
84#[allow(dead_code)]
85pub fn jp_blend(a: &JawProtrusionState, b: &JawProtrusionState, t: f32) -> JawProtrusionState {
86    let t = t.clamp(0.0, 1.0);
87    JawProtrusionState {
88        protrusion: a.protrusion + (b.protrusion - a.protrusion) * t,
89        mandibular_plane_deg: a.mandibular_plane_deg
90            + (b.mandibular_plane_deg - a.mandibular_plane_deg) * t,
91        lateral_shift: a.lateral_shift + (b.lateral_shift - a.lateral_shift) * t,
92    }
93}
94
95/// Approximate horizontal jaw offset in normalised units.
96#[allow(dead_code)]
97pub fn jp_horizontal_offset(state: &JawProtrusionState) -> f32 {
98    state.protrusion * (state.mandibular_plane_deg.to_radians() + FRAC_PI_8).cos()
99}
100
101#[allow(dead_code)]
102pub fn jp_to_weights(state: &JawProtrusionState) -> [f32; 3] {
103    [
104        state.protrusion,
105        state.mandibular_plane_deg / 15.0,
106        state.lateral_shift,
107    ]
108}
109
110#[allow(dead_code)]
111pub fn jp_to_json(state: &JawProtrusionState) -> String {
112    format!(
113        "{{\"protrusion\":{:.4},\"plane_deg\":{:.4},\"lateral\":{:.4}}}",
114        state.protrusion, state.mandibular_plane_deg, state.lateral_shift
115    )
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn default_neutral() {
124        assert!(jp_is_neutral(&new_jaw_protrusion_state()));
125    }
126
127    #[test]
128    fn protrusion_clamp_max() {
129        let mut s = new_jaw_protrusion_state();
130        let cfg = default_jaw_protrusion_config();
131        jp_set_protrusion(&mut s, &cfg, 5.0);
132        assert!(s.protrusion <= cfg.max_protrusion);
133    }
134
135    #[test]
136    fn protrusion_clamp_min() {
137        let mut s = new_jaw_protrusion_state();
138        let cfg = default_jaw_protrusion_config();
139        jp_set_protrusion(&mut s, &cfg, -5.0);
140        assert!(s.protrusion >= -cfg.max_protrusion);
141    }
142
143    #[test]
144    fn plane_clamp() {
145        let mut s = new_jaw_protrusion_state();
146        let cfg = default_jaw_protrusion_config();
147        jp_set_plane(&mut s, &cfg, 999.0);
148        assert!(s.mandibular_plane_deg <= cfg.max_plane_deg);
149    }
150
151    #[test]
152    fn lateral_clamp() {
153        let mut s = new_jaw_protrusion_state();
154        jp_set_lateral(&mut s, 5.0);
155        assert!(s.lateral_shift <= 1.0);
156    }
157
158    #[test]
159    fn reset_neutral() {
160        let mut s = new_jaw_protrusion_state();
161        let cfg = default_jaw_protrusion_config();
162        jp_set_protrusion(&mut s, &cfg, 0.5);
163        jp_reset(&mut s);
164        assert!(jp_is_neutral(&s));
165    }
166
167    #[test]
168    fn blend_half() {
169        let cfg = default_jaw_protrusion_config();
170        let mut a = new_jaw_protrusion_state();
171        let mut b = new_jaw_protrusion_state();
172        jp_set_protrusion(&mut a, &cfg, 0.0);
173        jp_set_protrusion(&mut b, &cfg, 1.0);
174        let m = jp_blend(&a, &b, 0.5);
175        assert!((m.protrusion - 0.5).abs() < 1e-4);
176    }
177
178    #[test]
179    fn horizontal_offset_finite() {
180        let s = new_jaw_protrusion_state();
181        assert!(jp_horizontal_offset(&s).is_finite());
182    }
183
184    #[test]
185    fn weights_len() {
186        assert_eq!(jp_to_weights(&new_jaw_protrusion_state()).len(), 3);
187    }
188
189    #[test]
190    fn json_has_protrusion() {
191        assert!(jp_to_json(&new_jaw_protrusion_state()).contains("protrusion"));
192    }
193}