oxihuman_morph/
eye_tilt_control.rs1#![allow(dead_code)]
4
5use std::f32::consts::FRAC_PI_6;
8
9#[allow(dead_code)]
11#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12pub enum EyeTiltSide {
13 Left,
14 Right,
15 Both,
16}
17
18#[allow(dead_code)]
20#[derive(Clone, Debug)]
21pub struct EyeTiltState {
22 pub tilt_left_deg: f32,
24 pub tilt_right_deg: f32,
25}
26
27#[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#[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}