Skip to main content

oxihuman_morph/
finger_spread_control.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan) / SPDX-License-Identifier: Apache-2.0
2#![allow(dead_code)]
3
4//! Finger spread (abduction/adduction) morph control.
5//!
6//! Models the lateral spreading of fingers independently for hand posing.
7
8use std::f32::consts::PI;
9
10/// Which finger.
11#[allow(dead_code)]
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum Finger {
14    Thumb,
15    Index,
16    Middle,
17    Ring,
18    Pinky,
19}
20
21/// Per-finger spread parameters.
22#[allow(dead_code)]
23#[derive(Debug, Clone, PartialEq)]
24pub struct FingerSpreadParams {
25    /// Spread angles for each finger in radians (abduction from neutral).
26    pub spreads: [f32; 5],
27    /// Overall spread multiplier.
28    pub global_scale: f32,
29    /// Web skin stretch factor, 0..=1.
30    pub web_stretch: f32,
31}
32
33impl Default for FingerSpreadParams {
34    fn default() -> Self {
35        Self {
36            spreads: [0.0; 5],
37            global_scale: 1.0,
38            web_stretch: 0.3,
39        }
40    }
41}
42
43/// Result of finger spread evaluation.
44#[allow(dead_code)]
45#[derive(Debug, Clone)]
46pub struct FingerSpreadResult {
47    /// Per-finger effective spread angle.
48    pub effective_spreads: [f32; 5],
49    /// Total hand width change estimate.
50    pub width_change: f32,
51    /// Web skin stretch weights per gap (4 gaps: thumb-index through ring-pinky).
52    pub web_weights: [f32; 4],
53}
54
55/// Maximum spread angle per finger (anatomical limits).
56#[allow(dead_code)]
57pub fn max_spread(finger: Finger) -> f32 {
58    match finger {
59        Finger::Thumb => PI / 4.0,
60        Finger::Index => PI / 8.0,
61        Finger::Middle => PI / 12.0,
62        Finger::Ring => PI / 8.0,
63        Finger::Pinky => PI / 6.0,
64    }
65}
66
67/// Clamp spread to anatomical limit.
68#[allow(dead_code)]
69pub fn clamp_spread(angle: f32, finger: Finger) -> f32 {
70    let max = max_spread(finger);
71    angle.clamp(-max * 0.5, max)
72}
73
74/// Compute the effective spread for one finger.
75#[allow(dead_code)]
76pub fn effective_spread(raw_angle: f32, finger: Finger, global_scale: f32) -> f32 {
77    clamp_spread(raw_angle * global_scale, finger)
78}
79
80/// Web skin stretch between two adjacent fingers.
81///
82/// Returns a weight in 0..=1 representing how much the web skin is stretched.
83#[allow(dead_code)]
84pub fn web_stretch_weight(spread_a: f32, spread_b: f32, max_a: f32, max_b: f32) -> f32 {
85    let diff = (spread_a - spread_b).abs();
86    let max_diff = (max_a + max_b) * 0.5;
87    if max_diff < 1e-6 {
88        return 0.0;
89    }
90    (diff / max_diff).clamp(0.0, 1.0)
91}
92
93/// Finger index mapping.
94#[allow(dead_code)]
95pub fn finger_from_index(idx: usize) -> Option<Finger> {
96    match idx {
97        0 => Some(Finger::Thumb),
98        1 => Some(Finger::Index),
99        2 => Some(Finger::Middle),
100        3 => Some(Finger::Ring),
101        4 => Some(Finger::Pinky),
102        _ => None,
103    }
104}
105
106/// Evaluate finger spread morph.
107#[allow(dead_code)]
108pub fn evaluate_finger_spread(params: &FingerSpreadParams) -> FingerSpreadResult {
109    let fingers = [
110        Finger::Thumb,
111        Finger::Index,
112        Finger::Middle,
113        Finger::Ring,
114        Finger::Pinky,
115    ];
116    let mut effective_spreads = [0.0_f32; 5];
117
118    for (i, &finger) in fingers.iter().enumerate() {
119        effective_spreads[i] = effective_spread(params.spreads[i], finger, params.global_scale);
120    }
121
122    let mut web_weights = [0.0_f32; 4];
123    for i in 0..4 {
124        let max_a = max_spread(fingers[i]);
125        let max_b = max_spread(fingers[i + 1]);
126        web_weights[i] =
127            web_stretch_weight(effective_spreads[i], effective_spreads[i + 1], max_a, max_b)
128                * params.web_stretch;
129    }
130
131    let width_change: f32 = effective_spreads.iter().map(|s| s.sin()).sum::<f32>() * 0.01;
132
133    FingerSpreadResult {
134        effective_spreads,
135        width_change,
136        web_weights,
137    }
138}
139
140/// Preset: relaxed spread.
141#[allow(dead_code)]
142pub fn preset_relaxed() -> FingerSpreadParams {
143    FingerSpreadParams {
144        spreads: [0.1, 0.03, 0.0, -0.02, -0.05],
145        global_scale: 1.0,
146        web_stretch: 0.3,
147    }
148}
149
150/// Preset: wide spread.
151#[allow(dead_code)]
152pub fn preset_wide() -> FingerSpreadParams {
153    FingerSpreadParams {
154        spreads: [PI / 5.0, PI / 10.0, PI / 14.0, PI / 10.0, PI / 8.0],
155        global_scale: 1.0,
156        web_stretch: 0.8,
157    }
158}
159
160/// Blend finger spread params.
161#[allow(dead_code)]
162#[allow(clippy::needless_range_loop)]
163pub fn blend_finger_spread(
164    a: &FingerSpreadParams,
165    b: &FingerSpreadParams,
166    t: f32,
167) -> FingerSpreadParams {
168    let t = t.clamp(0.0, 1.0);
169    let inv = 1.0 - t;
170    let mut spreads = [0.0; 5];
171    for i in 0..5 {
172        spreads[i] = a.spreads[i] * inv + b.spreads[i] * t;
173    }
174    FingerSpreadParams {
175        spreads,
176        global_scale: a.global_scale * inv + b.global_scale * t,
177        web_stretch: a.web_stretch * inv + b.web_stretch * t,
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184    use std::f32::consts::PI;
185
186    #[test]
187    fn test_default_params() {
188        let p = FingerSpreadParams::default();
189        assert_eq!(p.spreads, [0.0; 5]);
190    }
191
192    #[test]
193    fn test_max_spread_thumb_largest() {
194        let thumb = max_spread(Finger::Thumb);
195        let middle = max_spread(Finger::Middle);
196        assert!(thumb > middle);
197    }
198
199    #[test]
200    fn test_clamp_spread() {
201        let clamped = clamp_spread(PI, Finger::Index);
202        assert!(clamped <= max_spread(Finger::Index));
203    }
204
205    #[test]
206    fn test_effective_spread_zero_scale() {
207        let e = effective_spread(0.5, Finger::Index, 0.0);
208        assert!(e.abs() < 1e-6);
209    }
210
211    #[test]
212    fn test_web_stretch_weight_same() {
213        let w = web_stretch_weight(0.1, 0.1, 0.5, 0.5);
214        assert!(w.abs() < 1e-6);
215    }
216
217    #[test]
218    fn test_finger_from_index_valid() {
219        assert_eq!(finger_from_index(0), Some(Finger::Thumb));
220        assert_eq!(finger_from_index(4), Some(Finger::Pinky));
221    }
222
223    #[test]
224    fn test_finger_from_index_invalid() {
225        assert_eq!(finger_from_index(5), None);
226    }
227
228    #[test]
229    fn test_evaluate_default() {
230        let r = evaluate_finger_spread(&FingerSpreadParams::default());
231        assert_eq!(r.effective_spreads, [0.0; 5]);
232        assert!(r.width_change.abs() < 1e-6);
233    }
234
235    #[test]
236    fn test_preset_wide_nonzero() {
237        let p = preset_wide();
238        let r = evaluate_finger_spread(&p);
239        assert!(r.width_change > 0.0);
240    }
241
242    #[test]
243    fn test_blend_finger_spread() {
244        let a = FingerSpreadParams::default();
245        let b = preset_wide();
246        let r = blend_finger_spread(&a, &b, 0.5);
247        assert!(r.spreads[0] > 0.0);
248    }
249}