Skip to main content

oximedia_virtual/multicam/
manager.rs

1//! Multi-camera coordination manager
2//!
3//! Provides multi-camera management with automatic camera selection
4//! based on talent tracking position.
5
6use super::{CameraId, MultiCameraState};
7use crate::math::{Point3, Vector3};
8use crate::{tracking::CameraPose, Result, VirtualProductionError};
9use serde::{Deserialize, Serialize};
10
11/// Multi-camera configuration
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct MultiCameraConfig {
14    /// Number of cameras
15    pub num_cameras: usize,
16    /// Enable auto-switching
17    pub auto_switch: bool,
18}
19
20impl Default for MultiCameraConfig {
21    fn default() -> Self {
22        Self {
23            num_cameras: 1,
24            auto_switch: false,
25        }
26    }
27}
28
29/// Criteria used for automatic camera selection.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31pub enum AutoSwitchCriteria {
32    /// Select the camera whose optical axis is most aligned with the
33    /// talent's position (smallest angle between forward vector and
34    /// direction to talent).
35    BestAngle,
36    /// Select the camera closest to the talent.
37    NearestDistance,
38    /// Select based on a weighted score combining angle and distance.
39    /// The score = (1 - w) * normalized_angle + w * normalized_distance
40    /// where w is the `distance_weight` in `AutoSwitchConfig`.
41    WeightedScore,
42    /// Select the camera that has the talent most centered in its
43    /// field of view (closest to optical axis in screen space).
44    CenteredFraming,
45}
46
47/// Configuration for automatic camera selection.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct AutoSwitchConfig {
50    /// Selection criteria.
51    pub criteria: AutoSwitchCriteria,
52    /// Minimum time between automatic switches (milliseconds).
53    /// Prevents rapid ping-ponging between cameras.
54    pub min_switch_interval_ms: u64,
55    /// Hysteresis threshold: a new camera must score at least this
56    /// much better (as a fraction, e.g. 0.1 = 10%) than the current
57    /// camera to trigger a switch.
58    pub hysteresis: f64,
59    /// Distance weight for `WeightedScore` criteria (0.0 to 1.0).
60    pub distance_weight: f64,
61    /// Camera horizontal field of view in radians (used for `CenteredFraming`).
62    pub camera_fov_h: f64,
63}
64
65impl Default for AutoSwitchConfig {
66    fn default() -> Self {
67        Self {
68            criteria: AutoSwitchCriteria::BestAngle,
69            min_switch_interval_ms: 2000,
70            hysteresis: 0.15,
71            distance_weight: 0.3,
72            camera_fov_h: std::f64::consts::PI / 3.0, // 60 degrees
73        }
74    }
75}
76
77/// Result of evaluating a camera for talent coverage.
78#[derive(Debug, Clone, Copy)]
79pub struct CameraScore {
80    /// Camera identifier.
81    pub camera_id: CameraId,
82    /// Angle between camera forward and direction to talent (radians).
83    pub angle_to_talent: f64,
84    /// Distance from camera to talent (meters).
85    pub distance_to_talent: f64,
86    /// Normalized score (0.0 = best, 1.0 = worst). Lower is better.
87    pub score: f64,
88    /// Whether the talent is within the camera's field of view.
89    pub in_fov: bool,
90}
91
92/// Multi-camera manager
93pub struct MultiCameraManager {
94    config: MultiCameraConfig,
95    state: MultiCameraState,
96    /// Auto-switch configuration (used when auto_switch is enabled).
97    auto_switch_config: AutoSwitchConfig,
98    /// Timestamp (ns) of the last auto-switch. `None` if no switch has occurred.
99    last_switch_timestamp_ns: Option<u64>,
100    /// History of automatic switches for diagnostics.
101    switch_history: Vec<SwitchEvent>,
102}
103
104/// Record of a camera switch event.
105#[derive(Debug, Clone)]
106pub struct SwitchEvent {
107    /// Timestamp of the switch.
108    pub timestamp_ns: u64,
109    /// Camera switched from.
110    pub from: CameraId,
111    /// Camera switched to.
112    pub to: CameraId,
113    /// Score of the selected camera.
114    pub score: f64,
115    /// Reason for the switch.
116    pub reason: String,
117}
118
119impl MultiCameraManager {
120    /// Create new multi-camera manager
121    pub fn new(config: MultiCameraConfig) -> Result<Self> {
122        if config.num_cameras == 0 {
123            return Err(VirtualProductionError::MultiCamera(
124                "Number of cameras must be > 0".to_string(),
125            ));
126        }
127
128        Ok(Self {
129            config,
130            state: MultiCameraState::new(),
131            auto_switch_config: AutoSwitchConfig::default(),
132            last_switch_timestamp_ns: None,
133            switch_history: Vec::new(),
134        })
135    }
136
137    /// Create with auto-switch configuration.
138    pub fn with_auto_switch(
139        config: MultiCameraConfig,
140        auto_switch_config: AutoSwitchConfig,
141    ) -> Result<Self> {
142        if config.num_cameras == 0 {
143            return Err(VirtualProductionError::MultiCamera(
144                "Number of cameras must be > 0".to_string(),
145            ));
146        }
147
148        Ok(Self {
149            config: MultiCameraConfig {
150                auto_switch: true,
151                ..config
152            },
153            state: MultiCameraState::new(),
154            auto_switch_config,
155            last_switch_timestamp_ns: None,
156            switch_history: Vec::new(),
157        })
158    }
159
160    /// Update camera pose
161    pub fn update_camera(&mut self, camera_id: CameraId, pose: CameraPose) {
162        if let Some(entry) = self.state.poses.iter_mut().find(|(id, _)| *id == camera_id) {
163            entry.1 = pose;
164        } else {
165            self.state.poses.push((camera_id, pose));
166        }
167    }
168
169    /// Set active camera
170    pub fn set_active_camera(&mut self, camera_id: CameraId) {
171        self.state.active_camera = camera_id;
172    }
173
174    /// Get active camera
175    #[must_use]
176    pub fn active_camera(&self) -> CameraId {
177        self.state.active_camera
178    }
179
180    /// Get active camera pose
181    #[must_use]
182    pub fn active_pose(&self) -> Option<&CameraPose> {
183        self.state.active_pose()
184    }
185
186    /// Get all camera poses
187    #[must_use]
188    pub fn all_poses(&self) -> &[(CameraId, CameraPose)] {
189        &self.state.poses
190    }
191
192    /// Get configuration
193    #[must_use]
194    pub fn config(&self) -> &MultiCameraConfig {
195        &self.config
196    }
197
198    /// Get the auto-switch configuration.
199    #[must_use]
200    pub fn auto_switch_config(&self) -> &AutoSwitchConfig {
201        &self.auto_switch_config
202    }
203
204    /// Set the auto-switch configuration.
205    pub fn set_auto_switch_config(&mut self, config: AutoSwitchConfig) {
206        self.auto_switch_config = config;
207    }
208
209    /// Evaluate all cameras and score them for a given talent position.
210    ///
211    /// Returns scores sorted best-first (lowest score first).
212    #[must_use]
213    pub fn evaluate_cameras(&self, talent_position: &Point3<f64>) -> Vec<CameraScore> {
214        let mut scores: Vec<CameraScore> = self
215            .state
216            .poses
217            .iter()
218            .map(|(camera_id, pose)| self.score_camera(*camera_id, pose, talent_position))
219            .collect();
220
221        scores.sort_by(|a, b| {
222            a.score
223                .partial_cmp(&b.score)
224                .unwrap_or(std::cmp::Ordering::Equal)
225        });
226        scores
227    }
228
229    /// Automatically select the best camera for the given talent position.
230    ///
231    /// Applies hysteresis to avoid rapid switching. Returns `Some(CameraId)`
232    /// if a switch is recommended, `None` if the current camera is still best
233    /// (or the switch interval hasn't elapsed).
234    pub fn auto_select(
235        &mut self,
236        talent_position: &Point3<f64>,
237        current_timestamp_ns: u64,
238    ) -> Option<CameraId> {
239        if !self.config.auto_switch {
240            return None;
241        }
242
243        if self.state.poses.is_empty() {
244            return None;
245        }
246
247        // Check minimum switch interval (skip check if no switch has occurred yet)
248        if let Some(last_ts) = self.last_switch_timestamp_ns {
249            let elapsed_ns = current_timestamp_ns.saturating_sub(last_ts);
250            let min_interval_ns = self.auto_switch_config.min_switch_interval_ms * 1_000_000;
251            if elapsed_ns < min_interval_ns {
252                return None;
253            }
254        }
255
256        let scores = self.evaluate_cameras(talent_position);
257        if scores.is_empty() {
258            return None;
259        }
260
261        let best = &scores[0];
262        let current_id = self.state.active_camera;
263
264        // If best is already active, no switch needed
265        if best.camera_id == current_id {
266            return None;
267        }
268
269        // Find current camera's score
270        let current_score = scores
271            .iter()
272            .find(|s| s.camera_id == current_id)
273            .map(|s| s.score)
274            .unwrap_or(f64::MAX);
275
276        // Hysteresis: only switch if the improvement exceeds the threshold
277        let improvement = if current_score > 1e-10 {
278            (current_score - best.score) / current_score
279        } else {
280            1.0
281        };
282
283        if improvement < self.auto_switch_config.hysteresis {
284            return None;
285        }
286
287        // Perform the switch
288        let previous_camera = self.state.active_camera;
289        self.state.active_camera = best.camera_id;
290        self.last_switch_timestamp_ns = Some(current_timestamp_ns);
291
292        self.switch_history.push(SwitchEvent {
293            timestamp_ns: current_timestamp_ns,
294            from: previous_camera,
295            to: best.camera_id,
296            score: best.score,
297            reason: format!(
298                "{:?}: improvement {:.1}%",
299                self.auto_switch_config.criteria,
300                improvement * 100.0
301            ),
302        });
303
304        Some(best.camera_id)
305    }
306
307    /// Get the switch history.
308    #[must_use]
309    pub fn switch_history(&self) -> &[SwitchEvent] {
310        &self.switch_history
311    }
312
313    /// Clear switch history.
314    pub fn clear_switch_history(&mut self) {
315        self.switch_history.clear();
316    }
317
318    // -----------------------------------------------------------------------
319    // Internal scoring
320    // -----------------------------------------------------------------------
321
322    fn score_camera(
323        &self,
324        camera_id: CameraId,
325        pose: &CameraPose,
326        talent_position: &Point3<f64>,
327    ) -> CameraScore {
328        let cam_pos = pose.position;
329        let direction_to_talent = Vector3::new(
330            talent_position.x - cam_pos.x,
331            talent_position.y - cam_pos.y,
332            talent_position.z - cam_pos.z,
333        );
334
335        let distance = direction_to_talent.norm();
336        let dir_normalized = if distance > 1e-10 {
337            Vector3::new(
338                direction_to_talent.x / distance,
339                direction_to_talent.y / distance,
340                direction_to_talent.z / distance,
341            )
342        } else {
343            Vector3::new(0.0, 0.0, -1.0)
344        };
345
346        // Camera forward vector
347        let forward = pose.forward();
348
349        // Angle between forward and direction to talent
350        let cos_angle = forward.x * dir_normalized.x
351            + forward.y * dir_normalized.y
352            + forward.z * dir_normalized.z;
353        let angle = cos_angle.clamp(-1.0, 1.0).acos();
354
355        let in_fov = angle < self.auto_switch_config.camera_fov_h * 0.5;
356
357        let score = match self.auto_switch_config.criteria {
358            AutoSwitchCriteria::BestAngle => {
359                // Normalize angle to [0, 1] where 0 is best
360                angle / std::f64::consts::PI
361            }
362            AutoSwitchCriteria::NearestDistance => {
363                // Normalize distance; assume max reasonable distance is 20m
364                (distance / 20.0).min(1.0)
365            }
366            AutoSwitchCriteria::WeightedScore => {
367                let w = self.auto_switch_config.distance_weight;
368                let angle_norm = angle / std::f64::consts::PI;
369                let dist_norm = (distance / 20.0).min(1.0);
370                (1.0 - w) * angle_norm + w * dist_norm
371            }
372            AutoSwitchCriteria::CenteredFraming => {
373                // Score based on how centered the talent is in the FOV
374                if !in_fov {
375                    1.0 // Worst score if out of FOV
376                } else {
377                    let half_fov = self.auto_switch_config.camera_fov_h * 0.5;
378                    if half_fov > 1e-10 {
379                        angle / half_fov
380                    } else {
381                        0.0
382                    }
383                }
384            }
385        };
386
387        CameraScore {
388            camera_id,
389            angle_to_talent: angle,
390            distance_to_talent: distance,
391            score,
392            in_fov,
393        }
394    }
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400    use crate::math::UnitQuaternion;
401
402    #[test]
403    fn test_multicam_manager() {
404        let config = MultiCameraConfig {
405            num_cameras: 4,
406            auto_switch: false,
407        };
408        let manager = MultiCameraManager::new(config);
409        assert!(manager.is_ok());
410    }
411
412    #[test]
413    fn test_multicam_update() {
414        let config = MultiCameraConfig::default();
415        let mut manager = MultiCameraManager::new(config).expect("should succeed in test");
416
417        let pose = CameraPose::new(Point3::origin(), UnitQuaternion::identity(), 0);
418
419        manager.update_camera(CameraId(0), pose);
420        assert!(manager.active_pose().is_some());
421    }
422
423    #[test]
424    fn test_multicam_switch() {
425        let config = MultiCameraConfig {
426            num_cameras: 2,
427            auto_switch: false,
428        };
429        let mut manager = MultiCameraManager::new(config).expect("should succeed in test");
430
431        manager.set_active_camera(CameraId(1));
432        assert_eq!(manager.active_camera(), CameraId(1));
433    }
434
435    // --- Auto camera selection tests ---
436
437    fn make_camera_pose(x: f64, y: f64, z: f64, look_z: f64) -> CameraPose {
438        // Camera at (x, y, z), looking along -Z by default
439        let _ = look_z; // orientation is identity (looks along -Z)
440        CameraPose::new(Point3::new(x, y, z), UnitQuaternion::identity(), 0)
441    }
442
443    #[test]
444    fn test_evaluate_cameras_best_angle() {
445        let config = MultiCameraConfig {
446            num_cameras: 3,
447            auto_switch: true,
448        };
449        let mut manager = MultiCameraManager::with_auto_switch(
450            config,
451            AutoSwitchConfig {
452                criteria: AutoSwitchCriteria::BestAngle,
453                ..AutoSwitchConfig::default()
454            },
455        )
456        .expect("should succeed in test");
457
458        // Camera 0 at origin looking -Z
459        manager.update_camera(CameraId(0), make_camera_pose(0.0, 0.0, 0.0, -1.0));
460        // Camera 1 offset to the right
461        manager.update_camera(CameraId(1), make_camera_pose(5.0, 0.0, 0.0, -1.0));
462        // Camera 2 far away
463        manager.update_camera(CameraId(2), make_camera_pose(10.0, 0.0, 0.0, -1.0));
464
465        // Talent directly in front of camera 0 at (0, 0, -5)
466        let talent_pos = Point3::new(0.0, 0.0, -5.0);
467        let scores = manager.evaluate_cameras(&talent_pos);
468
469        assert_eq!(scores.len(), 3);
470        // Camera 0 should have the best (lowest) score - talent is on its axis
471        assert_eq!(scores[0].camera_id, CameraId(0));
472        assert!(
473            scores[0].score < scores[1].score,
474            "cam0 score {} should be < cam1 score {}",
475            scores[0].score,
476            scores[1].score
477        );
478    }
479
480    #[test]
481    fn test_evaluate_cameras_nearest_distance() {
482        let config = MultiCameraConfig {
483            num_cameras: 2,
484            auto_switch: true,
485        };
486        let mut manager = MultiCameraManager::with_auto_switch(
487            config,
488            AutoSwitchConfig {
489                criteria: AutoSwitchCriteria::NearestDistance,
490                ..AutoSwitchConfig::default()
491            },
492        )
493        .expect("should succeed in test");
494
495        manager.update_camera(CameraId(0), make_camera_pose(0.0, 0.0, 0.0, -1.0));
496        manager.update_camera(CameraId(1), make_camera_pose(0.0, 0.0, -4.0, -1.0));
497
498        // Talent at (0, 0, -5) - closer to camera 1
499        let talent_pos = Point3::new(0.0, 0.0, -5.0);
500        let scores = manager.evaluate_cameras(&talent_pos);
501
502        assert_eq!(scores[0].camera_id, CameraId(1));
503        assert!(
504            scores[0].distance_to_talent < scores[1].distance_to_talent,
505            "cam1 should be closer"
506        );
507    }
508
509    #[test]
510    fn test_auto_select_switches_camera() {
511        let config = MultiCameraConfig {
512            num_cameras: 2,
513            auto_switch: true,
514        };
515        let mut manager = MultiCameraManager::with_auto_switch(
516            config,
517            AutoSwitchConfig {
518                criteria: AutoSwitchCriteria::BestAngle,
519                min_switch_interval_ms: 0, // allow immediate switching
520                hysteresis: 0.05,
521                ..AutoSwitchConfig::default()
522            },
523        )
524        .expect("should succeed in test");
525
526        manager.update_camera(CameraId(0), make_camera_pose(0.0, 0.0, 0.0, -1.0));
527        manager.update_camera(CameraId(1), make_camera_pose(5.0, 0.0, 0.0, -1.0));
528
529        // Start with camera 0 active
530        manager.set_active_camera(CameraId(0));
531
532        // Talent moves to be directly in front of camera 1
533        // Camera 1 at (5,0,0) looking -Z, talent at (5, 0, -5)
534        let talent_pos = Point3::new(5.0, 0.0, -5.0);
535        let result = manager.auto_select(&talent_pos, 1_000_000_000);
536
537        // Should switch to camera 1
538        assert_eq!(result, Some(CameraId(1)));
539        assert_eq!(manager.active_camera(), CameraId(1));
540    }
541
542    #[test]
543    fn test_auto_select_respects_min_interval() {
544        let config = MultiCameraConfig {
545            num_cameras: 2,
546            auto_switch: true,
547        };
548        let mut manager = MultiCameraManager::with_auto_switch(
549            config,
550            AutoSwitchConfig {
551                criteria: AutoSwitchCriteria::NearestDistance,
552                min_switch_interval_ms: 2000,
553                hysteresis: 0.0,
554                ..AutoSwitchConfig::default()
555            },
556        )
557        .expect("should succeed in test");
558
559        // Camera 0 at origin, camera 1 at (10, 0, 0)
560        manager.update_camera(CameraId(0), make_camera_pose(0.0, 0.0, 0.0, -1.0));
561        manager.update_camera(CameraId(1), make_camera_pose(10.0, 0.0, 0.0, -1.0));
562        manager.set_active_camera(CameraId(0));
563
564        // Talent right next to camera 1 => should switch to cam 1
565        let talent_near_cam1 = Point3::new(10.0, 0.0, -1.0);
566        let r1 = manager.auto_select(&talent_near_cam1, 0);
567        assert!(r1.is_some(), "should switch to nearer camera");
568
569        // Now talent moves back near cam 0, but interval not elapsed
570        let talent_near_cam0 = Point3::new(0.0, 0.0, -1.0);
571        let r2 = manager.auto_select(&talent_near_cam0, 500_000_000); // 0.5s later
572        assert!(r2.is_none(), "should respect min switch interval");
573
574        // After interval elapses, should be able to switch again
575        let r3 = manager.auto_select(&talent_near_cam0, 3_000_000_000); // 3s later
576        assert!(r3.is_some(), "should allow switch after interval");
577    }
578
579    #[test]
580    fn test_auto_select_hysteresis() {
581        let config = MultiCameraConfig {
582            num_cameras: 2,
583            auto_switch: true,
584        };
585        let mut manager = MultiCameraManager::with_auto_switch(
586            config,
587            AutoSwitchConfig {
588                criteria: AutoSwitchCriteria::BestAngle,
589                min_switch_interval_ms: 0,
590                hysteresis: 0.5, // very high hysteresis
591                ..AutoSwitchConfig::default()
592            },
593        )
594        .expect("should succeed in test");
595
596        manager.update_camera(CameraId(0), make_camera_pose(0.0, 0.0, 0.0, -1.0));
597        manager.update_camera(CameraId(1), make_camera_pose(1.0, 0.0, 0.0, -1.0));
598        manager.set_active_camera(CameraId(0));
599
600        // Talent slightly favors camera 1, but not by 50%
601        let talent_pos = Point3::new(0.5, 0.0, -5.0);
602        let result = manager.auto_select(&talent_pos, 1_000_000_000);
603
604        // High hysteresis should prevent switching for a marginal improvement
605        assert!(result.is_none(), "hysteresis should prevent switch");
606    }
607
608    #[test]
609    fn test_auto_select_disabled() {
610        let config = MultiCameraConfig {
611            num_cameras: 2,
612            auto_switch: false,
613        };
614        let mut manager = MultiCameraManager::new(config).expect("should succeed in test");
615        manager.update_camera(CameraId(0), make_camera_pose(0.0, 0.0, 0.0, -1.0));
616        manager.update_camera(CameraId(1), make_camera_pose(5.0, 0.0, 0.0, -1.0));
617
618        let talent_pos = Point3::new(5.0, 0.0, -5.0);
619        let result = manager.auto_select(&talent_pos, 1_000_000_000);
620        assert!(result.is_none(), "auto_select should be disabled");
621    }
622
623    #[test]
624    fn test_switch_history() {
625        let config = MultiCameraConfig {
626            num_cameras: 2,
627            auto_switch: true,
628        };
629        let mut manager = MultiCameraManager::with_auto_switch(
630            config,
631            AutoSwitchConfig {
632                criteria: AutoSwitchCriteria::BestAngle,
633                min_switch_interval_ms: 0,
634                hysteresis: 0.0,
635                ..AutoSwitchConfig::default()
636            },
637        )
638        .expect("should succeed in test");
639
640        manager.update_camera(CameraId(0), make_camera_pose(0.0, 0.0, 0.0, -1.0));
641        manager.update_camera(CameraId(1), make_camera_pose(5.0, 0.0, 0.0, -1.0));
642        manager.set_active_camera(CameraId(0));
643
644        let talent_pos = Point3::new(5.0, 0.0, -5.0);
645        manager.auto_select(&talent_pos, 1_000_000_000);
646
647        assert_eq!(manager.switch_history().len(), 1);
648        assert_eq!(manager.switch_history()[0].from, CameraId(0));
649        assert_eq!(manager.switch_history()[0].to, CameraId(1));
650
651        manager.clear_switch_history();
652        assert!(manager.switch_history().is_empty());
653    }
654
655    #[test]
656    fn test_camera_score_in_fov() {
657        let config = MultiCameraConfig {
658            num_cameras: 1,
659            auto_switch: true,
660        };
661        let mut manager = MultiCameraManager::with_auto_switch(
662            config,
663            AutoSwitchConfig {
664                camera_fov_h: std::f64::consts::PI / 3.0, // 60 deg
665                ..AutoSwitchConfig::default()
666            },
667        )
668        .expect("should succeed in test");
669
670        manager.update_camera(CameraId(0), make_camera_pose(0.0, 0.0, 0.0, -1.0));
671
672        // Talent directly ahead - should be in FOV
673        let scores_ahead = manager.evaluate_cameras(&Point3::new(0.0, 0.0, -5.0));
674        assert!(scores_ahead[0].in_fov, "talent ahead should be in FOV");
675
676        // Talent behind camera - should NOT be in FOV
677        let scores_behind = manager.evaluate_cameras(&Point3::new(0.0, 0.0, 5.0));
678        assert!(
679            !scores_behind[0].in_fov,
680            "talent behind should not be in FOV"
681        );
682    }
683
684    #[test]
685    fn test_weighted_score_criteria() {
686        let config = MultiCameraConfig {
687            num_cameras: 2,
688            auto_switch: true,
689        };
690        let mut manager = MultiCameraManager::with_auto_switch(
691            config,
692            AutoSwitchConfig {
693                criteria: AutoSwitchCriteria::WeightedScore,
694                distance_weight: 0.5,
695                min_switch_interval_ms: 0,
696                hysteresis: 0.0,
697                ..AutoSwitchConfig::default()
698            },
699        )
700        .expect("should succeed in test");
701
702        manager.update_camera(CameraId(0), make_camera_pose(0.0, 0.0, 0.0, -1.0));
703        manager.update_camera(CameraId(1), make_camera_pose(2.0, 0.0, 0.0, -1.0));
704
705        let scores = manager.evaluate_cameras(&Point3::new(1.0, 0.0, -3.0));
706        // Both cameras should have valid scores
707        assert_eq!(scores.len(), 2);
708        for s in &scores {
709            assert!(
710                s.score >= 0.0 && s.score <= 1.0,
711                "score out of range: {}",
712                s.score
713            );
714        }
715    }
716
717    #[test]
718    fn test_centered_framing_criteria() {
719        let config = MultiCameraConfig {
720            num_cameras: 2,
721            auto_switch: true,
722        };
723        let mut manager = MultiCameraManager::with_auto_switch(
724            config,
725            AutoSwitchConfig {
726                criteria: AutoSwitchCriteria::CenteredFraming,
727                min_switch_interval_ms: 0,
728                hysteresis: 0.0,
729                camera_fov_h: std::f64::consts::PI / 3.0,
730                ..AutoSwitchConfig::default()
731            },
732        )
733        .expect("should succeed in test");
734
735        manager.update_camera(CameraId(0), make_camera_pose(0.0, 0.0, 0.0, -1.0));
736        manager.update_camera(CameraId(1), make_camera_pose(5.0, 0.0, 0.0, -1.0));
737
738        // Talent at (0, 0, -5) - perfectly centered for camera 0
739        let scores = manager.evaluate_cameras(&Point3::new(0.0, 0.0, -5.0));
740        assert_eq!(scores[0].camera_id, CameraId(0));
741        assert!(
742            scores[0].score < 0.05,
743            "perfectly centered should have very low score: {}",
744            scores[0].score
745        );
746    }
747
748    #[test]
749    fn test_auto_select_no_cameras() {
750        let config = MultiCameraConfig {
751            num_cameras: 1,
752            auto_switch: true,
753        };
754        let mut manager = MultiCameraManager::with_auto_switch(config, AutoSwitchConfig::default())
755            .expect("should succeed in test");
756
757        // No cameras registered yet
758        let talent_pos = Point3::new(0.0, 0.0, -5.0);
759        let result = manager.auto_select(&talent_pos, 1_000_000_000);
760        assert!(result.is_none());
761    }
762}