Skip to main content

oxihuman_morph/
eye_tilt_control.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3#![allow(dead_code)]
4
5//! Eye-tilt (canthal tilt / palpebral axis angle) control.
6
7use std::f32::consts::FRAC_PI_6;
8
9/// Side.
10#[allow(dead_code)]
11#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12pub enum EyeTiltSide {
13    Left,
14    Right,
15    Both,
16}
17
18/// State.
19#[allow(dead_code)]
20#[derive(Clone, Debug)]
21pub struct EyeTiltState {
22    /// Tilt angle in degrees.  Positive = lateral corner up (positive canthal tilt).
23    pub tilt_left_deg: f32,
24    pub tilt_right_deg: f32,
25}
26
27/// Config.
28#[allow(dead_code)]
29#[derive(Clone, Debug)]
30pub struct EyeTiltConfig {
31    pub max_tilt_deg: f32,
32}
33
34impl Default for EyeTiltConfig {
35    fn default() -> Self {
36        Self { max_tilt_deg: 20.0 }
37    }
38}
39impl Default for EyeTiltState {
40    fn default() -> Self {
41        Self {
42            tilt_left_deg: 0.0,
43            tilt_right_deg: 0.0,
44        }
45    }
46}
47
48#[allow(dead_code)]
49pub fn new_eye_tilt_state() -> EyeTiltState {
50    EyeTiltState::default()
51}
52
53#[allow(dead_code)]
54pub fn default_eye_tilt_config() -> EyeTiltConfig {
55    EyeTiltConfig::default()
56}
57
58#[allow(dead_code)]
59pub fn et_set_tilt(state: &mut EyeTiltState, cfg: &EyeTiltConfig, side: EyeTiltSide, deg: f32) {
60    let d = deg.clamp(-cfg.max_tilt_deg, cfg.max_tilt_deg);
61    match side {
62        EyeTiltSide::Left => state.tilt_left_deg = d,
63        EyeTiltSide::Right => state.tilt_right_deg = d,
64        EyeTiltSide::Both => {
65            state.tilt_left_deg = d;
66            state.tilt_right_deg = d;
67        }
68    }
69}
70
71#[allow(dead_code)]
72pub fn et_reset(state: &mut EyeTiltState) {
73    *state = EyeTiltState::default();
74}
75
76#[allow(dead_code)]
77pub fn et_is_neutral(state: &EyeTiltState) -> bool {
78    state.tilt_left_deg.abs() < 1e-4 && state.tilt_right_deg.abs() < 1e-4
79}
80
81#[allow(dead_code)]
82pub fn et_blend(a: &EyeTiltState, b: &EyeTiltState, t: f32) -> EyeTiltState {
83    let t = t.clamp(0.0, 1.0);
84    EyeTiltState {
85        tilt_left_deg: a.tilt_left_deg + (b.tilt_left_deg - a.tilt_left_deg) * t,
86        tilt_right_deg: a.tilt_right_deg + (b.tilt_right_deg - a.tilt_right_deg) * t,
87    }
88}
89
90#[allow(dead_code)]
91pub fn et_tilt_rad_left(state: &EyeTiltState) -> f32 {
92    state.tilt_left_deg.to_radians()
93}
94
95#[allow(dead_code)]
96pub fn et_tilt_rad_right(state: &EyeTiltState) -> f32 {
97    state.tilt_right_deg.to_radians()
98}
99
100#[allow(dead_code)]
101pub fn et_asymmetry(state: &EyeTiltState) -> f32 {
102    (state.tilt_left_deg - state.tilt_right_deg).abs()
103}
104
105/// Reference angle constant used internally (30° = PI/6).
106#[allow(dead_code)]
107pub fn et_reference_angle_rad() -> f32 {
108    FRAC_PI_6
109}
110
111#[allow(dead_code)]
112pub fn et_to_weights(state: &EyeTiltState, max_deg: f32) -> [f32; 2] {
113    if max_deg < 1e-6 {
114        return [0.0, 0.0];
115    }
116    [
117        state.tilt_left_deg / max_deg,
118        state.tilt_right_deg / max_deg,
119    ]
120}
121
122#[allow(dead_code)]
123pub fn et_to_json(state: &EyeTiltState) -> String {
124    format!(
125        "{{\"tilt_left_deg\":{:.4},\"tilt_right_deg\":{:.4}}}",
126        state.tilt_left_deg, state.tilt_right_deg
127    )
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[test]
135    fn default_neutral() {
136        assert!(et_is_neutral(&new_eye_tilt_state()));
137    }
138
139    #[test]
140    fn set_tilt_clamp_positive() {
141        let mut s = new_eye_tilt_state();
142        let cfg = default_eye_tilt_config();
143        et_set_tilt(&mut s, &cfg, EyeTiltSide::Left, 999.0);
144        assert!(s.tilt_left_deg <= cfg.max_tilt_deg);
145    }
146
147    #[test]
148    fn set_tilt_clamp_negative() {
149        let mut s = new_eye_tilt_state();
150        let cfg = default_eye_tilt_config();
151        et_set_tilt(&mut s, &cfg, EyeTiltSide::Right, -999.0);
152        assert!(s.tilt_right_deg >= -cfg.max_tilt_deg);
153    }
154
155    #[test]
156    fn both_sides() {
157        let mut s = new_eye_tilt_state();
158        let cfg = default_eye_tilt_config();
159        et_set_tilt(&mut s, &cfg, EyeTiltSide::Both, 10.0);
160        assert!((s.tilt_left_deg - s.tilt_right_deg).abs() < 1e-5);
161    }
162
163    #[test]
164    fn reset_clears() {
165        let mut s = new_eye_tilt_state();
166        let cfg = default_eye_tilt_config();
167        et_set_tilt(&mut s, &cfg, EyeTiltSide::Both, 5.0);
168        et_reset(&mut s);
169        assert!(et_is_neutral(&s));
170    }
171
172    #[test]
173    fn blend_midpoint() {
174        let cfg = default_eye_tilt_config();
175        let mut a = new_eye_tilt_state();
176        let mut b = new_eye_tilt_state();
177        et_set_tilt(&mut a, &cfg, EyeTiltSide::Left, 0.0);
178        et_set_tilt(&mut b, &cfg, EyeTiltSide::Left, 10.0);
179        let m = et_blend(&a, &b, 0.5);
180        assert!((m.tilt_left_deg - 5.0).abs() < 1e-4);
181    }
182
183    #[test]
184    fn rad_conversion() {
185        let mut s = new_eye_tilt_state();
186        let cfg = default_eye_tilt_config();
187        et_set_tilt(&mut s, &cfg, EyeTiltSide::Left, 10.0);
188        let rad = et_tilt_rad_left(&s);
189        assert!((rad - 10f32.to_radians()).abs() < 1e-5);
190    }
191
192    #[test]
193    fn asymmetry_zero_symmetric() {
194        assert!((et_asymmetry(&new_eye_tilt_state())).abs() < 1e-5);
195    }
196
197    #[test]
198    fn weights_len() {
199        assert_eq!(et_to_weights(&new_eye_tilt_state(), 20.0).len(), 2);
200    }
201
202    #[test]
203    fn json_not_empty() {
204        assert!(!et_to_json(&new_eye_tilt_state()).is_empty());
205    }
206}