Skip to main content

oxihuman_morph/
eye_squint_control.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3#![allow(dead_code)]
4
5//! Eye squint control — orbital compression / narrowing of the eye aperture.
6
7use std::f32::consts::FRAC_PI_8;
8
9/// Configuration.
10#[allow(dead_code)]
11#[derive(Debug, Clone)]
12pub struct EyeSquintConfig {
13    /// Reference angle for the squint shape.
14    pub ref_angle_rad: f32,
15    /// Whether to apply asymmetric lower-lid weighting.
16    pub lower_lid_bias: bool,
17}
18
19impl Default for EyeSquintConfig {
20    fn default() -> Self {
21        EyeSquintConfig {
22            ref_angle_rad: FRAC_PI_8,
23            lower_lid_bias: false,
24        }
25    }
26}
27
28/// Runtime state.
29#[allow(dead_code)]
30#[derive(Debug, Clone)]
31pub struct EyeSquintState {
32    left: f32,
33    right: f32,
34    /// Inner-corner contribution in `[0.0, 1.0]`.
35    inner: f32,
36    config: EyeSquintConfig,
37}
38
39/// Default config.
40pub fn default_eye_squint_config() -> EyeSquintConfig {
41    EyeSquintConfig::default()
42}
43
44/// New neutral state.
45pub fn new_eye_squint_state(config: EyeSquintConfig) -> EyeSquintState {
46    EyeSquintState {
47        left: 0.0,
48        right: 0.0,
49        inner: 0.0,
50        config,
51    }
52}
53
54/// Set left squint.
55pub fn esq_set_left(state: &mut EyeSquintState, v: f32) {
56    state.left = v.clamp(0.0, 1.0);
57}
58
59/// Set right squint.
60pub fn esq_set_right(state: &mut EyeSquintState, v: f32) {
61    state.right = v.clamp(0.0, 1.0);
62}
63
64/// Set both sides.
65pub fn esq_set_both(state: &mut EyeSquintState, v: f32) {
66    let v = v.clamp(0.0, 1.0);
67    state.left = v;
68    state.right = v;
69}
70
71/// Set inner-corner contribution.
72pub fn esq_set_inner(state: &mut EyeSquintState, v: f32) {
73    state.inner = v.clamp(0.0, 1.0);
74}
75
76/// Reset.
77pub fn esq_reset(state: &mut EyeSquintState) {
78    state.left = 0.0;
79    state.right = 0.0;
80    state.inner = 0.0;
81}
82
83/// True when neutral.
84pub fn esq_is_neutral(state: &EyeSquintState) -> bool {
85    state.left < 1e-5 && state.right < 1e-5
86}
87
88/// Asymmetry between sides.
89pub fn esq_asymmetry(state: &EyeSquintState) -> f32 {
90    (state.left - state.right).abs()
91}
92
93/// Average squint across both eyes.
94pub fn esq_average(state: &EyeSquintState) -> f32 {
95    (state.left + state.right) * 0.5
96}
97
98/// Compute the orbital compression angle in radians (approximation).
99pub fn esq_compression_angle(state: &EyeSquintState) -> f32 {
100    esq_average(state) * state.config.ref_angle_rad
101}
102
103/// Morph weights: `[left, right, inner, avg]`.
104pub fn esq_to_weights(state: &EyeSquintState) -> [f32; 4] {
105    [state.left, state.right, state.inner, esq_average(state)]
106}
107
108/// Blend.
109pub fn esq_blend(a: &EyeSquintState, b: &EyeSquintState, t: f32) -> EyeSquintState {
110    let t = t.clamp(0.0, 1.0);
111    EyeSquintState {
112        left: a.left + (b.left - a.left) * t,
113        right: a.right + (b.right - a.right) * t,
114        inner: a.inner + (b.inner - a.inner) * t,
115        config: a.config.clone(),
116    }
117}
118
119/// Serialise.
120pub fn esq_to_json(state: &EyeSquintState) -> String {
121    format!(
122        r#"{{"left":{:.4},"right":{:.4},"inner":{:.4}}}"#,
123        state.left, state.right, state.inner
124    )
125}
126
127// ---------------------------------------------------------------------------
128// Tests
129// ---------------------------------------------------------------------------
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    fn make() -> EyeSquintState {
135        new_eye_squint_state(default_eye_squint_config())
136    }
137
138    #[test]
139    fn neutral_on_creation() {
140        assert!(esq_is_neutral(&make()));
141    }
142
143    #[test]
144    fn set_left() {
145        let mut s = make();
146        esq_set_left(&mut s, 0.5);
147        assert!((s.left - 0.5).abs() < 1e-5);
148    }
149
150    #[test]
151    fn set_both_syncs() {
152        let mut s = make();
153        esq_set_both(&mut s, 0.7);
154        assert!((s.left - s.right).abs() < 1e-5);
155    }
156
157    #[test]
158    fn reset_clears() {
159        let mut s = make();
160        esq_set_both(&mut s, 1.0);
161        esq_reset(&mut s);
162        assert!(esq_is_neutral(&s));
163    }
164
165    #[test]
166    fn asymmetry_zero_equal() {
167        let mut s = make();
168        esq_set_both(&mut s, 0.5);
169        assert!(esq_asymmetry(&s) < 1e-5);
170    }
171
172    #[test]
173    fn compression_angle_positive() {
174        let mut s = make();
175        esq_set_both(&mut s, 0.5);
176        assert!(esq_compression_angle(&s) > 0.0);
177    }
178
179    #[test]
180    fn blend_midpoint() {
181        let mut b = make();
182        esq_set_both(&mut b, 1.0);
183        let m = esq_blend(&make(), &b, 0.5);
184        assert!((m.left - 0.5).abs() < 1e-5);
185    }
186
187    #[test]
188    fn weights_in_range() {
189        let mut s = make();
190        esq_set_both(&mut s, 0.8);
191        for v in esq_to_weights(&s) {
192            assert!((0.0..=1.0).contains(&v));
193        }
194    }
195
196    #[test]
197    fn json_has_left() {
198        assert!(esq_to_json(&make()).contains("left"));
199    }
200}