1#![allow(dead_code)]
5
6use std::collections::HashMap;
7
8pub enum GazeTarget {
14 Point([f32; 3]),
16 Direction([f32; 3]),
18 Angles { yaw: f32, pitch: f32 },
20 Forward,
22}
23
24pub struct EyeConfig {
26 pub left_eye_pos: [f32; 3],
28 pub right_eye_pos: [f32; 3],
30 pub forward_dir: [f32; 3],
32 pub up_dir: [f32; 3],
34 pub max_yaw: f32,
36 pub max_pitch: f32,
38 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
56pub struct EyeGazeAngles {
58 pub yaw: f32,
60 pub pitch: f32,
62}
63
64pub struct GazeResult {
66 pub left_eye: EyeGazeAngles,
67 pub right_eye: EyeGazeAngles,
68 pub morph_weights: HashMap<String, f32>,
70}
71
72#[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
109pub 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 let right_raw = vec3_cross(fwd, up);
130 let right_dir = vec3_normalize(right_raw);
131
132 let yaw_raw = f32::atan2(vec3_dot(dir, right_dir), vec3_dot(dir, fwd));
134 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
144fn 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
163pub 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 let norm_d = vec3_normalize(*d);
179 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
235pub 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 [
259 cy,
261 sp * sy,
262 -cp * sy,
263 0.0,
265 cp,
266 sp,
267 sy,
269 -sp * cy,
270 cp * cy,
271 ]
272}
273
274pub 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
288pub 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
296pub struct SaccadeSequence {
302 pub targets: Vec<(f32, GazeTarget)>,
304 pub blink_times: Vec<f32>,
306}
307
308impl SaccadeSequence {
309 pub fn new() -> Self {
311 Self {
312 targets: Vec::new(),
313 blink_times: Vec::new(),
314 }
315 }
316
317 pub fn add_target(&mut self, time: f32, target: GazeTarget) {
319 self.targets.push((time, target));
320 self.targets
322 .sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
323 }
324
325 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 pub fn duration(&self) -> f32 {
334 self.targets.last().map(|(t, _)| *t).unwrap_or(0.0)
335 }
336
337 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 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 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#[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 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 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 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 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 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 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 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 let result2 = compute_gaze(&cfg, &GazeTarget::Point([0.0, 1.67, 0.5]));
522 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 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 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 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 let w = iris_deform_weight(max_yaw, max_yaw);
562 assert!(approx_eq(w, 0.15, 1e-5), "w={w}");
563 let w0 = iris_deform_weight(0.0, max_yaw);
565 assert!(approx_eq(w0, 0.0, 1e-5));
566 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 let angles = EyeGazeAngles {
575 yaw: 0.0,
576 pitch: 0.0,
577 };
578 let mat = gaze_to_rotation_matrix(&angles);
579 assert!(approx_eq(mat[0], 1.0, 1e-5), "mat[0]={}", mat[0]); assert!(approx_eq(mat[1], 0.0, 1e-5), "mat[1]={}", mat[1]); assert!(approx_eq(mat[2], 0.0, 1e-5), "mat[2]={}", mat[2]); assert!(approx_eq(mat[3], 0.0, 1e-5), "mat[3]={}", mat[3]); assert!(approx_eq(mat[4], 1.0, 1e-5), "mat[4]={}", mat[4]); assert!(approx_eq(mat[5], 0.0, 1e-5), "mat[5]={}", mat[5]); assert!(approx_eq(mat[6], 0.0, 1e-5), "mat[6]={}", mat[6]); assert!(approx_eq(mat[7], 0.0, 1e-5), "mat[7]={}", mat[7]); assert!(approx_eq(mat[8], 1.0, 1e-5), "mat[8]={}", mat[8]); 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)); assert!(approx_eq(mat_yaw[6], 1.0, 1e-5)); assert!(approx_eq(mat_yaw[8], 0.0, 1e-5)); }
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 let r0 = seq.evaluate(0.0, &cfg);
625 assert!(approx_eq(r0.left_eye.yaw, 0.0, 1e-4));
626
627 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 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 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 assert!(approx_eq(seq.duration(), 1.0, 1e-5));
657 }
658}