Skip to main content

oxihuman_morph/
neck_forward_control.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3#![allow(dead_code)]
4
5//! Neck forward control — forward head posture / cervical lordosis.
6
7use std::f32::consts::FRAC_PI_3;
8
9/// Configuration.
10#[allow(dead_code)]
11#[derive(Debug, Clone)]
12pub struct NeckForwardConfig {
13    /// Maximum forward tilt angle in radians.
14    pub max_angle_rad: f32,
15    /// Whether to compensate thoracic kyphosis.
16    pub thoracic_compensate: bool,
17}
18
19impl Default for NeckForwardConfig {
20    fn default() -> Self {
21        NeckForwardConfig {
22            max_angle_rad: FRAC_PI_3,
23            thoracic_compensate: false,
24        }
25    }
26}
27
28/// Runtime state.
29#[allow(dead_code)]
30#[derive(Debug, Clone)]
31pub struct NeckForwardState {
32    /// Forward tilt in `[0.0, 1.0]`.
33    forward: f32,
34    /// Lateral bend in `[-1.0, 1.0]`.
35    lateral: f32,
36    /// Protrusion (anterior shift) in `[0.0, 1.0]`.
37    protrusion: f32,
38    config: NeckForwardConfig,
39}
40
41/// Default config.
42pub fn default_neck_forward_config() -> NeckForwardConfig {
43    NeckForwardConfig::default()
44}
45
46/// New neutral state.
47pub fn new_neck_forward_state(config: NeckForwardConfig) -> NeckForwardState {
48    NeckForwardState {
49        forward: 0.0,
50        lateral: 0.0,
51        protrusion: 0.0,
52        config,
53    }
54}
55
56/// Set forward tilt.
57pub fn nfc_set_forward(state: &mut NeckForwardState, v: f32) {
58    state.forward = v.clamp(0.0, 1.0);
59}
60
61/// Set lateral bend.
62pub fn nfc_set_lateral(state: &mut NeckForwardState, v: f32) {
63    state.lateral = v.clamp(-1.0, 1.0);
64}
65
66/// Set protrusion.
67pub fn nfc_set_protrusion(state: &mut NeckForwardState, v: f32) {
68    state.protrusion = v.clamp(0.0, 1.0);
69}
70
71/// Reset.
72pub fn nfc_reset(state: &mut NeckForwardState) {
73    state.forward = 0.0;
74    state.lateral = 0.0;
75    state.protrusion = 0.0;
76}
77
78/// True when neutral.
79pub fn nfc_is_neutral(state: &NeckForwardState) -> bool {
80    state.forward < 1e-5 && state.lateral.abs() < 1e-5 && state.protrusion < 1e-5
81}
82
83/// Compute forward tilt angle in radians.
84pub fn nfc_angle_rad(state: &NeckForwardState) -> f32 {
85    state.forward * state.config.max_angle_rad
86}
87
88/// Morph weights: `[forward, lateral_norm, protrusion]`.
89pub fn nfc_to_weights(state: &NeckForwardState) -> [f32; 3] {
90    [
91        state.forward,
92        (state.lateral * 0.5 + 0.5).clamp(0.0, 1.0),
93        state.protrusion,
94    ]
95}
96
97/// Blend.
98pub fn nfc_blend(a: &NeckForwardState, b: &NeckForwardState, t: f32) -> NeckForwardState {
99    let t = t.clamp(0.0, 1.0);
100    NeckForwardState {
101        forward: a.forward + (b.forward - a.forward) * t,
102        lateral: a.lateral + (b.lateral - a.lateral) * t,
103        protrusion: a.protrusion + (b.protrusion - a.protrusion) * t,
104        config: a.config.clone(),
105    }
106}
107
108/// Serialise.
109pub fn nfc_to_json(state: &NeckForwardState) -> String {
110    format!(
111        r#"{{"forward":{:.4},"lateral":{:.4},"protrusion":{:.4}}}"#,
112        state.forward, state.lateral, state.protrusion
113    )
114}
115
116// ---------------------------------------------------------------------------
117// Tests
118// ---------------------------------------------------------------------------
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    fn make() -> NeckForwardState {
124        new_neck_forward_state(default_neck_forward_config())
125    }
126
127    #[test]
128    fn neutral_on_creation() {
129        assert!(nfc_is_neutral(&make()));
130    }
131
132    #[test]
133    fn set_forward_clamps() {
134        let mut s = make();
135        nfc_set_forward(&mut s, 3.0);
136        assert!((s.forward - 1.0).abs() < 1e-5);
137    }
138
139    #[test]
140    fn reset_clears() {
141        let mut s = make();
142        nfc_set_forward(&mut s, 0.8);
143        nfc_reset(&mut s);
144        assert!(nfc_is_neutral(&s));
145    }
146
147    #[test]
148    fn angle_positive_when_forward() {
149        let mut s = make();
150        nfc_set_forward(&mut s, 0.5);
151        assert!(nfc_angle_rad(&s) > 0.0);
152    }
153
154    #[test]
155    fn weights_in_range() {
156        let s = make();
157        for v in nfc_to_weights(&s) {
158            assert!((0.0..=1.0).contains(&v));
159        }
160    }
161
162    #[test]
163    fn blend_midpoint() {
164        let mut b = make();
165        nfc_set_forward(&mut b, 1.0);
166        let m = nfc_blend(&make(), &b, 0.5);
167        assert!((m.forward - 0.5).abs() < 1e-5);
168    }
169
170    #[test]
171    fn lateral_clamped_positive() {
172        let mut s = make();
173        nfc_set_lateral(&mut s, 5.0);
174        assert!((s.lateral - 1.0).abs() < 1e-5);
175    }
176
177    #[test]
178    fn json_has_forward() {
179        assert!(nfc_to_json(&make()).contains("forward"));
180    }
181
182    #[test]
183    fn blend_at_zero_is_a() {
184        let a = make();
185        let r = nfc_blend(&a, &make(), 0.0);
186        assert!((r.forward - a.forward).abs() < 1e-5);
187    }
188}