Skip to main content

oxihuman_morph/
eye_control.rs

1//! Eye movement and gaze control system with blink integration.
2
3#[allow(dead_code)]
4#[derive(Clone, Debug)]
5pub struct EyeState {
6    /// Yaw angle in radians (horizontal rotation).
7    pub yaw: f32,
8    /// Pitch angle in radians (vertical rotation).
9    pub pitch: f32,
10    /// Current blink closure fraction [0 = open, 1 = closed].
11    pub blink_fraction: f32,
12    /// Timer counting up between blinks.
13    pub blink_timer: f32,
14    /// Duration of current blink.
15    pub blink_duration: f32,
16    /// Whether a blink is active.
17    pub blinking: bool,
18    /// LCG state for deterministic variation.
19    pub lcg_state: u64,
20}
21
22#[allow(dead_code)]
23#[derive(Clone, Debug)]
24pub enum GazeTarget {
25    /// Look at a world-space point from the given eye origin.
26    Point { origin: [f32; 3], target: [f32; 3] },
27    /// Directly specified yaw/pitch angles in radians.
28    Angles { yaw: f32, pitch: f32 },
29    /// Forward (neutral gaze).
30    Forward,
31}
32
33#[allow(dead_code)]
34#[derive(Clone, Debug)]
35pub struct EyeControlConfig {
36    /// Maximum horizontal deviation in radians.
37    pub max_yaw: f32,
38    /// Maximum vertical deviation in radians.
39    pub max_pitch: f32,
40    /// Average time between blinks (seconds).
41    pub blink_interval: f32,
42    /// Duration of a single blink (seconds).
43    pub blink_duration: f32,
44    /// Speed of saccade (fraction per second).
45    pub saccade_speed: f32,
46    /// Randomness in blink interval [0..1].
47    pub blink_variation: f32,
48}
49
50// ---------------------------------------------------------------------------
51// LCG helper
52// ---------------------------------------------------------------------------
53
54#[allow(dead_code)]
55fn lcg_step(state: &mut u64) -> f32 {
56    *state = state
57        .wrapping_mul(6_364_136_223_846_793_005)
58        .wrapping_add(1_442_695_040_888_963_407);
59    let bits = (*state >> 33) as u32;
60    (bits as f32) / (u32::MAX as f32 + 1.0)
61}
62
63// ---------------------------------------------------------------------------
64// Construction
65// ---------------------------------------------------------------------------
66
67/// Return a default `EyeControlConfig` with sensible values.
68#[allow(dead_code)]
69pub fn default_eye_config() -> EyeControlConfig {
70    EyeControlConfig {
71        max_yaw: std::f32::consts::FRAC_PI_4,
72        max_pitch: std::f32::consts::FRAC_PI_6,
73        blink_interval: 4.0,
74        blink_duration: 0.15,
75        saccade_speed: 5.0,
76        blink_variation: 0.3,
77    }
78}
79
80/// Create a new `EyeState` at neutral gaze.
81#[allow(dead_code)]
82pub fn new_eye_state(lcg_seed: u64) -> EyeState {
83    EyeState {
84        yaw: 0.0,
85        pitch: 0.0,
86        blink_fraction: 0.0,
87        blink_timer: 0.0,
88        blink_duration: 0.0,
89        blinking: false,
90        lcg_state: lcg_seed.max(1),
91    }
92}
93
94// ---------------------------------------------------------------------------
95// Angle computation
96// ---------------------------------------------------------------------------
97
98/// Compute the yaw and pitch angles needed to look from `origin` toward `target`.
99/// Returns `(yaw_rad, pitch_rad)`.
100#[allow(dead_code)]
101pub fn look_at_target(origin: [f32; 3], target: [f32; 3]) -> (f32, f32) {
102    let dx = target[0] - origin[0];
103    let dy = target[1] - origin[1];
104    let dz = target[2] - origin[2];
105    let horiz = (dx * dx + dz * dz).sqrt();
106    let yaw = dx.atan2(dz);
107    let pitch = (-dy).atan2(horiz);
108    (yaw, pitch)
109}
110
111/// Return the current yaw angle of the eye state in degrees.
112#[allow(dead_code)]
113pub fn eye_yaw_deg(state: &EyeState) -> f32 {
114    state.yaw.to_degrees()
115}
116
117/// Return the current pitch angle of the eye state in degrees.
118#[allow(dead_code)]
119pub fn eye_pitch_deg(state: &EyeState) -> f32 {
120    state.pitch.to_degrees()
121}
122
123// ---------------------------------------------------------------------------
124// Saccade / update
125// ---------------------------------------------------------------------------
126
127/// Smoothly move the eye toward `target_yaw`/`target_pitch` by at most
128/// `speed * dt` radians.
129#[allow(dead_code)]
130pub fn saccade_towards(
131    state: &mut EyeState,
132    target_yaw: f32,
133    target_pitch: f32,
134    speed: f32,
135    dt: f32,
136) {
137    let max_step = speed * dt;
138    let dy = target_yaw - state.yaw;
139    let dp = target_pitch - state.pitch;
140    let dist = (dy * dy + dp * dp).sqrt();
141    if dist <= max_step || dist < 1e-6 {
142        state.yaw = target_yaw;
143        state.pitch = target_pitch;
144    } else {
145        let s = max_step / dist;
146        state.yaw += dy * s;
147        state.pitch += dp * s;
148    }
149}
150
151/// Advance the eye gaze state by `dt` seconds toward a given `GazeTarget`.
152#[allow(dead_code)]
153pub fn update_eye_gaze(
154    state: &mut EyeState,
155    target: &GazeTarget,
156    config: &EyeControlConfig,
157    dt: f32,
158) {
159    let (ty, tp) = match target {
160        GazeTarget::Forward => (0.0_f32, 0.0_f32),
161        GazeTarget::Angles { yaw, pitch } => (*yaw, *pitch),
162        GazeTarget::Point {
163            origin,
164            target: tgt,
165        } => look_at_target(*origin, *tgt),
166    };
167    saccade_towards(state, ty, tp, config.saccade_speed, dt);
168    clamp_gaze(state, config);
169}
170
171/// Clamp the eye gaze angles to the configured maximum deviation.
172#[allow(dead_code)]
173pub fn clamp_gaze(state: &mut EyeState, config: &EyeControlConfig) {
174    state.yaw = state.yaw.clamp(-config.max_yaw, config.max_yaw);
175    state.pitch = state.pitch.clamp(-config.max_pitch, config.max_pitch);
176}
177
178// ---------------------------------------------------------------------------
179// Blink
180// ---------------------------------------------------------------------------
181
182/// Return the current blink closure fraction `[0..1]` (0 = open, 1 = closed).
183#[allow(dead_code)]
184pub fn blink_factor(state: &EyeState) -> f32 {
185    state.blink_fraction
186}
187
188/// Immediately trigger a blink with the given duration.
189#[allow(dead_code)]
190pub fn trigger_blink(state: &mut EyeState, duration: f32) {
191    state.blinking = true;
192    state.blink_duration = duration.max(0.01);
193    state.blink_timer = 0.0;
194    state.blink_fraction = 0.0;
195}
196
197/// Tick the automatic blink system: advance timers and trigger blinks when due.
198/// Uses `config.blink_interval` + LCG variation.
199#[allow(dead_code)]
200pub fn auto_blink_tick(state: &mut EyeState, config: &EyeControlConfig, dt: f32) {
201    if state.blinking {
202        state.blink_timer += dt;
203        let half = state.blink_duration * 0.5;
204        if state.blink_timer < half {
205            state.blink_fraction = state.blink_timer / half;
206        } else if state.blink_timer < state.blink_duration {
207            state.blink_fraction = 1.0 - (state.blink_timer - half) / half;
208        } else {
209            state.blink_fraction = 0.0;
210            state.blinking = false;
211            // Schedule next blink.
212            let noise = lcg_step(&mut state.lcg_state) * 2.0 - 1.0;
213            state.blink_timer = -config.blink_interval * (1.0 + noise * config.blink_variation);
214        }
215    } else {
216        state.blink_timer += dt;
217        if state.blink_timer >= config.blink_interval {
218            trigger_blink(state, config.blink_duration);
219        }
220    }
221}
222
223/// Return `true` if the eye is currently in a blink animation.
224#[allow(dead_code)]
225pub fn is_blinking_eye(state: &EyeState) -> bool {
226    state.blinking
227}
228
229// ---------------------------------------------------------------------------
230// Blending / distance
231// ---------------------------------------------------------------------------
232
233/// Blend between two eye states by factor `t` (0 = a, 1 = b).
234#[allow(dead_code)]
235pub fn gaze_blend(a: &EyeState, b: &EyeState, t: f32) -> EyeState {
236    let t = t.clamp(0.0, 1.0);
237    let u = 1.0 - t;
238    EyeState {
239        yaw: a.yaw * u + b.yaw * t,
240        pitch: a.pitch * u + b.pitch * t,
241        blink_fraction: a.blink_fraction * u + b.blink_fraction * t,
242        blink_timer: a.blink_timer * u + b.blink_timer * t,
243        blink_duration: a.blink_duration * u + b.blink_duration * t,
244        blinking: if t < 0.5 { a.blinking } else { b.blinking },
245        lcg_state: a.lcg_state,
246    }
247}
248
249/// Angular distance between two eye states (Euclidean in yaw-pitch space).
250#[allow(dead_code)]
251pub fn gaze_distance(a: &EyeState, b: &EyeState) -> f32 {
252    let dy = a.yaw - b.yaw;
253    let dp = a.pitch - b.pitch;
254    (dy * dy + dp * dp).sqrt()
255}
256
257// ---------------------------------------------------------------------------
258// Tests
259// ---------------------------------------------------------------------------
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    fn cfg() -> EyeControlConfig {
266        default_eye_config()
267    }
268
269    #[test]
270    fn test_default_eye_config() {
271        let c = cfg();
272        assert!(c.max_yaw > 0.0);
273        assert!(c.max_pitch > 0.0);
274        assert!(c.blink_interval > 0.0);
275    }
276
277    #[test]
278    fn test_new_eye_state() {
279        let s = new_eye_state(42);
280        assert_eq!(s.yaw, 0.0);
281        assert_eq!(s.pitch, 0.0);
282        assert!(!s.blinking);
283    }
284
285    #[test]
286    fn test_look_at_target_forward() {
287        let origin = [0.0_f32, 0.0, 0.0];
288        let target = [0.0_f32, 0.0, 10.0];
289        let (y, p) = look_at_target(origin, target);
290        assert!(y.abs() < 1e-4, "yaw should be ~0 for forward target");
291        assert!(p.abs() < 1e-4, "pitch should be ~0 for forward target");
292    }
293
294    #[test]
295    fn test_look_at_target_right() {
296        let origin = [0.0_f32, 0.0, 0.0];
297        let target = [1.0_f32, 0.0, 1.0];
298        let (y, _p) = look_at_target(origin, target);
299        assert!(y > 0.0, "yaw should be positive looking right");
300    }
301
302    #[test]
303    fn test_eye_yaw_pitch_deg() {
304        let mut s = new_eye_state(1);
305        s.yaw = std::f32::consts::FRAC_PI_4;
306        s.pitch = std::f32::consts::FRAC_PI_6;
307        assert!((eye_yaw_deg(&s) - 45.0).abs() < 0.01);
308        assert!((eye_pitch_deg(&s) - 30.0).abs() < 0.01);
309    }
310
311    #[test]
312    fn test_saccade_towards_reaches() {
313        let mut s = new_eye_state(1);
314        saccade_towards(&mut s, 1.0, 0.5, 10.0, 1.0);
315        assert!((s.yaw - 1.0).abs() < 1e-5);
316        assert!((s.pitch - 0.5).abs() < 1e-5);
317    }
318
319    #[test]
320    fn test_saccade_towards_partial() {
321        let mut s = new_eye_state(1);
322        saccade_towards(&mut s, 1.0, 0.0, 0.1, 1.0);
323        assert!(s.yaw > 0.0 && s.yaw < 1.0, "should partially approach");
324    }
325
326    #[test]
327    fn test_clamp_gaze() {
328        let mut s = new_eye_state(1);
329        s.yaw = 999.0;
330        s.pitch = -999.0;
331        let c = cfg();
332        clamp_gaze(&mut s, &c);
333        assert!(s.yaw <= c.max_yaw);
334        assert!(s.pitch >= -c.max_pitch);
335    }
336
337    #[test]
338    fn test_trigger_blink() {
339        let mut s = new_eye_state(1);
340        trigger_blink(&mut s, 0.2);
341        assert!(s.blinking);
342        assert!((s.blink_duration - 0.2).abs() < 1e-6);
343    }
344
345    #[test]
346    fn test_blink_factor_initial() {
347        let s = new_eye_state(1);
348        assert_eq!(blink_factor(&s), 0.0);
349    }
350
351    #[test]
352    fn test_auto_blink_tick_starts_blink() {
353        let mut s = new_eye_state(1);
354        let c = EyeControlConfig {
355            blink_interval: 0.1,
356            ..cfg()
357        };
358        // Advance past the interval.
359        auto_blink_tick(&mut s, &c, 0.2);
360        assert!(s.blinking || s.blink_fraction > 0.0 || s.blink_timer != 0.2);
361    }
362
363    #[test]
364    fn test_auto_blink_tick_closure() {
365        let mut s = new_eye_state(1);
366        let c = EyeControlConfig {
367            blink_interval: 0.01,
368            blink_duration: 0.2,
369            ..cfg()
370        };
371        // Trigger blink.
372        auto_blink_tick(&mut s, &c, 0.05);
373        // Mid blink: fraction should be > 0.
374        if s.blinking {
375            auto_blink_tick(&mut s, &c, 0.05);
376            assert!(s.blink_fraction >= 0.0);
377        }
378    }
379
380    #[test]
381    fn test_is_blinking_eye() {
382        let mut s = new_eye_state(1);
383        assert!(!is_blinking_eye(&s));
384        trigger_blink(&mut s, 0.15);
385        assert!(is_blinking_eye(&s));
386    }
387
388    #[test]
389    fn test_gaze_blend_midpoint() {
390        let mut a = new_eye_state(1);
391        let mut b = new_eye_state(2);
392        a.yaw = 0.0;
393        b.yaw = 1.0;
394        let m = gaze_blend(&a, &b, 0.5);
395        assert!((m.yaw - 0.5).abs() < 1e-5);
396    }
397
398    #[test]
399    fn test_gaze_blend_extremes() {
400        let a = new_eye_state(1);
401        let b = new_eye_state(2);
402        let m0 = gaze_blend(&a, &b, 0.0);
403        assert!((m0.yaw - a.yaw).abs() < 1e-6);
404        let m1 = gaze_blend(&a, &b, 1.0);
405        assert!((m1.yaw - b.yaw).abs() < 1e-6);
406    }
407
408    #[test]
409    fn test_gaze_distance_zero() {
410        let s = new_eye_state(1);
411        assert!(gaze_distance(&s, &s) < 1e-6);
412    }
413
414    #[test]
415    fn test_gaze_distance_nonzero() {
416        let mut a = new_eye_state(1);
417        let b = new_eye_state(2);
418        a.yaw = 1.0;
419        assert!(gaze_distance(&a, &b) > 0.5);
420    }
421
422    #[test]
423    fn test_update_eye_gaze_converges() {
424        let mut s = new_eye_state(1);
425        let c = cfg();
426        let target = GazeTarget::Angles {
427            yaw: 0.3,
428            pitch: 0.1,
429        };
430        for _ in 0..200 {
431            update_eye_gaze(&mut s, &target, &c, 0.05);
432        }
433        assert!((s.yaw - 0.3).abs() < 0.01);
434        assert!((s.pitch - 0.1).abs() < 0.01);
435    }
436
437    #[test]
438    fn test_update_eye_gaze_clamped() {
439        let mut s = new_eye_state(1);
440        let c = cfg();
441        let target = GazeTarget::Angles {
442            yaw: 99.0,
443            pitch: 99.0,
444        };
445        for _ in 0..200 {
446            update_eye_gaze(&mut s, &target, &c, 0.1);
447        }
448        assert!(s.yaw <= c.max_yaw + 1e-4);
449        assert!(s.pitch <= c.max_pitch + 1e-4);
450    }
451}