Skip to main content

oxihuman_morph/
jaw_rest_control.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3#![allow(dead_code)]
4
5//! Jaw rest control — rest-position gap and muscle tone of the jaw.
6
7use std::f32::consts::PI;
8
9/// Configuration.
10#[allow(dead_code)]
11#[derive(Debug, Clone)]
12pub struct JawRestConfig {
13    /// Maximum rest-gap angle in radians (informational).
14    pub max_gap_rad: f32,
15    /// Whether natural micro-relaxation is enabled.
16    pub micro_relax: bool,
17}
18
19impl Default for JawRestConfig {
20    fn default() -> Self {
21        JawRestConfig {
22            max_gap_rad: PI / 12.0,
23            micro_relax: true,
24        }
25    }
26}
27
28/// Runtime state.
29#[allow(dead_code)]
30#[derive(Debug, Clone)]
31pub struct JawRestState {
32    /// Rest-gap in `[0.0, 1.0]` (0 = fully closed, 1 = max rest opening).
33    gap: f32,
34    /// Lateral shift in `[-1.0, 1.0]`.
35    lateral: f32,
36    /// Muscle relaxation in `[0.0, 1.0]`.
37    relaxation: f32,
38    config: JawRestConfig,
39}
40
41/// Default config.
42pub fn default_jaw_rest_config() -> JawRestConfig {
43    JawRestConfig::default()
44}
45
46/// New neutral state (gap = 0.1, fully relaxed).
47pub fn new_jaw_rest_state(config: JawRestConfig) -> JawRestState {
48    JawRestState {
49        gap: 0.1,
50        lateral: 0.0,
51        relaxation: 0.5,
52        config,
53    }
54}
55
56/// Set rest gap.
57pub fn jr_set_gap(state: &mut JawRestState, v: f32) {
58    state.gap = v.clamp(0.0, 1.0);
59}
60
61/// Set lateral shift.
62pub fn jr_set_lateral(state: &mut JawRestState, v: f32) {
63    state.lateral = v.clamp(-1.0, 1.0);
64}
65
66/// Set muscle relaxation.
67pub fn jr_set_relaxation(state: &mut JawRestState, v: f32) {
68    state.relaxation = v.clamp(0.0, 1.0);
69}
70
71/// Reset.
72pub fn jr_reset(state: &mut JawRestState) {
73    state.gap = 0.1;
74    state.lateral = 0.0;
75    state.relaxation = 0.5;
76}
77
78/// True when neutral (gap = 0.1, lateral = 0.0, relaxation = 0.5).
79pub fn jr_is_neutral(state: &JawRestState) -> bool {
80    (state.gap - 0.1).abs() < 1e-5
81        && state.lateral.abs() < 1e-5
82        && (state.relaxation - 0.5).abs() < 1e-5
83}
84
85/// Compute the rest-gap angle in radians.
86pub fn jr_gap_rad(state: &JawRestState) -> f32 {
87    state.gap * state.config.max_gap_rad
88}
89
90/// Morph weights: `[gap, lateral_norm, relaxation]`.
91pub fn jr_to_weights(state: &JawRestState) -> [f32; 3] {
92    [
93        state.gap,
94        (state.lateral * 0.5 + 0.5).clamp(0.0, 1.0),
95        state.relaxation,
96    ]
97}
98
99/// Blend.
100pub fn jr_blend(a: &JawRestState, b: &JawRestState, t: f32) -> JawRestState {
101    let t = t.clamp(0.0, 1.0);
102    JawRestState {
103        gap: a.gap + (b.gap - a.gap) * t,
104        lateral: a.lateral + (b.lateral - a.lateral) * t,
105        relaxation: a.relaxation + (b.relaxation - a.relaxation) * t,
106        config: a.config.clone(),
107    }
108}
109
110/// Serialise.
111pub fn jr_to_json(state: &JawRestState) -> String {
112    format!(
113        r#"{{"gap":{:.4},"lateral":{:.4},"relaxation":{:.4}}}"#,
114        state.gap, state.lateral, state.relaxation
115    )
116}
117
118// ---------------------------------------------------------------------------
119// Tests
120// ---------------------------------------------------------------------------
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    fn make() -> JawRestState {
126        new_jaw_rest_state(default_jaw_rest_config())
127    }
128
129    #[test]
130    fn neutral_on_creation() {
131        assert!(jr_is_neutral(&make()));
132    }
133
134    #[test]
135    fn gap_clamped_high() {
136        let mut s = make();
137        jr_set_gap(&mut s, 5.0);
138        assert!((s.gap - 1.0).abs() < 1e-5);
139    }
140
141    #[test]
142    fn lateral_clamped_negative() {
143        let mut s = make();
144        jr_set_lateral(&mut s, -5.0);
145        assert!((s.lateral + 1.0).abs() < 1e-5);
146    }
147
148    #[test]
149    fn reset_restores_neutral() {
150        let mut s = make();
151        jr_set_gap(&mut s, 0.9);
152        jr_reset(&mut s);
153        assert!(jr_is_neutral(&s));
154    }
155
156    #[test]
157    fn gap_rad_positive() {
158        let mut s = make();
159        jr_set_gap(&mut s, 0.5);
160        assert!(jr_gap_rad(&s) > 0.0);
161    }
162
163    #[test]
164    fn weights_in_range() {
165        let s = make();
166        for v in jr_to_weights(&s) {
167            assert!((0.0..=1.0).contains(&v));
168        }
169    }
170
171    #[test]
172    fn blend_midpoint() {
173        let mut b = make();
174        jr_set_gap(&mut b, 1.0);
175        let m = jr_blend(&make(), &b, 0.5);
176        // gap starts at 0.1, target 1.0, midpoint is ~0.55
177        assert!(m.gap > 0.1 && m.gap < 1.0);
178    }
179
180    #[test]
181    fn json_has_gap() {
182        assert!(jr_to_json(&make()).contains("gap"));
183    }
184
185    #[test]
186    fn blend_at_zero_is_a() {
187        let a = make();
188        let r = jr_blend(&a, &make(), 0.0);
189        assert!((r.gap - a.gap).abs() < 1e-5);
190    }
191}