Skip to main content

oxihuman_morph/
body_center_control.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan) / SPDX-License-Identifier: Apache-2.0
2#![allow(dead_code)]
3
4//! Body centre-of-mass shift control — anterior/posterior lean and lateral sway.
5
6use std::f32::consts::FRAC_PI_4;
7
8/// Configuration for the body centre control.
9#[allow(dead_code)]
10#[derive(Debug, Clone, PartialEq)]
11pub struct BodyCenterConfig {
12    /// Max anterior shift in metres.
13    pub max_anterior: f32,
14    /// Max posterior shift in metres.
15    pub max_posterior: f32,
16    /// Max lateral sway in metres.
17    pub max_lateral: f32,
18}
19
20impl Default for BodyCenterConfig {
21    fn default() -> Self {
22        Self {
23            max_anterior: 0.08,
24            max_posterior: 0.06,
25            max_lateral: 0.05,
26        }
27    }
28}
29
30/// Runtime state for the body centre morph.
31#[allow(dead_code)]
32#[derive(Debug, Clone, Default)]
33pub struct BodyCenterState {
34    /// Anterior (+) / posterior (−) shift, −1..=1.
35    pub ap_shift: f32,
36    /// Lateral shift left (−) / right (+), −1..=1.
37    pub lateral_shift: f32,
38}
39
40/// Create a new body centre state.
41#[allow(dead_code)]
42pub fn new_body_center_state() -> BodyCenterState {
43    BodyCenterState::default()
44}
45
46/// Default config.
47#[allow(dead_code)]
48pub fn default_body_center_config() -> BodyCenterConfig {
49    BodyCenterConfig::default()
50}
51
52/// Set anterior/posterior shift (clamped to −1..=1).
53#[allow(dead_code)]
54pub fn bcc_set_ap(state: &mut BodyCenterState, v: f32) {
55    state.ap_shift = v.clamp(-1.0, 1.0);
56}
57
58/// Set lateral shift (clamped to −1..=1).
59#[allow(dead_code)]
60pub fn bcc_set_lateral(state: &mut BodyCenterState, v: f32) {
61    state.lateral_shift = v.clamp(-1.0, 1.0);
62}
63
64/// Reset to neutral.
65#[allow(dead_code)]
66pub fn bcc_reset(state: &mut BodyCenterState) {
67    *state = BodyCenterState::default();
68}
69
70/// Whether the state is effectively neutral.
71#[allow(dead_code)]
72pub fn bcc_is_neutral(state: &BodyCenterState) -> bool {
73    state.ap_shift.abs() < 1e-4 && state.lateral_shift.abs() < 1e-4
74}
75
76/// Total displacement magnitude (0..=1 normalised).
77#[allow(dead_code)]
78pub fn bcc_displacement(state: &BodyCenterState) -> f32 {
79    (state.ap_shift.powi(2) + state.lateral_shift.powi(2))
80        .sqrt()
81        .min(1.0)
82}
83
84/// Convert to morph weight map (two weights: anterior-posterior, lateral).
85#[allow(dead_code)]
86pub fn bcc_to_weights(state: &BodyCenterState, cfg: &BodyCenterConfig) -> [f32; 2] {
87    let ap = if state.ap_shift >= 0.0 {
88        state.ap_shift * cfg.max_anterior
89    } else {
90        state.ap_shift * cfg.max_posterior
91    };
92    let lat = state.lateral_shift * cfg.max_lateral;
93    [ap, lat]
94}
95
96/// Lean angle in radians (sagittal plane).
97#[allow(dead_code)]
98pub fn bcc_lean_angle_rad(state: &BodyCenterState) -> f32 {
99    state.ap_shift * FRAC_PI_4 * 0.25
100}
101
102/// Blend two states.
103#[allow(dead_code)]
104pub fn bcc_blend(a: &BodyCenterState, b: &BodyCenterState, t: f32) -> BodyCenterState {
105    let t = t.clamp(0.0, 1.0);
106    let inv = 1.0 - t;
107    BodyCenterState {
108        ap_shift: a.ap_shift * inv + b.ap_shift * t,
109        lateral_shift: a.lateral_shift * inv + b.lateral_shift * t,
110    }
111}
112
113/// Serialise to a JSON-like string.
114#[allow(dead_code)]
115pub fn bcc_to_json(state: &BodyCenterState) -> String {
116    format!(
117        "{{\"ap_shift\":{:.4},\"lateral_shift\":{:.4}}}",
118        state.ap_shift, state.lateral_shift
119    )
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn default_is_neutral() {
128        let s = new_body_center_state();
129        assert!(bcc_is_neutral(&s));
130    }
131
132    #[test]
133    fn set_ap_clamps() {
134        let mut s = new_body_center_state();
135        bcc_set_ap(&mut s, 5.0);
136        assert!((s.ap_shift - 1.0).abs() < 1e-6);
137        bcc_set_ap(&mut s, -5.0);
138        assert!((s.ap_shift + 1.0).abs() < 1e-6);
139    }
140
141    #[test]
142    fn set_lateral_clamps() {
143        let mut s = new_body_center_state();
144        bcc_set_lateral(&mut s, -3.0);
145        assert!((s.lateral_shift + 1.0).abs() < 1e-6);
146    }
147
148    #[test]
149    fn reset_clears() {
150        let mut s = new_body_center_state();
151        bcc_set_ap(&mut s, 0.5);
152        bcc_set_lateral(&mut s, 0.3);
153        bcc_reset(&mut s);
154        assert!(bcc_is_neutral(&s));
155    }
156
157    #[test]
158    fn displacement_zero_at_neutral() {
159        let s = new_body_center_state();
160        assert!(bcc_displacement(&s) < 1e-6);
161    }
162
163    #[test]
164    fn displacement_positive_when_shifted() {
165        let mut s = new_body_center_state();
166        bcc_set_ap(&mut s, 0.6);
167        bcc_set_lateral(&mut s, 0.4);
168        assert!(bcc_displacement(&s) > 0.5);
169    }
170
171    #[test]
172    fn weights_sign_matches_direction() {
173        let cfg = default_body_center_config();
174        let mut s = new_body_center_state();
175        bcc_set_ap(&mut s, 1.0);
176        let w = bcc_to_weights(&s, &cfg);
177        assert!(w[0] > 0.0);
178    }
179
180    #[test]
181    fn lean_angle_sign() {
182        let mut s = new_body_center_state();
183        bcc_set_ap(&mut s, 1.0);
184        assert!(bcc_lean_angle_rad(&s) > 0.0);
185        bcc_set_ap(&mut s, -1.0);
186        assert!(bcc_lean_angle_rad(&s) < 0.0);
187    }
188
189    #[test]
190    fn blend_midpoint() {
191        let mut a = new_body_center_state();
192        let mut b = new_body_center_state();
193        bcc_set_ap(&mut a, 0.0);
194        bcc_set_ap(&mut b, 1.0);
195        let r = bcc_blend(&a, &b, 0.5);
196        assert!((r.ap_shift - 0.5).abs() < 1e-5);
197    }
198
199    #[test]
200    fn json_contains_ap_key() {
201        let s = new_body_center_state();
202        let j = bcc_to_json(&s);
203        assert!(j.contains("ap_shift"));
204    }
205}