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