Skip to main content

viewport_lib/camera/
camera.rs

1/// A snapshot of camera position state (center, distance, orientation).
2///
3/// Returned by framing helpers such as [`Camera::fit_sphere_target`] and
4/// [`Camera::fit_aabb_target`]. Useful for presets and animation targets: pass
5/// `target.center` / `target.distance` / `target.orientation` to
6/// [`CameraAnimator::fly_to`](crate::camera::animator::CameraAnimator::fly_to).
7///
8/// The getters/setters on [`Camera`] (`center()`, `distance()`,
9/// `orientation()`) are forward-compatible accessors intended for eventual
10/// field privatization (Phase 4). Field access (`camera.center` etc.) still
11/// works and is not deprecated.
12#[derive(Clone, Copy, Debug)]
13pub struct CameraTarget {
14    /// Orbit target point in world space.
15    pub center: glam::Vec3,
16    /// Distance from center to eye.
17    pub distance: f32,
18    /// Camera orientation as a unit quaternion.
19    pub orientation: glam::Quat,
20}
21
22/// Projection mode for the viewport camera.
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24#[non_exhaustive]
25pub enum Projection {
26    /// Perspective projection — objects farther away appear smaller.
27    Perspective,
28    /// Orthographic projection — no foreshortening, parallel lines stay parallel.
29    Orthographic,
30}
31
32/// Arcball camera for the 3D viewport.
33///
34/// The camera orbits around a center point. `orientation` is a quaternion that
35/// maps camera-space axes to world-space: eye = center + orientation * (Z * distance).
36/// Pan translates the center in camera-space. Zoom adjusts the distance.
37///
38/// Using a quaternion avoids gimbal lock and allows full 360° orbit in any direction.
39/// All matrices are computed right-handed (wgpu NDC convention).
40#[derive(Clone)]
41pub struct Camera {
42    /// Projection mode (perspective or orthographic).
43    pub projection: Projection,
44    /// Orbit target point in world space.
45    pub center: glam::Vec3,
46    /// Distance from center to eye (zoom).
47    pub distance: f32,
48    /// Camera orientation as a unit quaternion.
49    /// eye = center + orientation * (Vec3::Z * distance).
50    /// Identity = looking along -Z from +Z position (standard front view).
51    pub orientation: glam::Quat,
52    /// Vertical field of view in radians.
53    pub fov_y: f32,
54    /// Viewport width / height ratio — updated each frame from viewport rect.
55    pub aspect: f32,
56    /// Near clipping plane distance.
57    pub znear: f32,
58    /// Far clipping plane distance.
59    pub zfar: f32,
60}
61
62impl Default for Camera {
63    fn default() -> Self {
64        Self {
65            projection: Projection::Perspective,
66            center: glam::Vec3::ZERO,
67            distance: 5.0,
68            // Default to a slight top-down view above the x-y plane.
69            orientation: glam::Quat::from_rotation_y(0.3) * glam::Quat::from_rotation_x(-0.3),
70            fov_y: std::f32::consts::FRAC_PI_4,
71            aspect: 1.5,
72            znear: 0.01,
73            zfar: 1000.0,
74        }
75    }
76}
77
78impl Camera {
79    /// Minimum allowed camera distance (zoom limit).
80    pub const MIN_DISTANCE: f32 = 0.01;
81
82    /// Maximum allowed camera distance (zoom limit).
83    pub const MAX_DISTANCE: f32 = 1.0e6;
84
85    // -----------------------------------------------------------------------
86    // Getters and setters
87    // -----------------------------------------------------------------------
88
89    /// Return the orbit target center in world space.
90    ///
91    /// Forward-compatible accessor — equivalent to reading `self.center`.
92    pub fn center(&self) -> glam::Vec3 {
93        self.center
94    }
95
96    /// Set the orbit target center in world space.
97    pub fn set_center(&mut self, center: glam::Vec3) {
98        self.center = center;
99    }
100
101    /// Return the camera distance (zoom).
102    ///
103    /// Forward-compatible accessor — equivalent to reading `self.distance`.
104    pub fn distance(&self) -> f32 {
105        self.distance
106    }
107
108    /// Set the camera distance, clamped to `[MIN_DISTANCE, MAX_DISTANCE]`.
109    pub fn set_distance(&mut self, d: f32) {
110        self.distance = d.clamp(Self::MIN_DISTANCE, Self::MAX_DISTANCE);
111    }
112
113    /// Return the camera orientation quaternion.
114    ///
115    /// Forward-compatible accessor — equivalent to reading `self.orientation`.
116    pub fn orientation(&self) -> glam::Quat {
117        self.orientation
118    }
119
120    /// Set the camera orientation, normalizing the quaternion.
121    pub fn set_orientation(&mut self, q: glam::Quat) {
122        self.orientation = q.normalize();
123    }
124
125    /// Set the vertical field of view in radians.
126    pub fn set_fov_y(&mut self, fov_y: f32) {
127        self.fov_y = fov_y;
128    }
129
130    /// Set the aspect ratio from pixel dimensions.
131    ///
132    /// If `height` is zero or negative, aspect is set to `1.0`.
133    pub fn set_aspect_ratio(&mut self, width: f32, height: f32) {
134        self.aspect = if height > 0.0 { width / height } else { 1.0 };
135    }
136
137    /// Set the near and far clipping plane distances.
138    pub fn set_clip_planes(&mut self, znear: f32, zfar: f32) {
139        self.znear = znear;
140        self.zfar = zfar;
141    }
142
143    // -----------------------------------------------------------------------
144    // Operation methods
145    // -----------------------------------------------------------------------
146
147    /// Orbit the camera by `yaw` and `pitch` radians.
148    ///
149    /// Applies `Quat::from_rotation_y(-yaw) * orientation * Quat::from_rotation_x(-pitch)`.
150    /// The sign convention matches all examples: positive yaw rotates counter-clockwise
151    /// when viewed from above, positive pitch tilts up.
152    pub fn orbit(&mut self, yaw: f32, pitch: f32) {
153        self.orientation = (glam::Quat::from_rotation_y(-yaw)
154            * self.orientation
155            * glam::Quat::from_rotation_x(-pitch))
156        .normalize();
157    }
158
159    /// Pan the camera by world-space deltas.
160    ///
161    /// `right_delta` subtracts from center along the camera right vector;
162    /// `up_delta` adds to center along the camera up vector. This sign
163    /// convention matches mouse pan: dragging right moves the scene left.
164    pub fn pan_world(&mut self, right_delta: f32, up_delta: f32) {
165        self.center -= self.right() * right_delta;
166        self.center += self.up() * up_delta;
167    }
168
169    /// Pan the camera by pixel-space deltas.
170    ///
171    /// Computes `pan_scale = 2 * distance * tan(fov_y/2) / viewport_height`
172    /// then delegates to [`pan_world`](Self::pan_world).
173    pub fn pan_pixels(&mut self, delta_pixels: glam::Vec2, viewport_height: f32) {
174        let pan_scale = 2.0 * self.distance * (self.fov_y / 2.0).tan()
175            / viewport_height.max(1.0);
176        self.pan_world(delta_pixels.x * pan_scale, delta_pixels.y * pan_scale);
177    }
178
179    /// Zoom by multiplying the distance by `factor`, clamped to `[MIN_DISTANCE, MAX_DISTANCE]`.
180    pub fn zoom_by_factor(&mut self, factor: f32) {
181        self.distance = (self.distance * factor).clamp(Self::MIN_DISTANCE, Self::MAX_DISTANCE);
182    }
183
184    /// Zoom by adding `delta` to the distance, clamped to `[MIN_DISTANCE, MAX_DISTANCE]`.
185    pub fn zoom_by_delta(&mut self, delta: f32) {
186        self.distance = (self.distance + delta).clamp(Self::MIN_DISTANCE, Self::MAX_DISTANCE);
187    }
188
189    // -----------------------------------------------------------------------
190    // Framing helpers
191    // -----------------------------------------------------------------------
192
193    /// Frame the camera to contain a bounding sphere, applying the result directly.
194    pub fn frame_sphere(&mut self, center: glam::Vec3, radius: f32) {
195        let (c, d) = self.fit_sphere(center, radius);
196        self.center = c;
197        self.set_distance(d);
198    }
199
200    /// Frame the camera to contain an AABB, applying the result directly.
201    pub fn frame_aabb(&mut self, aabb: &crate::scene::aabb::Aabb) {
202        let (c, d) = self.fit_aabb(aabb);
203        self.center = c;
204        self.set_distance(d);
205    }
206
207    /// Compute a [`CameraTarget`] that would frame a bounding sphere,
208    /// preserving the current orientation.
209    pub fn fit_sphere_target(&self, center: glam::Vec3, radius: f32) -> CameraTarget {
210        let (c, d) = self.fit_sphere(center, radius);
211        CameraTarget {
212            center: c,
213            distance: d,
214            orientation: self.orientation,
215        }
216    }
217
218    /// Compute a [`CameraTarget`] that would frame an AABB,
219    /// preserving the current orientation.
220    pub fn fit_aabb_target(&self, aabb: &crate::scene::aabb::Aabb) -> CameraTarget {
221        let (c, d) = self.fit_aabb(aabb);
222        CameraTarget {
223            center: c,
224            distance: d,
225            orientation: self.orientation,
226        }
227    }
228
229    // -----------------------------------------------------------------------
230    // Internal helpers
231    // -----------------------------------------------------------------------
232
233    /// Compute the eye offset from the orbit center in world space.
234    fn eye_offset(&self) -> glam::Vec3 {
235        self.orientation * (glam::Vec3::Z * self.distance)
236    }
237
238    /// Eye (camera) position in world space.
239    pub fn eye_position(&self) -> glam::Vec3 {
240        self.center + self.eye_offset()
241    }
242
243    /// Right-handed view matrix (world -> camera space).
244    pub fn view_matrix(&self) -> glam::Mat4 {
245        let eye = self.eye_position();
246        let up = self.orientation * glam::Vec3::Y;
247        glam::Mat4::look_at_rh(eye, self.center, up)
248    }
249
250    /// Right-handed projection matrix (wgpu depth 0..1).
251    ///
252    /// In orthographic mode the viewing volume is derived from `distance` and
253    /// `fov_y` so that switching between perspective and orthographic at the
254    /// same distance produces a similar framing.
255    pub fn proj_matrix(&self) -> glam::Mat4 {
256        // Ensure the far plane always extends past the orbit center regardless
257        // of how far the user has zoomed out. zfar is a minimum; if the camera
258        // distance exceeds it the stored value is stale.
259        let effective_zfar = self.zfar.max(self.distance * 3.0);
260        match self.projection {
261            Projection::Perspective => {
262                glam::Mat4::perspective_rh(self.fov_y, self.aspect, self.znear, effective_zfar)
263            }
264            Projection::Orthographic => {
265                let half_h = self.distance * (self.fov_y / 2.0).tan();
266                let half_w = half_h * self.aspect;
267                glam::Mat4::orthographic_rh(
268                    -half_w,
269                    half_w,
270                    -half_h,
271                    half_h,
272                    self.znear,
273                    effective_zfar,
274                )
275            }
276        }
277    }
278
279    /// Combined view-projection matrix for use in the camera uniform buffer.
280    /// Note: projection * view (column-major order: proj applied after view).
281    pub fn view_proj_matrix(&self) -> glam::Mat4 {
282        self.proj_matrix() * self.view_matrix()
283    }
284
285    /// Camera right vector in world space (used for pan).
286    pub fn right(&self) -> glam::Vec3 {
287        self.orientation * glam::Vec3::X
288    }
289
290    /// Camera up vector in world space (used for pan).
291    pub fn up(&self) -> glam::Vec3 {
292        self.orientation * glam::Vec3::Y
293    }
294
295    /// Extract the view frustum from the current view-projection matrix.
296    pub fn frustum(&self) -> crate::camera::frustum::Frustum {
297        crate::camera::frustum::Frustum::from_view_proj(&self.view_proj_matrix())
298    }
299
300    /// Compute `(center, distance)` that would frame a bounding sphere in view,
301    /// preserving the current orientation.
302    ///
303    /// A 1.2× padding factor is applied so the object doesn't touch the edges.
304    pub fn fit_sphere(&self, center: glam::Vec3, radius: f32) -> (glam::Vec3, f32) {
305        let distance = match self.projection {
306            Projection::Perspective => radius / (self.fov_y / 2.0).tan() * 1.2,
307            Projection::Orthographic => radius * 1.2,
308        };
309        (center, distance)
310    }
311
312    /// Compute `(center, distance)` that would frame an AABB in view,
313    /// preserving the current orientation.
314    ///
315    /// Converts the AABB to a bounding sphere and delegates to [`fit_sphere`](Self::fit_sphere).
316    pub fn fit_aabb(&self, aabb: &crate::scene::aabb::Aabb) -> (glam::Vec3, f32) {
317        let center = aabb.center();
318        let radius = aabb.half_extents().length();
319        self.fit_sphere(center, radius)
320    }
321
322    /// Center the camera on a domain of the given extents, adjusting distance
323    /// and clipping planes so the entire domain is visible.
324    pub fn center_on_domain(&mut self, nx: f32, ny: f32, nz: f32) {
325        self.center = glam::Vec3::new(nx / 2.0, ny / 2.0, nz / 2.0);
326        let diagonal = (nx * nx + ny * ny + nz * nz).sqrt();
327        self.distance = (diagonal / 2.0) / (self.fov_y / 2.0).tan() * 1.2;
328        self.znear = (diagonal * 0.0001).max(0.01);
329        self.zfar = (diagonal * 10.0).max(1000.0);
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336
337    #[test]
338    fn test_default_eye_position() {
339        let cam = Camera::default();
340        let expected = cam.center + cam.orientation * (glam::Vec3::Z * cam.distance);
341        let eye = cam.eye_position();
342        assert!(
343            (eye - expected).length() < 1e-5,
344            "eye={eye:?} expected={expected:?}"
345        );
346    }
347
348    #[test]
349    fn test_view_matrix_looks_at_center() {
350        let cam = Camera::default();
351        let view = cam.view_matrix();
352        // Center in view space should be near the negative Z axis.
353        let center_view = view.transform_point3(cam.center);
354        // X and Y should be near zero; Z should be negative (in front of camera).
355        assert!(
356            center_view.x.abs() < 1e-4,
357            "center_view.x={}",
358            center_view.x
359        );
360        assert!(
361            center_view.y.abs() < 1e-4,
362            "center_view.y={}",
363            center_view.y
364        );
365        assert!(
366            center_view.z < 0.0,
367            "center should be in front of camera, z={}",
368            center_view.z
369        );
370    }
371
372    #[test]
373    fn test_view_proj_roundtrip() {
374        let cam = Camera::default();
375        let vp = cam.view_proj_matrix();
376        let vp_inv = vp.inverse();
377        // NDC center (0,0,0.5) should unproject to somewhere near the center.
378        let world_pt = vp_inv.project_point3(glam::Vec3::new(0.0, 0.0, 0.5));
379        // The unprojected point should lie along the camera-to-center ray.
380        let eye = cam.eye_position();
381        let to_center = (cam.center - eye).normalize();
382        let to_pt = (world_pt - eye).normalize();
383        let dot = to_center.dot(to_pt);
384        assert!(
385            dot > 0.99,
386            "dot={dot}, point should be along camera-to-center ray"
387        );
388    }
389
390    #[test]
391    fn test_center_on_domain() {
392        let mut cam = Camera::default();
393        cam.center_on_domain(10.0, 10.0, 10.0);
394        assert!((cam.center - glam::Vec3::splat(5.0)).length() < 1e-5);
395        assert!(cam.distance > 0.0);
396    }
397
398    #[test]
399    fn test_fit_sphere_perspective() {
400        let cam = Camera::default(); // perspective, fov_y = PI/4
401        let (center, dist) = cam.fit_sphere(glam::Vec3::ZERO, 5.0);
402        assert!((center - glam::Vec3::ZERO).length() < 1e-5);
403        let expected = 5.0 / (cam.fov_y / 2.0).tan() * 1.2;
404        assert!(
405            (dist - expected).abs() < 1e-4,
406            "dist={dist}, expected={expected}"
407        );
408    }
409
410    #[test]
411    fn test_fit_sphere_orthographic() {
412        let mut cam = Camera::default();
413        cam.projection = Projection::Orthographic;
414        let (_, dist) = cam.fit_sphere(glam::Vec3::ZERO, 5.0);
415        let expected = 5.0 * 1.2;
416        assert!(
417            (dist - expected).abs() < 1e-4,
418            "dist={dist}, expected={expected}"
419        );
420    }
421
422    #[test]
423    fn test_fit_aabb_unit_cube() {
424        let cam = Camera::default();
425        let aabb = crate::scene::aabb::Aabb {
426            min: glam::Vec3::splat(-0.5),
427            max: glam::Vec3::splat(0.5),
428        };
429        let (center, dist) = cam.fit_aabb(&aabb);
430        assert!(center.length() < 1e-5, "center should be origin");
431        assert!(dist > 0.0, "distance should be positive");
432        // Half-diagonal of unit cube = sqrt(0.75) ≈ 0.866
433        let radius = aabb.half_extents().length();
434        let expected = radius / (cam.fov_y / 2.0).tan() * 1.2;
435        assert!(
436            (dist - expected).abs() < 1e-4,
437            "dist={dist}, expected={expected}"
438        );
439    }
440
441    #[test]
442    fn test_fit_aabb_preserves_padding() {
443        let cam = Camera::default();
444        let aabb = crate::scene::aabb::Aabb {
445            min: glam::Vec3::splat(-2.0),
446            max: glam::Vec3::splat(2.0),
447        };
448        let (_, dist) = cam.fit_aabb(&aabb);
449        // Without padding: radius / tan(fov/2)
450        let radius = aabb.half_extents().length();
451        let no_pad = radius / (cam.fov_y / 2.0).tan();
452        assert!(
453            dist > no_pad,
454            "padded distance ({dist}) should exceed unpadded ({no_pad})"
455        );
456    }
457
458    #[test]
459    fn test_right_up_orthogonal() {
460        let cam = Camera::default();
461        let dot = cam.right().dot(cam.up());
462        assert!(
463            dot.abs() < 1e-5,
464            "right and up should be orthogonal, dot={dot}"
465        );
466    }
467
468    // -----------------------------------------------------------------------
469    // Tests for new methods (Task 1)
470    // -----------------------------------------------------------------------
471
472    #[test]
473    fn test_constants() {
474        assert!((Camera::MIN_DISTANCE - 0.01).abs() < 1e-7);
475        assert!((Camera::MAX_DISTANCE - 1.0e6).abs() < 1.0);
476    }
477
478    #[test]
479    fn test_set_distance_clamps() {
480        let mut cam = Camera::default();
481        cam.set_distance(-1.0);
482        assert!(
483            (cam.distance - Camera::MIN_DISTANCE).abs() < 1e-7,
484            "negative distance should clamp to MIN_DISTANCE"
485        );
486        cam.set_distance(2.0e6);
487        assert!(
488            (cam.distance - Camera::MAX_DISTANCE).abs() < 1.0,
489            "too-large distance should clamp to MAX_DISTANCE"
490        );
491    }
492
493    #[test]
494    fn test_set_center_and_getter() {
495        let mut cam = Camera::default();
496        let target = glam::Vec3::new(1.0, 2.0, 3.0);
497        cam.set_center(target);
498        assert_eq!(cam.center(), target);
499        assert_eq!(cam.center, target);
500    }
501
502    #[test]
503    fn test_set_distance_getter() {
504        let mut cam = Camera::default();
505        cam.set_distance(7.5);
506        assert!((cam.distance() - 7.5).abs() < 1e-6);
507    }
508
509    #[test]
510    fn test_set_orientation_normalizes() {
511        let mut cam = Camera::default();
512        // Supply a non-unit quaternion.
513        let q = glam::Quat::from_xyzw(0.0, 0.707, 0.0, 0.707) * 2.0;
514        cam.set_orientation(q);
515        let len = (cam.orientation.x * cam.orientation.x
516            + cam.orientation.y * cam.orientation.y
517            + cam.orientation.z * cam.orientation.z
518            + cam.orientation.w * cam.orientation.w)
519            .sqrt();
520        assert!((len - 1.0).abs() < 1e-5, "orientation should be normalized, len={len}");
521    }
522
523    #[test]
524    fn test_set_aspect_ratio_normal() {
525        let mut cam = Camera::default();
526        cam.set_aspect_ratio(800.0, 600.0);
527        let expected = 800.0 / 600.0;
528        assert!((cam.aspect - expected).abs() < 1e-5);
529    }
530
531    #[test]
532    fn test_set_aspect_ratio_zero_height() {
533        let mut cam = Camera::default();
534        cam.set_aspect_ratio(800.0, 0.0);
535        assert!((cam.aspect - 1.0).abs() < 1e-5, "zero height should produce aspect=1.0");
536    }
537
538    #[test]
539    fn test_set_fov_y() {
540        let mut cam = Camera::default();
541        cam.set_fov_y(1.2);
542        assert!((cam.fov_y - 1.2).abs() < 1e-6);
543    }
544
545    #[test]
546    fn test_set_clip_planes() {
547        let mut cam = Camera::default();
548        cam.set_clip_planes(0.1, 500.0);
549        assert!((cam.znear - 0.1).abs() < 1e-6);
550        assert!((cam.zfar - 500.0).abs() < 1e-4);
551    }
552
553    #[test]
554    fn test_orbit_matches_manual() {
555        let mut cam = Camera::default();
556        let orig_orientation = cam.orientation;
557        let yaw = 0.1_f32;
558        let pitch = 0.2_f32;
559        let expected = (glam::Quat::from_rotation_y(-yaw)
560            * orig_orientation
561            * glam::Quat::from_rotation_x(-pitch))
562        .normalize();
563        cam.orbit(yaw, pitch);
564        let diff = (cam.orientation - expected).length();
565        assert!(diff < 1e-5, "orbit() result mismatch, diff={diff}");
566    }
567
568    #[test]
569    fn test_pan_world_moves_center() {
570        let mut cam = Camera::default();
571        cam.orientation = glam::Quat::IDENTITY;
572        let right = cam.right();
573        let up = cam.up();
574        let orig_center = cam.center;
575        cam.pan_world(1.0, 0.5);
576        let expected = orig_center - right * 1.0 + up * 0.5;
577        assert!(
578            (cam.center - expected).length() < 1e-5,
579            "pan_world center mismatch"
580        );
581    }
582
583    #[test]
584    fn test_pan_pixels_uses_correct_scale() {
585        let mut cam = Camera::default();
586        cam.orientation = glam::Quat::IDENTITY;
587        cam.distance = 10.0;
588        let viewport_h = 600.0_f32;
589        let pan_scale = 2.0 * cam.distance * (cam.fov_y / 2.0).tan() / viewport_h;
590        let dx = 100.0_f32;
591        let dy = 50.0_f32;
592        let orig_center = cam.center;
593        let right = cam.right();
594        let up = cam.up();
595        cam.pan_pixels(glam::vec2(dx, dy), viewport_h);
596        let expected = orig_center - right * dx * pan_scale + up * dy * pan_scale;
597        assert!(
598            (cam.center - expected).length() < 1e-4,
599            "pan_pixels center mismatch"
600        );
601    }
602
603    #[test]
604    fn test_zoom_by_factor_clamps() {
605        let mut cam = Camera::default();
606        cam.distance = 1.0;
607        cam.zoom_by_factor(0.0);
608        assert!(
609            (cam.distance - Camera::MIN_DISTANCE).abs() < 1e-7,
610            "factor=0 should clamp to MIN_DISTANCE"
611        );
612
613        cam.distance = 1.0;
614        cam.zoom_by_factor(2.0e7);
615        assert!(
616            (cam.distance - Camera::MAX_DISTANCE).abs() < 1.0,
617            "large factor should clamp to MAX_DISTANCE"
618        );
619    }
620
621    #[test]
622    fn test_zoom_by_delta() {
623        let mut cam = Camera::default();
624        cam.distance = 5.0;
625        cam.zoom_by_delta(2.0);
626        assert!((cam.distance - 7.0).abs() < 1e-5);
627        cam.zoom_by_delta(-100.0);
628        assert!(
629            cam.distance >= Camera::MIN_DISTANCE,
630            "delta clamped to MIN_DISTANCE"
631        );
632    }
633
634    #[test]
635    fn test_frame_sphere_applies_result() {
636        let mut cam = Camera::default();
637        let sphere_center = glam::Vec3::new(1.0, 2.0, 3.0);
638        let radius = 5.0;
639        let (expected_c, expected_d) = cam.fit_sphere(sphere_center, radius);
640        cam.frame_sphere(sphere_center, radius);
641        assert!((cam.center - expected_c).length() < 1e-5);
642        assert!((cam.distance - expected_d.clamp(Camera::MIN_DISTANCE, Camera::MAX_DISTANCE)).abs() < 1e-5);
643    }
644
645    #[test]
646    fn test_frame_aabb_applies_result() {
647        let mut cam = Camera::default();
648        let aabb = crate::scene::aabb::Aabb {
649            min: glam::Vec3::splat(-1.0),
650            max: glam::Vec3::splat(1.0),
651        };
652        let (expected_c, expected_d) = cam.fit_aabb(&aabb);
653        cam.frame_aabb(&aabb);
654        assert!((cam.center - expected_c).length() < 1e-5);
655        assert!((cam.distance - expected_d.clamp(Camera::MIN_DISTANCE, Camera::MAX_DISTANCE)).abs() < 1e-5);
656    }
657
658    #[test]
659    fn test_fit_sphere_target() {
660        let cam = Camera::default();
661        let sphere_center = glam::Vec3::new(0.0, 1.0, 0.0);
662        let radius = 2.0;
663        let target = cam.fit_sphere_target(sphere_center, radius);
664        let (expected_c, expected_d) = cam.fit_sphere(sphere_center, radius);
665        assert!((target.center - expected_c).length() < 1e-5);
666        assert!((target.distance - expected_d).abs() < 1e-5);
667        // Orientation should be preserved.
668        let diff = (target.orientation - cam.orientation).length();
669        assert!(diff < 1e-5, "fit_sphere_target should preserve orientation");
670    }
671
672    #[test]
673    fn test_fit_aabb_target() {
674        let cam = Camera::default();
675        let aabb = crate::scene::aabb::Aabb {
676            min: glam::Vec3::splat(-2.0),
677            max: glam::Vec3::splat(2.0),
678        };
679        let target = cam.fit_aabb_target(&aabb);
680        let (expected_c, expected_d) = cam.fit_aabb(&aabb);
681        assert!((target.center - expected_c).length() < 1e-5);
682        assert!((target.distance - expected_d).abs() < 1e-5);
683        let diff = (target.orientation - cam.orientation).length();
684        assert!(diff < 1e-5, "fit_aabb_target should preserve orientation");
685    }
686}