Skip to main content

canvas_renderer/
spatial.rs

1//! Spatial rendering for holographic and 3D displays.
2//!
3//! This module provides camera and view calculations needed for
4//! multi-view rendering on holographic displays like Looking Glass.
5//!
6//! ## Holographic Rendering Concepts
7//!
8//! ```text
9//!                     Looking Glass Display
10//!                    ┌─────────────────────┐
11//!                    │  ╱   ╱   ╱   ╱   ╱  │
12//!                    │ ╱   ╱   ╱   ╱   ╱   │  Lenticular lens array
13//!                    │╱   ╱   ╱   ╱   ╱    │  directs different views
14//!                    └─────────────────────┘  to each eye
15//!
16//!          Camera Arc (45 views)
17//!         ╭─────────────────────╮
18//!        ╱  ╲                   ╲
19//!       ●    ●    ●    ●    ●    ●   ← Camera positions
20//!       0    8   16   24   32   44
21//!              ↓
22//!         ┌─────────────────┐
23//!         │ Scene to render │
24//!         └─────────────────┘
25//! ```
26//!
27//! ## Quilt Format
28//!
29//! Multiple views are rendered into a single "quilt" texture:
30//!
31//! ```text
32//! ┌────┬────┬────┬────┬────┐
33//! │ 40 │ 41 │ 42 │ 43 │ 44 │
34//! ├────┼────┼────┼────┼────┤
35//! │ 35 │ 36 │ 37 │ 38 │ 39 │
36//! ├────┼────┼────┼────┼────┤
37//! │... │... │... │... │... │
38//! ├────┼────┼────┼────┼────┤
39//! │  0 │  1 │  2 │  3 │  4 │
40//! └────┴────┴────┴────┴────┘
41//!        Quilt (5x9 grid = 45 views)
42//! ```
43
44use serde::{Deserialize, Serialize};
45
46/// A 3D vector for positions and directions.
47#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
48pub struct Vec3 {
49    /// X component.
50    pub x: f32,
51    /// Y component.
52    pub y: f32,
53    /// Z component.
54    pub z: f32,
55}
56
57impl Vec3 {
58    /// Create a new vector.
59    #[must_use]
60    pub const fn new(x: f32, y: f32, z: f32) -> Self {
61        Self { x, y, z }
62    }
63
64    /// Zero vector.
65    #[must_use]
66    pub const fn zero() -> Self {
67        Self::new(0.0, 0.0, 0.0)
68    }
69
70    /// Unit vector pointing up (Y+).
71    #[must_use]
72    pub const fn up() -> Self {
73        Self::new(0.0, 1.0, 0.0)
74    }
75
76    /// Unit vector pointing forward (Z-).
77    #[must_use]
78    pub const fn forward() -> Self {
79        Self::new(0.0, 0.0, -1.0)
80    }
81
82    /// Calculate the length (magnitude) of the vector.
83    #[must_use]
84    pub fn length(&self) -> f32 {
85        (self.x * self.x + self.y * self.y + self.z * self.z).sqrt()
86    }
87
88    /// Normalize the vector to unit length.
89    #[must_use]
90    pub fn normalize(&self) -> Self {
91        let len = self.length();
92        if len > 0.0 {
93            Self::new(self.x / len, self.y / len, self.z / len)
94        } else {
95            *self
96        }
97    }
98
99    /// Cross product of two vectors.
100    #[must_use]
101    pub fn cross(&self, other: &Self) -> Self {
102        Self::new(
103            self.y * other.z - self.z * other.y,
104            self.z * other.x - self.x * other.z,
105            self.x * other.y - self.y * other.x,
106        )
107    }
108
109    /// Dot product of two vectors.
110    #[must_use]
111    pub fn dot(&self, other: &Self) -> f32 {
112        self.x * other.x + self.y * other.y + self.z * other.z
113    }
114
115    /// Subtract two vectors.
116    #[must_use]
117    pub fn sub(&self, other: &Self) -> Self {
118        Self::new(self.x - other.x, self.y - other.y, self.z - other.z)
119    }
120
121    /// Add two vectors.
122    #[must_use]
123    pub fn add(&self, other: &Self) -> Self {
124        Self::new(self.x + other.x, self.y + other.y, self.z + other.z)
125    }
126
127    /// Scale vector by a scalar.
128    #[must_use]
129    pub fn scale(&self, s: f32) -> Self {
130        Self::new(self.x * s, self.y * s, self.z * s)
131    }
132}
133
134impl Default for Vec3 {
135    fn default() -> Self {
136        Self::zero()
137    }
138}
139
140/// A 4x4 matrix for transformations.
141#[derive(Debug, Clone, Copy, PartialEq)]
142pub struct Mat4 {
143    /// Matrix data in column-major order.
144    pub data: [f32; 16],
145}
146
147impl Mat4 {
148    /// Create identity matrix.
149    #[must_use]
150    pub fn identity() -> Self {
151        #[rustfmt::skip]
152        let data = [
153            1.0, 0.0, 0.0, 0.0,
154            0.0, 1.0, 0.0, 0.0,
155            0.0, 0.0, 1.0, 0.0,
156            0.0, 0.0, 0.0, 1.0,
157        ];
158        Self { data }
159    }
160
161    /// Create a look-at view matrix.
162    #[must_use]
163    pub fn look_at(eye: Vec3, target: Vec3, up: Vec3) -> Self {
164        let f = target.sub(&eye).normalize();
165        let s = f.cross(&up).normalize();
166        let u = s.cross(&f);
167
168        #[rustfmt::skip]
169        let data = [
170            s.x,  u.x,  -f.x, 0.0,
171            s.y,  u.y,  -f.y, 0.0,
172            s.z,  u.z,  -f.z, 0.0,
173            -s.dot(&eye), -u.dot(&eye), f.dot(&eye), 1.0,
174        ];
175        Self { data }
176    }
177
178    /// Create a perspective projection matrix.
179    #[must_use]
180    pub fn perspective(fov_y_radians: f32, aspect: f32, near: f32, far: f32) -> Self {
181        let f = 1.0 / (fov_y_radians / 2.0).tan();
182        let nf = 1.0 / (near - far);
183
184        #[rustfmt::skip]
185        let data = [
186            f / aspect, 0.0, 0.0, 0.0,
187            0.0, f, 0.0, 0.0,
188            0.0, 0.0, (far + near) * nf, -1.0,
189            0.0, 0.0, 2.0 * far * near * nf, 0.0,
190        ];
191        Self { data }
192    }
193
194    /// Multiply two matrices.
195    #[must_use]
196    pub fn mul(&self, other: &Self) -> Self {
197        let mut result = [0.0f32; 16];
198
199        for row in 0..4 {
200            for col in 0..4 {
201                for k in 0..4 {
202                    result[col * 4 + row] += self.data[k * 4 + row] * other.data[col * 4 + k];
203                }
204            }
205        }
206
207        Self { data: result }
208    }
209}
210
211impl Default for Mat4 {
212    fn default() -> Self {
213        Self::identity()
214    }
215}
216
217/// Camera for 3D rendering.
218#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct Camera {
220    /// Camera position in world space.
221    pub position: Vec3,
222    /// Point the camera is looking at.
223    pub target: Vec3,
224    /// Up direction (usually Y+).
225    pub up: Vec3,
226    /// Field of view in radians.
227    pub fov: f32,
228    /// Near clipping plane.
229    pub near: f32,
230    /// Far clipping plane.
231    pub far: f32,
232}
233
234impl Camera {
235    /// Create a new camera with default settings.
236    #[must_use]
237    pub fn new() -> Self {
238        Self {
239            position: Vec3::new(0.0, 0.0, 5.0),
240            target: Vec3::zero(),
241            up: Vec3::up(),
242            fov: std::f32::consts::FRAC_PI_4, // 45 degrees
243            near: 0.1,
244            far: 100.0,
245        }
246    }
247
248    /// Create view matrix for this camera.
249    #[must_use]
250    pub fn view_matrix(&self) -> Mat4 {
251        Mat4::look_at(self.position, self.target, self.up)
252    }
253
254    /// Create projection matrix for this camera.
255    #[must_use]
256    pub fn projection_matrix(&self, aspect: f32) -> Mat4 {
257        Mat4::perspective(self.fov, aspect, self.near, self.far)
258    }
259}
260
261impl Default for Camera {
262    fn default() -> Self {
263        Self::new()
264    }
265}
266
267/// Configuration for holographic multi-view rendering.
268#[derive(Debug, Clone, Serialize, Deserialize)]
269pub struct HolographicConfig {
270    /// Number of views to render (typically 45 for Looking Glass).
271    pub num_views: u32,
272    /// Number of columns in the quilt grid.
273    pub quilt_columns: u32,
274    /// Number of rows in the quilt grid.
275    pub quilt_rows: u32,
276    /// Width of each view in pixels.
277    pub view_width: u32,
278    /// Height of each view in pixels.
279    pub view_height: u32,
280    /// Total viewing angle in radians (how much the camera sweeps).
281    pub view_cone: f32,
282    /// Distance from camera to focal plane.
283    pub focal_distance: f32,
284}
285
286impl HolographicConfig {
287    /// Standard Looking Glass Portrait configuration (45 views, 5x9 quilt).
288    #[must_use]
289    pub fn looking_glass_portrait() -> Self {
290        Self {
291            num_views: 45,
292            quilt_columns: 5,
293            quilt_rows: 9,
294            view_width: 420,
295            view_height: 560,
296            view_cone: 40.0_f32.to_radians(), // 40 degrees total
297            focal_distance: 2.0,
298        }
299    }
300
301    /// Standard Looking Glass 4K configuration (45 views, 5x9 quilt).
302    #[must_use]
303    pub fn looking_glass_4k() -> Self {
304        Self {
305            num_views: 45,
306            quilt_columns: 5,
307            quilt_rows: 9,
308            view_width: 819,
309            view_height: 455,
310            view_cone: 40.0_f32.to_radians(),
311            focal_distance: 2.0,
312        }
313    }
314
315    /// Calculate the total quilt texture width.
316    #[must_use]
317    pub fn quilt_width(&self) -> u32 {
318        self.quilt_columns * self.view_width
319    }
320
321    /// Calculate the total quilt texture height.
322    #[must_use]
323    pub fn quilt_height(&self) -> u32 {
324        self.quilt_rows * self.view_height
325    }
326
327    /// Calculate which row and column a view index maps to.
328    #[must_use]
329    pub fn view_to_grid(&self, view_index: u32) -> (u32, u32) {
330        let col = view_index % self.quilt_columns;
331        let row = view_index / self.quilt_columns;
332        (col, row)
333    }
334
335    /// Calculate the pixel offset for a view in the quilt.
336    #[must_use]
337    pub fn view_offset(&self, view_index: u32) -> (u32, u32) {
338        let (col, row) = self.view_to_grid(view_index);
339        (col * self.view_width, row * self.view_height)
340    }
341
342    /// Calculate the camera position for a specific view.
343    ///
344    /// The camera moves along a horizontal arc centered on the target.
345    #[must_use]
346    #[allow(clippy::cast_precision_loss)] // View indices are small, precision loss negligible
347    pub fn camera_for_view(&self, base_camera: &Camera, view_index: u32) -> Camera {
348        if self.num_views <= 1 {
349            return base_camera.clone();
350        }
351
352        // Calculate the angle offset for this view
353        // View 0 is leftmost, view (num_views-1) is rightmost
354        let t = view_index as f32 / (self.num_views - 1) as f32;
355        let angle = (t - 0.5) * self.view_cone;
356
357        // Calculate new camera position by rotating around the target
358        let dir = base_camera.position.sub(&base_camera.target);
359        let distance = dir.length();
360
361        // Rotate the direction vector around Y axis
362        let cos_a = angle.cos();
363        let sin_a = angle.sin();
364        let new_dir = Vec3::new(
365            dir.x * cos_a + dir.z * sin_a,
366            dir.y,
367            -dir.x * sin_a + dir.z * cos_a,
368        );
369
370        Camera {
371            position: base_camera.target.add(&new_dir.normalize().scale(distance)),
372            target: base_camera.target,
373            up: base_camera.up,
374            fov: base_camera.fov,
375            near: base_camera.near,
376            far: base_camera.far,
377        }
378    }
379}
380
381impl Default for HolographicConfig {
382    fn default() -> Self {
383        Self::looking_glass_portrait()
384    }
385}
386
387/// Result of rendering a quilt.
388#[derive(Debug, Clone)]
389pub struct QuiltRenderInfo {
390    /// Width of the complete quilt texture.
391    pub width: u32,
392    /// Height of the complete quilt texture.
393    pub height: u32,
394    /// Number of views rendered.
395    pub num_views: u32,
396    /// Columns in the quilt grid.
397    pub columns: u32,
398    /// Rows in the quilt grid.
399    pub rows: u32,
400}
401
402impl QuiltRenderInfo {
403    /// Create render info from holographic config.
404    #[must_use]
405    pub fn from_config(config: &HolographicConfig) -> Self {
406        Self {
407            width: config.quilt_width(),
408            height: config.quilt_height(),
409            num_views: config.num_views,
410            columns: config.quilt_columns,
411            rows: config.quilt_rows,
412        }
413    }
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419
420    const EPSILON: f32 = 1e-5;
421
422    fn approx_eq(a: f32, b: f32) -> bool {
423        (a - b).abs() < EPSILON
424    }
425
426    // ===========================================
427    // TDD: Vec3 Tests
428    // ===========================================
429
430    #[test]
431    fn test_vec3_new() {
432        let v = Vec3::new(1.0, 2.0, 3.0);
433        assert!(approx_eq(v.x, 1.0));
434        assert!(approx_eq(v.y, 2.0));
435        assert!(approx_eq(v.z, 3.0));
436    }
437
438    #[test]
439    fn test_vec3_zero() {
440        let v = Vec3::zero();
441        assert!(approx_eq(v.x, 0.0));
442        assert!(approx_eq(v.y, 0.0));
443        assert!(approx_eq(v.z, 0.0));
444    }
445
446    #[test]
447    fn test_vec3_length() {
448        let v = Vec3::new(3.0, 4.0, 0.0);
449        assert!(approx_eq(v.length(), 5.0));
450    }
451
452    #[test]
453    fn test_vec3_normalize() {
454        let v = Vec3::new(3.0, 0.0, 0.0);
455        let n = v.normalize();
456        assert!(approx_eq(n.x, 1.0));
457        assert!(approx_eq(n.y, 0.0));
458        assert!(approx_eq(n.z, 0.0));
459    }
460
461    #[test]
462    fn test_vec3_normalize_zero() {
463        let v = Vec3::zero();
464        let n = v.normalize();
465        // Zero vector normalizes to zero
466        assert!(approx_eq(n.length(), 0.0));
467    }
468
469    #[test]
470    fn test_vec3_cross() {
471        let x = Vec3::new(1.0, 0.0, 0.0);
472        let y = Vec3::new(0.0, 1.0, 0.0);
473        let z = x.cross(&y);
474        assert!(approx_eq(z.x, 0.0));
475        assert!(approx_eq(z.y, 0.0));
476        assert!(approx_eq(z.z, 1.0));
477    }
478
479    #[test]
480    fn test_vec3_dot() {
481        let a = Vec3::new(1.0, 2.0, 3.0);
482        let b = Vec3::new(4.0, 5.0, 6.0);
483        assert!(approx_eq(a.dot(&b), 32.0)); // 1*4 + 2*5 + 3*6 = 32
484    }
485
486    #[test]
487    fn test_vec3_add_sub() {
488        let a = Vec3::new(1.0, 2.0, 3.0);
489        let b = Vec3::new(4.0, 5.0, 6.0);
490        let sum = a.add(&b);
491        let diff = a.sub(&b);
492
493        assert!(approx_eq(sum.x, 5.0));
494        assert!(approx_eq(sum.y, 7.0));
495        assert!(approx_eq(sum.z, 9.0));
496
497        assert!(approx_eq(diff.x, -3.0));
498        assert!(approx_eq(diff.y, -3.0));
499        assert!(approx_eq(diff.z, -3.0));
500    }
501
502    #[test]
503    fn test_vec3_scale() {
504        let v = Vec3::new(1.0, 2.0, 3.0);
505        let scaled = v.scale(2.0);
506        assert!(approx_eq(scaled.x, 2.0));
507        assert!(approx_eq(scaled.y, 4.0));
508        assert!(approx_eq(scaled.z, 6.0));
509    }
510
511    // ===========================================
512    // TDD: Mat4 Tests
513    // ===========================================
514
515    #[test]
516    fn test_mat4_identity() {
517        let m = Mat4::identity();
518        // Diagonal should be 1s
519        assert!(approx_eq(m.data[0], 1.0));
520        assert!(approx_eq(m.data[5], 1.0));
521        assert!(approx_eq(m.data[10], 1.0));
522        assert!(approx_eq(m.data[15], 1.0));
523        // Off-diagonal should be 0s
524        assert!(approx_eq(m.data[1], 0.0));
525        assert!(approx_eq(m.data[4], 0.0));
526    }
527
528    #[test]
529    fn test_mat4_mul_identity() {
530        let m = Mat4::identity();
531        let result = m.mul(&m);
532        // Identity * Identity = Identity
533        assert!(approx_eq(result.data[0], 1.0));
534        assert!(approx_eq(result.data[5], 1.0));
535        assert!(approx_eq(result.data[10], 1.0));
536        assert!(approx_eq(result.data[15], 1.0));
537    }
538
539    #[test]
540    fn test_mat4_look_at() {
541        let eye = Vec3::new(0.0, 0.0, 5.0);
542        let target = Vec3::zero();
543        let up = Vec3::up();
544        let view = Mat4::look_at(eye, target, up);
545
546        // The view matrix should translate -eye when looking at origin
547        // The last column should contain the translation
548        assert!(view.data[15].abs() - 1.0 < EPSILON);
549    }
550
551    #[test]
552    fn test_mat4_perspective() {
553        let proj = Mat4::perspective(
554            std::f32::consts::FRAC_PI_4, // 45 degrees
555            1.0,                         // square aspect
556            0.1,
557            100.0,
558        );
559        // Perspective matrix should have -1 in [2,3] position (row 3, col 2)
560        assert!(approx_eq(proj.data[11], -1.0));
561    }
562
563    // ===========================================
564    // TDD: Camera Tests
565    // ===========================================
566
567    #[test]
568    fn test_camera_default() {
569        let cam = Camera::new();
570        assert!(approx_eq(cam.position.z, 5.0));
571        assert!(approx_eq(cam.target.x, 0.0));
572        assert!(approx_eq(cam.target.y, 0.0));
573        assert!(approx_eq(cam.target.z, 0.0));
574    }
575
576    #[test]
577    fn test_camera_view_matrix() {
578        let cam = Camera::new();
579        let view = cam.view_matrix();
580        // Should be a valid 4x4 matrix
581        assert_eq!(view.data.len(), 16);
582    }
583
584    #[test]
585    fn test_camera_projection_matrix() {
586        let cam = Camera::new();
587        let proj = cam.projection_matrix(16.0 / 9.0);
588        // Should be a valid 4x4 matrix with perspective division
589        assert!(approx_eq(proj.data[11], -1.0));
590    }
591
592    // ===========================================
593    // TDD: HolographicConfig Tests
594    // ===========================================
595
596    #[test]
597    fn test_holographic_config_portrait() {
598        let config = HolographicConfig::looking_glass_portrait();
599        assert_eq!(config.num_views, 45);
600        assert_eq!(config.quilt_columns, 5);
601        assert_eq!(config.quilt_rows, 9);
602        assert_eq!(config.num_views, config.quilt_columns * config.quilt_rows);
603    }
604
605    #[test]
606    fn test_holographic_config_4k() {
607        let config = HolographicConfig::looking_glass_4k();
608        assert_eq!(config.num_views, 45);
609        assert_eq!(config.quilt_columns, 5);
610        assert_eq!(config.quilt_rows, 9);
611    }
612
613    #[test]
614    fn test_quilt_dimensions() {
615        let config = HolographicConfig::looking_glass_portrait();
616        let width = config.quilt_width();
617        let height = config.quilt_height();
618
619        assert_eq!(width, 5 * 420); // 2100
620        assert_eq!(height, 9 * 560); // 5040
621    }
622
623    #[test]
624    fn test_view_to_grid() {
625        let config = HolographicConfig::looking_glass_portrait();
626
627        // View 0 should be at (0, 0)
628        let (col, row) = config.view_to_grid(0);
629        assert_eq!(col, 0);
630        assert_eq!(row, 0);
631
632        // View 4 should be at (4, 0) - last column of first row
633        let (col, row) = config.view_to_grid(4);
634        assert_eq!(col, 4);
635        assert_eq!(row, 0);
636
637        // View 5 should be at (0, 1) - first column of second row
638        let (col, row) = config.view_to_grid(5);
639        assert_eq!(col, 0);
640        assert_eq!(row, 1);
641
642        // View 44 should be at (4, 8) - last view
643        let (col, row) = config.view_to_grid(44);
644        assert_eq!(col, 4);
645        assert_eq!(row, 8);
646    }
647
648    #[test]
649    fn test_view_offset() {
650        let config = HolographicConfig::looking_glass_portrait();
651
652        // View 0 starts at (0, 0)
653        let (x, y) = config.view_offset(0);
654        assert_eq!(x, 0);
655        assert_eq!(y, 0);
656
657        // View 1 starts at (420, 0)
658        let (x, y) = config.view_offset(1);
659        assert_eq!(x, 420);
660        assert_eq!(y, 0);
661
662        // View 5 starts at (0, 560) - second row
663        let (x, y) = config.view_offset(5);
664        assert_eq!(x, 0);
665        assert_eq!(y, 560);
666    }
667
668    #[test]
669    fn test_camera_for_view_single() {
670        let config = HolographicConfig {
671            num_views: 1,
672            ..Default::default()
673        };
674        let base = Camera::new();
675        let view_cam = config.camera_for_view(&base, 0);
676
677        // Single view should return the same camera
678        assert!(approx_eq(view_cam.position.x, base.position.x));
679        assert!(approx_eq(view_cam.position.y, base.position.y));
680        assert!(approx_eq(view_cam.position.z, base.position.z));
681    }
682
683    #[test]
684    fn test_camera_for_view_center() {
685        let config = HolographicConfig::looking_glass_portrait();
686        let base = Camera::new();
687
688        // Middle view (22) should be approximately at the center
689        let center_cam = config.camera_for_view(&base, 22);
690
691        // Center camera should have similar position to base
692        // (small offset due to discrete view count)
693        let distance_from_base = center_cam.position.sub(&base.position).length();
694        assert!(distance_from_base < 0.5);
695    }
696
697    #[test]
698    fn test_camera_for_view_symmetry() {
699        let config = HolographicConfig::looking_glass_portrait();
700        let base = Camera::new();
701
702        // First and last views should be symmetric about center
703        let left_cam = config.camera_for_view(&base, 0);
704        let right_cam = config.camera_for_view(&base, 44);
705
706        // X positions should be opposite
707        assert!(approx_eq(left_cam.position.x, -right_cam.position.x));
708        // Y positions should be the same
709        assert!(approx_eq(left_cam.position.y, right_cam.position.y));
710    }
711
712    #[test]
713    fn test_camera_for_view_maintains_distance() {
714        let config = HolographicConfig::looking_glass_portrait();
715        let base = Camera::new();
716        let base_distance = base.position.sub(&base.target).length();
717
718        // All views should maintain the same distance to target
719        for i in 0..config.num_views {
720            let view_cam = config.camera_for_view(&base, i);
721            let view_distance = view_cam.position.sub(&view_cam.target).length();
722            assert!(
723                approx_eq(view_distance, base_distance),
724                "View {i} distance {view_distance} != base {base_distance}"
725            );
726        }
727    }
728
729    #[test]
730    fn test_camera_for_view_progression() {
731        let config = HolographicConfig::looking_glass_portrait();
732        let base = Camera::new();
733
734        // Camera X position should increase from left to right
735        let mut prev_x = f32::NEG_INFINITY;
736        for i in 0..config.num_views {
737            let view_cam = config.camera_for_view(&base, i);
738            assert!(
739                view_cam.position.x > prev_x,
740                "View {} x {} should be > {}",
741                i,
742                view_cam.position.x,
743                prev_x
744            );
745            prev_x = view_cam.position.x;
746        }
747    }
748
749    #[test]
750    fn test_camera_for_view_edge_angles() {
751        let config = HolographicConfig::looking_glass_portrait();
752        let base = Camera::new();
753
754        // With base camera at (0, 0, 5) looking at origin,
755        // edge views should be at half the view cone angle
756        let left_cam = config.camera_for_view(&base, 0);
757        let right_cam = config.camera_for_view(&base, 44);
758
759        // Calculate actual angles from camera positions
760        let left_angle = left_cam.position.x.atan2(left_cam.position.z);
761        let right_angle = right_cam.position.x.atan2(right_cam.position.z);
762
763        // Total angle span should match view_cone
764        let total_span = right_angle - left_angle;
765        let expected_span = config.view_cone;
766        assert!(
767            (total_span - expected_span).abs() < 0.01,
768            "View cone span {total_span:.4} should match config {expected_span:.4}"
769        );
770    }
771
772    #[test]
773    fn test_camera_for_view_all_point_to_target() {
774        let config = HolographicConfig::looking_glass_portrait();
775        let base = Camera::new();
776
777        // Every generated camera should point at the same target
778        for i in 0..config.num_views {
779            let view_cam = config.camera_for_view(&base, i);
780            assert!(
781                approx_eq(view_cam.target.x, base.target.x),
782                "View {} target.x {} should equal base {}",
783                i,
784                view_cam.target.x,
785                base.target.x
786            );
787            assert!(
788                approx_eq(view_cam.target.y, base.target.y),
789                "View {} target.y {} should equal base {}",
790                i,
791                view_cam.target.y,
792                base.target.y
793            );
794            assert!(
795                approx_eq(view_cam.target.z, base.target.z),
796                "View {} target.z {} should equal base {}",
797                i,
798                view_cam.target.z,
799                base.target.z
800            );
801        }
802    }
803
804    #[test]
805    fn test_camera_for_view_preserves_camera_params() {
806        let config = HolographicConfig::looking_glass_portrait();
807        let base = Camera {
808            position: Vec3::new(0.0, 0.0, 10.0),
809            target: Vec3::new(0.0, 0.0, 0.0),
810            up: Vec3::up(),
811            fov: 0.8,  // Custom FOV
812            near: 0.5, // Custom near
813            far: 50.0, // Custom far
814        };
815
816        // All generated cameras should preserve FOV, near, far, and up
817        for i in 0..config.num_views {
818            let view_cam = config.camera_for_view(&base, i);
819            assert!(approx_eq(view_cam.fov, base.fov), "View {i} FOV mismatch");
820            assert!(
821                approx_eq(view_cam.near, base.near),
822                "View {i} near mismatch"
823            );
824            assert!(approx_eq(view_cam.far, base.far), "View {i} far mismatch");
825            assert!(
826                approx_eq(view_cam.up.x, base.up.x)
827                    && approx_eq(view_cam.up.y, base.up.y)
828                    && approx_eq(view_cam.up.z, base.up.z),
829                "View {i} up vector mismatch"
830            );
831        }
832    }
833
834    #[test]
835    fn test_camera_for_view_4k_config() {
836        let config = HolographicConfig::looking_glass_4k();
837        let base = Camera::new();
838
839        // Same tests as portrait - verify 4K config also works
840        assert_eq!(config.num_views, 45);
841
842        // Center view should be near base
843        let center_cam = config.camera_for_view(&base, 22);
844        let distance_from_base = center_cam.position.sub(&base.position).length();
845        assert!(
846            distance_from_base < 0.5,
847            "4K center view should be near base"
848        );
849
850        // Symmetry check
851        let left_cam = config.camera_for_view(&base, 0);
852        let right_cam = config.camera_for_view(&base, 44);
853        assert!(
854            approx_eq(left_cam.position.x, -right_cam.position.x),
855            "4K edge views should be symmetric"
856        );
857
858        // View cone should match
859        let left_angle = left_cam.position.x.atan2(left_cam.position.z);
860        let right_angle = right_cam.position.x.atan2(right_cam.position.z);
861        let total_span = right_angle - left_angle;
862        assert!(
863            (total_span - config.view_cone).abs() < 0.01,
864            "4K view cone should match config"
865        );
866    }
867
868    #[test]
869    fn test_camera_for_view_custom_target() {
870        let config = HolographicConfig::looking_glass_portrait();
871        let base = Camera {
872            position: Vec3::new(5.0, 2.0, 8.0),
873            target: Vec3::new(5.0, 2.0, 0.0), // Offset target
874            up: Vec3::up(),
875            fov: std::f32::consts::FRAC_PI_4,
876            near: 0.1,
877            far: 100.0,
878        };
879
880        let base_distance = base.position.sub(&base.target).length();
881
882        // All views should maintain distance to custom target
883        for i in 0..config.num_views {
884            let view_cam = config.camera_for_view(&base, i);
885            let view_distance = view_cam.position.sub(&view_cam.target).length();
886            assert!(
887                approx_eq(view_distance, base_distance),
888                "View {i} distance {view_distance} should match base {base_distance}"
889            );
890            // Target should be unchanged
891            assert!(approx_eq(view_cam.target.x, base.target.x));
892            assert!(approx_eq(view_cam.target.y, base.target.y));
893            assert!(approx_eq(view_cam.target.z, base.target.z));
894        }
895    }
896
897    #[test]
898    fn test_camera_for_view_zero_views() {
899        // Edge case: zero views should return base camera
900        let config = HolographicConfig {
901            num_views: 0,
902            ..Default::default()
903        };
904        let base = Camera::new();
905        let view_cam = config.camera_for_view(&base, 0);
906
907        // With 0 views, should return base (num_views <= 1 branch)
908        assert!(approx_eq(view_cam.position.x, base.position.x));
909        assert!(approx_eq(view_cam.position.y, base.position.y));
910        assert!(approx_eq(view_cam.position.z, base.position.z));
911    }
912
913    // ===========================================
914    // TDD: QuiltRenderInfo Tests
915    // ===========================================
916
917    #[test]
918    fn test_quilt_render_info_from_config() {
919        let config = HolographicConfig::looking_glass_portrait();
920        let info = QuiltRenderInfo::from_config(&config);
921
922        assert_eq!(info.width, 2100);
923        assert_eq!(info.height, 5040);
924        assert_eq!(info.num_views, 45);
925        assert_eq!(info.columns, 5);
926        assert_eq!(info.rows, 9);
927    }
928}