Skip to main content

oxihuman_morph/
gaze.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4#![allow(dead_code)]
5
6use std::collections::HashMap;
7
8// ---------------------------------------------------------------------------
9// Types
10// ---------------------------------------------------------------------------
11
12/// Gaze target specification.
13pub enum GazeTarget {
14    /// Look at a specific world-space point.
15    Point([f32; 3]),
16    /// Look in a direction (normalized).
17    Direction([f32; 3]),
18    /// Yaw and pitch angles in radians.
19    Angles { yaw: f32, pitch: f32 },
20    /// Forward-looking (neutral).
21    Forward,
22}
23
24/// Configuration for the two-eye gaze system.
25pub struct EyeConfig {
26    /// Left eye center in world space.
27    pub left_eye_pos: [f32; 3],
28    /// Right eye center in world space.
29    pub right_eye_pos: [f32; 3],
30    /// Default forward direction (normalized).
31    pub forward_dir: [f32; 3],
32    /// Up direction (normalized).
33    pub up_dir: [f32; 3],
34    /// Maximum horizontal rotation in radians.
35    pub max_yaw: f32,
36    /// Maximum vertical rotation in radians.
37    pub max_pitch: f32,
38    /// Distance at which eyes converge.
39    pub convergence_dist: f32,
40}
41
42impl Default for EyeConfig {
43    fn default() -> Self {
44        Self {
45            left_eye_pos: [-0.032, 1.67, 0.095],
46            right_eye_pos: [0.032, 1.67, 0.095],
47            forward_dir: [0.0, 0.0, 1.0],
48            up_dir: [0.0, 1.0, 0.0],
49            max_yaw: std::f32::consts::FRAC_PI_4,
50            max_pitch: std::f32::consts::FRAC_PI_6,
51            convergence_dist: 2.0,
52        }
53    }
54}
55
56/// Computed gaze angles for one eye.
57pub struct EyeGazeAngles {
58    /// Horizontal rotation (positive = right).
59    pub yaw: f32,
60    /// Vertical rotation (positive = up).
61    pub pitch: f32,
62}
63
64/// Result of a full gaze computation for both eyes.
65pub struct GazeResult {
66    pub left_eye: EyeGazeAngles,
67    pub right_eye: EyeGazeAngles,
68    /// Morph weights to activate (e.g., lid follow, iris deform).
69    pub morph_weights: HashMap<String, f32>,
70}
71
72// ---------------------------------------------------------------------------
73// Math helpers
74// ---------------------------------------------------------------------------
75
76#[inline]
77fn vec3_sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
78    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
79}
80
81#[inline]
82fn vec3_dot(a: [f32; 3], b: [f32; 3]) -> f32 {
83    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
84}
85
86#[inline]
87fn vec3_cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
88    [
89        a[1] * b[2] - a[2] * b[1],
90        a[2] * b[0] - a[0] * b[2],
91        a[0] * b[1] - a[1] * b[0],
92    ]
93}
94
95#[inline]
96fn vec3_length(v: [f32; 3]) -> f32 {
97    (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt()
98}
99
100#[inline]
101fn vec3_normalize(v: [f32; 3]) -> [f32; 3] {
102    let len = vec3_length(v);
103    if len < 1e-8 {
104        return [0.0, 0.0, 1.0];
105    }
106    [v[0] / len, v[1] / len, v[2] / len]
107}
108
109// ---------------------------------------------------------------------------
110// Core gaze functions
111// ---------------------------------------------------------------------------
112
113/// Compute gaze angles for one eye from its position toward a target point.
114///
115/// `right_dir = cross(forward_dir, up_dir)` (note: this gives left-hand cross
116/// for a right-handed coordinate system where forward = +Z, up = +Y, so the
117/// resulting right = -X; we negate to get +X = right).
118pub fn eye_angles_to_point(
119    eye_pos: [f32; 3],
120    target: [f32; 3],
121    config: &EyeConfig,
122) -> EyeGazeAngles {
123    let dir = vec3_normalize(vec3_sub(target, eye_pos));
124    let fwd = config.forward_dir;
125    let up = config.up_dir;
126
127    // right_dir = normalize(forward × up) gives -X for standard coords;
128    // we want +X = right, so negate.
129    let right_raw = vec3_cross(fwd, up);
130    let right_dir = vec3_normalize(right_raw);
131
132    // Project dir onto the forward/right plane for yaw.
133    let yaw_raw = f32::atan2(vec3_dot(dir, right_dir), vec3_dot(dir, fwd));
134    // Pitch from the up component (clamped to avoid NaN from asin).
135    let sin_pitch = vec3_dot(dir, up).clamp(-1.0, 1.0);
136    let pitch_raw = sin_pitch.asin();
137
138    EyeGazeAngles {
139        yaw: yaw_raw.clamp(-config.max_yaw, config.max_yaw),
140        pitch: pitch_raw.clamp(-config.max_pitch, config.max_pitch),
141    }
142}
143
144/// Build morph_weights from averaged eye angles.
145fn build_morph_weights(
146    left: &EyeGazeAngles,
147    right: &EyeGazeAngles,
148    config: &EyeConfig,
149) -> HashMap<String, f32> {
150    let avg_pitch = (left.pitch + right.pitch) * 0.5;
151    let avg_yaw = (left.yaw.abs() + right.yaw.abs()) * 0.5;
152
153    let (upper, lower) = lid_follow_weight(avg_pitch, config.max_pitch);
154    let iris = iris_deform_weight(avg_yaw, config.max_yaw);
155
156    let mut weights = HashMap::new();
157    weights.insert("lid_upper_follow".to_string(), upper);
158    weights.insert("lid_lower_follow".to_string(), lower);
159    weights.insert("iris_deform".to_string(), iris);
160    weights
161}
162
163/// Compute gaze angles for both eyes given a target.
164pub fn compute_gaze(config: &EyeConfig, target: &GazeTarget) -> GazeResult {
165    match target {
166        GazeTarget::Point(p) => {
167            let left = eye_angles_to_point(config.left_eye_pos, *p, config);
168            let right = eye_angles_to_point(config.right_eye_pos, *p, config);
169            let morph_weights = build_morph_weights(&left, &right, config);
170            GazeResult {
171                left_eye: left,
172                right_eye: right,
173                morph_weights,
174            }
175        }
176        GazeTarget::Direction(d) => {
177            // Both eyes look in the same direction from their respective positions.
178            let norm_d = vec3_normalize(*d);
179            // We construct a synthetic far target along the direction from each eye.
180            let far = 1000.0_f32;
181            let left_target = [
182                config.left_eye_pos[0] + norm_d[0] * far,
183                config.left_eye_pos[1] + norm_d[1] * far,
184                config.left_eye_pos[2] + norm_d[2] * far,
185            ];
186            let right_target = [
187                config.right_eye_pos[0] + norm_d[0] * far,
188                config.right_eye_pos[1] + norm_d[1] * far,
189                config.right_eye_pos[2] + norm_d[2] * far,
190            ];
191            let left = eye_angles_to_point(config.left_eye_pos, left_target, config);
192            let right = eye_angles_to_point(config.right_eye_pos, right_target, config);
193            let morph_weights = build_morph_weights(&left, &right, config);
194            GazeResult {
195                left_eye: left,
196                right_eye: right,
197                morph_weights,
198            }
199        }
200        GazeTarget::Angles { yaw, pitch } => {
201            let left = EyeGazeAngles {
202                yaw: yaw.clamp(-config.max_yaw, config.max_yaw),
203                pitch: pitch.clamp(-config.max_pitch, config.max_pitch),
204            };
205            let right = EyeGazeAngles {
206                yaw: yaw.clamp(-config.max_yaw, config.max_yaw),
207                pitch: pitch.clamp(-config.max_pitch, config.max_pitch),
208            };
209            let morph_weights = build_morph_weights(&left, &right, config);
210            GazeResult {
211                left_eye: left,
212                right_eye: right,
213                morph_weights,
214            }
215        }
216        GazeTarget::Forward => {
217            let left = EyeGazeAngles {
218                yaw: 0.0,
219                pitch: 0.0,
220            };
221            let right = EyeGazeAngles {
222                yaw: 0.0,
223                pitch: 0.0,
224            };
225            let morph_weights = build_morph_weights(&left, &right, config);
226            GazeResult {
227                left_eye: left,
228                right_eye: right,
229                morph_weights,
230            }
231        }
232    }
233}
234
235/// Convert gaze angles to a 3x3 rotation matrix (column-major).
236///
237/// Yaw rotates around the up axis (Y), pitch rotates around the right axis (X).
238/// Final rotation = R_pitch * R_yaw.
239pub fn gaze_to_rotation_matrix(angles: &EyeGazeAngles) -> [f32; 9] {
240    let (sy, cy) = angles.yaw.sin_cos();
241    let (sp, cp) = angles.pitch.sin_cos();
242
243    // R_yaw (around Y axis):
244    //  [ cy   0  sy ]
245    //  [  0   1   0 ]
246    //  [-sy   0  cy ]
247    //
248    // R_pitch (around X axis):
249    //  [  1   0   0 ]
250    //  [  0  cp -sp ]
251    //  [  0  sp  cp ]
252    //
253    // R = R_pitch * R_yaw (column-major storage):
254    // col 0: [cy, sp*sy, -cp*sy]
255    // col 1: [0,  cp,    sp    ]
256    // col 2: [sy, -sp*cy, cp*cy]
257
258    [
259        // col 0
260        cy,
261        sp * sy,
262        -cp * sy,
263        // col 1
264        0.0,
265        cp,
266        sp,
267        // col 2
268        sy,
269        -sp * cy,
270        cp * cy,
271    ]
272}
273
274/// Compute lid-follow morph weights from pitch angle.
275///
276/// Returns `(upper_lid_weight, lower_lid_weight)` in `[-1, 1]`.
277/// Eyes looking up → upper lid raises (positive), lower lid lowers (negative).
278pub fn lid_follow_weight(pitch: f32, max_pitch: f32) -> (f32, f32) {
279    if max_pitch < 1e-8 {
280        return (0.0, 0.0);
281    }
282    let t = (pitch / max_pitch).clamp(-1.0, 1.0);
283    let upper = t * 0.3;
284    let lower = -t * 0.2;
285    (upper, lower)
286}
287
288/// Compute iris deform weight from yaw (side gaze stretches iris slightly).
289pub fn iris_deform_weight(yaw: f32, max_yaw: f32) -> f32 {
290    if max_yaw < 1e-8 {
291        return 0.0;
292    }
293    (yaw.abs() / max_yaw).clamp(0.0, 1.0) * 0.15
294}
295
296// ---------------------------------------------------------------------------
297// Saccade sequence
298// ---------------------------------------------------------------------------
299
300/// A sequence of gaze targets with optional blink events for saccade simulation.
301pub struct SaccadeSequence {
302    /// `(time_seconds, target)` pairs, sorted by time.
303    pub targets: Vec<(f32, GazeTarget)>,
304    /// Times at which a blink occurs.
305    pub blink_times: Vec<f32>,
306}
307
308impl SaccadeSequence {
309    /// Create an empty sequence.
310    pub fn new() -> Self {
311        Self {
312            targets: Vec::new(),
313            blink_times: Vec::new(),
314        }
315    }
316
317    /// Add a gaze target at the given time.
318    pub fn add_target(&mut self, time: f32, target: GazeTarget) {
319        self.targets.push((time, target));
320        // Keep sorted by time.
321        self.targets
322            .sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
323    }
324
325    /// Add a blink event at the given time.
326    pub fn add_blink(&mut self, time: f32) {
327        self.blink_times.push(time);
328        self.blink_times
329            .sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
330    }
331
332    /// Total duration of the sequence (time of the last target).
333    pub fn duration(&self) -> f32 {
334        self.targets.last().map(|(t, _)| *t).unwrap_or(0.0)
335    }
336
337    /// Evaluate the sequence at time `t`, returning a `GazeResult`.
338    ///
339    /// Linearly interpolates yaw/pitch between adjacent targets.
340    /// Blink events add a `blink` morph weight of 1.0 for a window of ±0.05 s.
341    pub fn evaluate(&self, t: f32, config: &EyeConfig) -> GazeResult {
342        if self.targets.is_empty() {
343            return compute_gaze(config, &GazeTarget::Forward);
344        }
345
346        // Find bracket.
347        let first_time = self.targets[0].0;
348        if t <= first_time {
349            let result = compute_gaze(config, &self.targets[0].1);
350            return self.apply_blink(result, t);
351        }
352
353        let last_target = &self.targets[self.targets.len() - 1];
354        if t >= last_target.0 {
355            let result = compute_gaze(config, &last_target.1);
356            return self.apply_blink(result, t);
357        }
358
359        // Find adjacent targets by binary search.
360        let idx = self.targets.partition_point(|(time, _)| *time <= t);
361        let prev_idx = idx.saturating_sub(1);
362        let next_idx = idx.min(self.targets.len() - 1);
363
364        let (t0, ref tgt0) = self.targets[prev_idx];
365        let (t1, ref tgt1) = self.targets[next_idx];
366
367        let alpha = if (t1 - t0).abs() < 1e-8 {
368            0.0
369        } else {
370            ((t - t0) / (t1 - t0)).clamp(0.0, 1.0)
371        };
372
373        let r0 = compute_gaze(config, tgt0);
374        let r1 = compute_gaze(config, tgt1);
375
376        let left = EyeGazeAngles {
377            yaw: lerp(r0.left_eye.yaw, r1.left_eye.yaw, alpha),
378            pitch: lerp(r0.left_eye.pitch, r1.left_eye.pitch, alpha),
379        };
380        let right = EyeGazeAngles {
381            yaw: lerp(r0.right_eye.yaw, r1.right_eye.yaw, alpha),
382            pitch: lerp(r0.right_eye.pitch, r1.right_eye.pitch, alpha),
383        };
384
385        let morph_weights = build_morph_weights(&left, &right, config);
386        let mut result = GazeResult {
387            left_eye: left,
388            right_eye: right,
389            morph_weights,
390        };
391        result = self.apply_blink(result, t);
392        result
393    }
394
395    fn apply_blink(&self, mut result: GazeResult, t: f32) -> GazeResult {
396        const BLINK_HALF_WINDOW: f32 = 0.05;
397        let is_blinking = self
398            .blink_times
399            .iter()
400            .any(|&bt| (t - bt).abs() <= BLINK_HALF_WINDOW);
401        if is_blinking {
402            result.morph_weights.insert("blink".to_string(), 1.0);
403        }
404        result
405    }
406}
407
408impl Default for SaccadeSequence {
409    fn default() -> Self {
410        Self::new()
411    }
412}
413
414#[inline]
415fn lerp(a: f32, b: f32, t: f32) -> f32 {
416    a + (b - a) * t
417}
418
419// ---------------------------------------------------------------------------
420// Tests
421// ---------------------------------------------------------------------------
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426    use std::f32::consts::{FRAC_PI_4, FRAC_PI_6};
427
428    fn approx_eq(a: f32, b: f32, eps: f32) -> bool {
429        (a - b).abs() < eps
430    }
431
432    #[test]
433    fn test_eye_config_default() {
434        let cfg = EyeConfig::default();
435        assert!(approx_eq(cfg.left_eye_pos[0], -0.032, 1e-5));
436        assert!(approx_eq(cfg.right_eye_pos[0], 0.032, 1e-5));
437        assert!(approx_eq(cfg.forward_dir[2], 1.0, 1e-5));
438        assert!(approx_eq(cfg.up_dir[1], 1.0, 1e-5));
439        assert!(approx_eq(cfg.max_yaw, FRAC_PI_4, 1e-5));
440        assert!(approx_eq(cfg.max_pitch, FRAC_PI_6, 1e-5));
441        assert!(approx_eq(cfg.convergence_dist, 2.0, 1e-5));
442    }
443
444    #[test]
445    fn test_eye_angles_to_point_forward() {
446        let cfg = EyeConfig::default();
447        // Target directly in front.
448        let angles = eye_angles_to_point([0.0, 1.67, 0.095], [0.0, 1.67, 5.0], &cfg);
449        assert!(approx_eq(angles.yaw, 0.0, 1e-4));
450        assert!(approx_eq(angles.pitch, 0.0, 1e-4));
451    }
452
453    #[test]
454    fn test_eye_angles_to_point_right() {
455        let cfg = EyeConfig::default();
456        // Target far to the right at eye height.
457        // With forward=+Z, up=+Y, right = cross(fwd, up) gives a direction.
458        // cross([0,0,1], [0,1,0]) = [-1,0,0]; the code normalizes this.
459        // So "right" in the config's frame is -X.
460        // A target at +X relative to eye → negative yaw (leftward in that frame).
461        // We just check yaw is non-zero and pitch is near zero.
462        let angles = eye_angles_to_point([0.0, 1.67, 0.095], [10.0, 1.67, 1.095], &cfg);
463        assert!(
464            angles.yaw.abs() > 0.01,
465            "yaw should be non-zero for side target"
466        );
467        assert!(approx_eq(angles.pitch, 0.0, 1e-3));
468    }
469
470    #[test]
471    fn test_eye_angles_to_point_up() {
472        let cfg = EyeConfig::default();
473        // Target above, same X/Z.
474        let angles = eye_angles_to_point([0.0, 1.67, 0.095], [0.0, 5.0, 5.095], &cfg);
475        assert!(
476            angles.pitch > 0.0,
477            "pitch should be positive for upward target"
478        );
479    }
480
481    #[test]
482    fn test_eye_angles_clamped() {
483        let cfg = EyeConfig::default();
484        // Extreme right target: yaw must be clamped to max_yaw.
485        let angles = eye_angles_to_point([0.0, 1.67, 0.095], [1000.0, 1.67, 0.095], &cfg);
486        assert!(
487            angles.yaw.abs() <= cfg.max_yaw + 1e-5,
488            "yaw must not exceed max_yaw"
489        );
490        // Extreme up target: pitch must be clamped to max_pitch.
491        let angles2 = eye_angles_to_point([0.0, 1.67, 0.095], [0.0, 1000.0, 0.095], &cfg);
492        assert!(
493            angles2.pitch.abs() <= cfg.max_pitch + 1e-5,
494            "pitch must not exceed max_pitch"
495        );
496    }
497
498    #[test]
499    fn test_compute_gaze_forward() {
500        let cfg = EyeConfig::default();
501        let result = compute_gaze(&cfg, &GazeTarget::Forward);
502        assert!(approx_eq(result.left_eye.yaw, 0.0, 1e-5));
503        assert!(approx_eq(result.left_eye.pitch, 0.0, 1e-5));
504        assert!(approx_eq(result.right_eye.yaw, 0.0, 1e-5));
505        assert!(approx_eq(result.right_eye.pitch, 0.0, 1e-5));
506        // Morph weights for neutral gaze should be near zero.
507        let upper = result.morph_weights["lid_upper_follow"];
508        let lower = result.morph_weights["lid_lower_follow"];
509        assert!(approx_eq(upper, 0.0, 1e-5));
510        assert!(approx_eq(lower, 0.0, 1e-5));
511    }
512
513    #[test]
514    fn test_compute_gaze_point() {
515        let cfg = EyeConfig::default();
516        // Point far ahead → should be nearly forward.
517        let result = compute_gaze(&cfg, &GazeTarget::Point([0.0, 1.67, 100.0]));
518        assert!(result.left_eye.yaw.abs() < 0.01);
519        assert!(result.left_eye.pitch.abs() < 0.01);
520        // Vergence: both eyes converge on a close target.
521        let result2 = compute_gaze(&cfg, &GazeTarget::Point([0.0, 1.67, 0.5]));
522        // Left eye yaw should be positive (looking right), right eye negative (looking left) or vice versa.
523        // With our right_dir = cross(fwd, up) = [-1,0,0]:
524        // left eye looks at target slightly to its right → positive direction in -X frame.
525        assert!(result2.morph_weights.contains_key("iris_deform"));
526    }
527
528    #[test]
529    fn test_compute_gaze_angles() {
530        let cfg = EyeConfig::default();
531        let yaw = 0.3_f32;
532        let pitch = 0.2_f32;
533        let result = compute_gaze(&cfg, &GazeTarget::Angles { yaw, pitch });
534        assert!(approx_eq(result.left_eye.yaw, yaw, 1e-5));
535        assert!(approx_eq(result.left_eye.pitch, pitch, 1e-5));
536        assert!(approx_eq(result.right_eye.yaw, yaw, 1e-5));
537        assert!(approx_eq(result.right_eye.pitch, pitch, 1e-5));
538    }
539
540    #[test]
541    fn test_lid_follow_weight() {
542        let max_pitch = FRAC_PI_6;
543        // Looking up (positive pitch) → positive upper lid, negative lower lid.
544        let (upper, lower) = lid_follow_weight(max_pitch, max_pitch);
545        assert!(approx_eq(upper, 0.3, 1e-5), "upper={upper}");
546        assert!(approx_eq(lower, -0.2, 1e-5), "lower={lower}");
547        // Looking down (negative pitch) → negative upper, positive lower.
548        let (upper2, lower2) = lid_follow_weight(-max_pitch, max_pitch);
549        assert!(approx_eq(upper2, -0.3, 1e-5));
550        assert!(approx_eq(lower2, 0.2, 1e-5));
551        // Neutral → zeros.
552        let (upper3, lower3) = lid_follow_weight(0.0, max_pitch);
553        assert!(approx_eq(upper3, 0.0, 1e-5));
554        assert!(approx_eq(lower3, 0.0, 1e-5));
555    }
556
557    #[test]
558    fn test_iris_deform_weight() {
559        let max_yaw = FRAC_PI_4;
560        // Extreme gaze → 0.15.
561        let w = iris_deform_weight(max_yaw, max_yaw);
562        assert!(approx_eq(w, 0.15, 1e-5), "w={w}");
563        // Neutral → 0.
564        let w0 = iris_deform_weight(0.0, max_yaw);
565        assert!(approx_eq(w0, 0.0, 1e-5));
566        // Negative yaw (left gaze) same magnitude.
567        let wn = iris_deform_weight(-max_yaw, max_yaw);
568        assert!(approx_eq(wn, 0.15, 1e-5));
569    }
570
571    #[test]
572    fn test_gaze_to_rotation_matrix() {
573        // Forward gaze → identity matrix.
574        let angles = EyeGazeAngles {
575            yaw: 0.0,
576            pitch: 0.0,
577        };
578        let mat = gaze_to_rotation_matrix(&angles);
579        // Column-major identity: [1,0,0, 0,1,0, 0,0,1].
580        assert!(approx_eq(mat[0], 1.0, 1e-5), "mat[0]={}", mat[0]); // col0.x
581        assert!(approx_eq(mat[1], 0.0, 1e-5), "mat[1]={}", mat[1]); // col0.y
582        assert!(approx_eq(mat[2], 0.0, 1e-5), "mat[2]={}", mat[2]); // col0.z
583        assert!(approx_eq(mat[3], 0.0, 1e-5), "mat[3]={}", mat[3]); // col1.x
584        assert!(approx_eq(mat[4], 1.0, 1e-5), "mat[4]={}", mat[4]); // col1.y
585        assert!(approx_eq(mat[5], 0.0, 1e-5), "mat[5]={}", mat[5]); // col1.z
586        assert!(approx_eq(mat[6], 0.0, 1e-5), "mat[6]={}", mat[6]); // col2.x
587        assert!(approx_eq(mat[7], 0.0, 1e-5), "mat[7]={}", mat[7]); // col2.y
588        assert!(approx_eq(mat[8], 1.0, 1e-5), "mat[8]={}", mat[8]); // col2.z
589
590        // Pure yaw of PI/2 around Y: col0 should be [0,0,-1], col2 [1,0,0].
591        let angles_yaw = EyeGazeAngles {
592            yaw: std::f32::consts::FRAC_PI_2,
593            pitch: 0.0,
594        };
595        let mat_yaw = gaze_to_rotation_matrix(&angles_yaw);
596        assert!(approx_eq(mat_yaw[0], 0.0, 1e-5)); // cy
597        assert!(approx_eq(mat_yaw[6], 1.0, 1e-5)); // sy (col2.x)
598        assert!(approx_eq(mat_yaw[8], 0.0, 1e-5)); // cp*cy (col2.z)
599    }
600
601    #[test]
602    fn test_saccade_sequence_new() {
603        let seq = SaccadeSequence::new();
604        assert!(seq.targets.is_empty());
605        assert!(seq.blink_times.is_empty());
606        assert!(approx_eq(seq.duration(), 0.0, 1e-5));
607    }
608
609    #[test]
610    fn test_saccade_sequence_evaluate() {
611        let cfg = EyeConfig::default();
612        let mut seq = SaccadeSequence::new();
613        seq.add_target(0.0, GazeTarget::Forward);
614        seq.add_target(
615            1.0,
616            GazeTarget::Angles {
617                yaw: 0.4,
618                pitch: 0.1,
619            },
620        );
621        seq.add_blink(0.5);
622
623        // At t=0 → forward.
624        let r0 = seq.evaluate(0.0, &cfg);
625        assert!(approx_eq(r0.left_eye.yaw, 0.0, 1e-4));
626
627        // At t=1 → angles.
628        let r1 = seq.evaluate(1.0, &cfg);
629        assert!(approx_eq(
630            r1.left_eye.yaw,
631            0.4_f32.clamp(-cfg.max_yaw, cfg.max_yaw),
632            1e-4
633        ));
634
635        // At t=0.5 → half way + blink.
636        let r_mid = seq.evaluate(0.5, &cfg);
637        assert!(
638            r_mid.morph_weights.contains_key("blink"),
639            "blink weight should be present at t=0.5"
640        );
641        assert!(approx_eq(
642            *r_mid.morph_weights.get("blink").expect("should succeed"),
643            1.0,
644            1e-5
645        ));
646
647        // At t=2.0 → clamp to last target.
648        let r_late = seq.evaluate(2.0, &cfg);
649        assert!(approx_eq(
650            r_late.left_eye.yaw,
651            0.4_f32.clamp(-cfg.max_yaw, cfg.max_yaw),
652            1e-4
653        ));
654
655        // Duration.
656        assert!(approx_eq(seq.duration(), 1.0, 1e-5));
657    }
658}