Skip to main content

oxihuman_morph/
hand_metacarpal_control.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3#![allow(dead_code)]
4
5//! Hand metacarpal bone length/width control.
6
7use std::f32::consts::FRAC_PI_4;
8
9/// Number of metacarpal bones per hand.
10pub const MC_COUNT: usize = 5;
11
12#[allow(dead_code)]
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum HandSide {
15    Left,
16    Right,
17}
18
19#[allow(dead_code)]
20#[derive(Debug, Clone)]
21pub struct HandMetacarpalConfig {
22    pub max_length: f32,
23}
24
25impl Default for HandMetacarpalConfig {
26    fn default() -> Self {
27        Self { max_length: 1.0 }
28    }
29}
30
31#[allow(dead_code)]
32#[derive(Debug, Clone)]
33pub struct HandMetacarpalState {
34    pub left: [f32; MC_COUNT],
35    pub right: [f32; MC_COUNT],
36    pub config: HandMetacarpalConfig,
37}
38
39#[allow(dead_code)]
40pub fn default_hand_metacarpal_config() -> HandMetacarpalConfig {
41    HandMetacarpalConfig::default()
42}
43
44#[allow(dead_code)]
45pub fn new_hand_metacarpal_state(config: HandMetacarpalConfig) -> HandMetacarpalState {
46    HandMetacarpalState {
47        left: [0.0; MC_COUNT],
48        right: [0.0; MC_COUNT],
49        config,
50    }
51}
52
53#[allow(dead_code)]
54pub fn hmc_set(state: &mut HandMetacarpalState, side: HandSide, bone: usize, v: f32) {
55    if bone < MC_COUNT {
56        let v = v.clamp(0.0, state.config.max_length);
57        match side {
58            HandSide::Left => state.left[bone] = v,
59            HandSide::Right => state.right[bone] = v,
60        }
61    }
62}
63
64#[allow(dead_code)]
65pub fn hmc_set_all(state: &mut HandMetacarpalState, v: f32) {
66    let v = v.clamp(0.0, state.config.max_length);
67    #[allow(clippy::needless_range_loop)]
68    for i in 0..MC_COUNT {
69        state.left[i] = v;
70        state.right[i] = v;
71    }
72}
73
74#[allow(dead_code)]
75pub fn hmc_reset(state: &mut HandMetacarpalState) {
76    state.left = [0.0; MC_COUNT];
77    state.right = [0.0; MC_COUNT];
78}
79
80#[allow(dead_code)]
81pub fn hmc_is_neutral(state: &HandMetacarpalState) -> bool {
82    state.left.iter().all(|v| v.abs() < 1e-6) && state.right.iter().all(|v| v.abs() < 1e-6)
83}
84
85#[allow(dead_code)]
86pub fn hmc_average(state: &HandMetacarpalState, side: HandSide) -> f32 {
87    let arr = match side {
88        HandSide::Left => &state.left,
89        HandSide::Right => &state.right,
90    };
91    arr.iter().sum::<f32>() / MC_COUNT as f32
92}
93
94#[allow(dead_code)]
95pub fn hmc_span_angle_rad(state: &HandMetacarpalState) -> f32 {
96    let avg = (hmc_average(state, HandSide::Left) + hmc_average(state, HandSide::Right)) * 0.5;
97    avg * FRAC_PI_4
98}
99
100#[allow(dead_code)]
101pub fn hmc_to_weights(state: &HandMetacarpalState) -> [f32; MC_COUNT] {
102    let m = state.config.max_length;
103    let mut w = [0.0f32; MC_COUNT];
104    #[allow(clippy::needless_range_loop)]
105    for i in 0..MC_COUNT {
106        w[i] = if m > 1e-9 {
107            (state.left[i] + state.right[i]) * 0.5 / m
108        } else {
109            0.0
110        };
111    }
112    w
113}
114
115#[allow(dead_code)]
116pub fn hmc_to_json(state: &HandMetacarpalState) -> String {
117    format!(
118        "{{\"left_avg\":{:.4},\"right_avg\":{:.4}}}",
119        hmc_average(state, HandSide::Left),
120        hmc_average(state, HandSide::Right)
121    )
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    #[test]
128    fn default_neutral() {
129        assert!(hmc_is_neutral(&new_hand_metacarpal_state(
130            default_hand_metacarpal_config()
131        )));
132    }
133    #[test]
134    fn set_clamps() {
135        let mut s = new_hand_metacarpal_state(default_hand_metacarpal_config());
136        hmc_set(&mut s, HandSide::Left, 0, 5.0);
137        assert!((0.0..=1.0).contains(&s.left[0]));
138    }
139    #[test]
140    fn set_all_applies() {
141        let mut s = new_hand_metacarpal_state(default_hand_metacarpal_config());
142        hmc_set_all(&mut s, 0.5);
143        assert!((s.left[0] - 0.5).abs() < 1e-5);
144    }
145    #[test]
146    fn reset_zeroes() {
147        let mut s = new_hand_metacarpal_state(default_hand_metacarpal_config());
148        hmc_set_all(&mut s, 0.7);
149        hmc_reset(&mut s);
150        assert!(hmc_is_neutral(&s));
151    }
152    #[test]
153    fn average_is_zero_by_default() {
154        let s = new_hand_metacarpal_state(default_hand_metacarpal_config());
155        assert!(hmc_average(&s, HandSide::Left).abs() < 1e-6);
156    }
157    #[test]
158    fn span_angle_nonneg() {
159        let s = new_hand_metacarpal_state(default_hand_metacarpal_config());
160        assert!(hmc_span_angle_rad(&s) >= 0.0);
161    }
162    #[test]
163    fn to_weights_max() {
164        let mut s = new_hand_metacarpal_state(default_hand_metacarpal_config());
165        hmc_set_all(&mut s, 1.0);
166        let w = hmc_to_weights(&s);
167        assert!((w[0] - 1.0).abs() < 1e-5);
168    }
169    #[test]
170    fn out_of_range_ignored() {
171        let mut s = new_hand_metacarpal_state(default_hand_metacarpal_config());
172        hmc_set(&mut s, HandSide::Left, 99, 1.0);
173        assert!(hmc_is_neutral(&s));
174    }
175    #[test]
176    fn to_json_has_left_avg() {
177        assert!(
178            hmc_to_json(&new_hand_metacarpal_state(default_hand_metacarpal_config()))
179                .contains("left_avg")
180        );
181    }
182    #[test]
183    fn mc_count_correct() {
184        assert_eq!(MC_COUNT, 5);
185    }
186}