Skip to main content

oxihuman_morph/
eye_droop_control.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan) / SPDX-License-Identifier: Apache-2.0
2#![allow(dead_code)]
3
4//! Eye droop control — ptosis-style downward drift of upper eyelid margin.
5
6use std::f32::consts::FRAC_PI_8;
7
8/// Which eye.
9#[allow(dead_code)]
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum EyeSide {
12    Left,
13    Right,
14}
15
16/// Config.
17#[allow(dead_code)]
18#[derive(Debug, Clone, PartialEq)]
19pub struct EyeDroopConfig {
20    pub max_droop_m: f32,
21}
22
23impl Default for EyeDroopConfig {
24    fn default() -> Self {
25        Self { max_droop_m: 0.006 }
26    }
27}
28
29/// State.
30#[allow(dead_code)]
31#[derive(Debug, Clone, Default)]
32pub struct EyeDroopState {
33    pub left: f32,
34    pub right: f32,
35}
36
37#[allow(dead_code)]
38pub fn new_eye_droop_state() -> EyeDroopState {
39    EyeDroopState::default()
40}
41
42#[allow(dead_code)]
43pub fn default_eye_droop_config() -> EyeDroopConfig {
44    EyeDroopConfig::default()
45}
46
47#[allow(dead_code)]
48pub fn edr_set(state: &mut EyeDroopState, side: EyeSide, v: f32) {
49    let v = v.clamp(0.0, 1.0);
50    match side {
51        EyeSide::Left => state.left = v,
52        EyeSide::Right => state.right = v,
53    }
54}
55
56#[allow(dead_code)]
57pub fn edr_set_both(state: &mut EyeDroopState, v: f32) {
58    let v = v.clamp(0.0, 1.0);
59    state.left = v;
60    state.right = v;
61}
62
63#[allow(dead_code)]
64pub fn edr_reset(state: &mut EyeDroopState) {
65    *state = EyeDroopState::default();
66}
67
68#[allow(dead_code)]
69pub fn edr_is_neutral(state: &EyeDroopState) -> bool {
70    state.left < 1e-4 && state.right < 1e-4
71}
72
73#[allow(dead_code)]
74pub fn edr_asymmetry(state: &EyeDroopState) -> f32 {
75    (state.left - state.right).abs()
76}
77
78/// Lid angle delta in radians.
79#[allow(dead_code)]
80pub fn edr_lid_angle_rad(state: &EyeDroopState, side: EyeSide) -> f32 {
81    let v = match side {
82        EyeSide::Left => state.left,
83        EyeSide::Right => state.right,
84    };
85    v * FRAC_PI_8
86}
87
88#[allow(dead_code)]
89pub fn edr_to_weights(state: &EyeDroopState, cfg: &EyeDroopConfig) -> [f32; 2] {
90    [state.left * cfg.max_droop_m, state.right * cfg.max_droop_m]
91}
92
93#[allow(dead_code)]
94pub fn edr_blend(a: &EyeDroopState, b: &EyeDroopState, t: f32) -> EyeDroopState {
95    let t = t.clamp(0.0, 1.0);
96    let inv = 1.0 - t;
97    EyeDroopState {
98        left: a.left * inv + b.left * t,
99        right: a.right * inv + b.right * t,
100    }
101}
102
103#[allow(dead_code)]
104pub fn edr_to_json(state: &EyeDroopState) -> String {
105    format!(
106        "{{\"left\":{:.4},\"right\":{:.4}}}",
107        state.left, state.right
108    )
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn default_neutral() {
117        assert!(edr_is_neutral(&new_eye_droop_state()));
118    }
119
120    #[test]
121    fn set_clamps_high() {
122        let mut s = new_eye_droop_state();
123        edr_set(&mut s, EyeSide::Left, 10.0);
124        assert!((s.left - 1.0).abs() < 1e-6);
125    }
126
127    #[test]
128    fn set_clamps_low() {
129        let mut s = new_eye_droop_state();
130        edr_set(&mut s, EyeSide::Right, -1.0);
131        assert!(s.right < 1e-6);
132    }
133
134    #[test]
135    fn reset_works() {
136        let mut s = new_eye_droop_state();
137        edr_set_both(&mut s, 0.5);
138        edr_reset(&mut s);
139        assert!(edr_is_neutral(&s));
140    }
141
142    #[test]
143    fn asymmetry_zero_when_equal() {
144        let mut s = new_eye_droop_state();
145        edr_set_both(&mut s, 0.5);
146        assert!(edr_asymmetry(&s) < 1e-6);
147    }
148
149    #[test]
150    fn lid_angle_positive() {
151        let mut s = new_eye_droop_state();
152        edr_set(&mut s, EyeSide::Left, 1.0);
153        assert!(edr_lid_angle_rad(&s, EyeSide::Left) > 0.0);
154    }
155
156    #[test]
157    fn weights_correct() {
158        let cfg = default_eye_droop_config();
159        let mut s = new_eye_droop_state();
160        edr_set_both(&mut s, 1.0);
161        let w = edr_to_weights(&s, &cfg);
162        assert!((w[0] - cfg.max_droop_m).abs() < 1e-6);
163    }
164
165    #[test]
166    fn blend_midpoint() {
167        let mut b = new_eye_droop_state();
168        edr_set_both(&mut b, 1.0);
169        let r = edr_blend(&new_eye_droop_state(), &b, 0.5);
170        assert!((r.left - 0.5).abs() < 1e-5);
171    }
172
173    #[test]
174    fn json_has_left_right() {
175        let j = edr_to_json(&new_eye_droop_state());
176        assert!(j.contains("left") && j.contains("right"));
177    }
178
179    #[test]
180    fn set_both_equal() {
181        let mut s = new_eye_droop_state();
182        edr_set_both(&mut s, 0.7);
183        assert!((s.left - s.right).abs() < 1e-6);
184    }
185}