Skip to main content

polyscope_render/
camera.rs

1//! Camera and view management.
2
3use glam::{Mat3, Mat4, Quat, Vec3};
4use std::time::Instant;
5
6/// Camera navigation/interaction style.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
8pub enum NavigationStyle {
9    /// Turntable - orbits around target, constrained to up direction.
10    #[default]
11    Turntable,
12    /// Free - unconstrained rotation using camera-local axes.
13    Free,
14    /// Planar - 2D panning only, no rotation.
15    Planar,
16    /// Arcball - sphere-mapped rotation (virtual trackball).
17    Arcball,
18    /// First person - mouse look + WASD movement.
19    FirstPerson,
20    /// None - all camera controls disabled.
21    None,
22}
23
24impl From<u32> for NavigationStyle {
25    fn from(v: u32) -> Self {
26        match v {
27            0 => Self::Turntable,
28            1 => Self::Free,
29            2 => Self::Planar,
30            3 => Self::Arcball,
31            4 => Self::FirstPerson,
32            _ => Self::None,
33        }
34    }
35}
36
37impl From<NavigationStyle> for u32 {
38    fn from(v: NavigationStyle) -> Self {
39        match v {
40            NavigationStyle::Turntable => 0,
41            NavigationStyle::Free => 1,
42            NavigationStyle::Planar => 2,
43            NavigationStyle::Arcball => 3,
44            NavigationStyle::FirstPerson => 4,
45            NavigationStyle::None => 5,
46        }
47    }
48}
49
50/// Camera projection mode.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
52pub enum ProjectionMode {
53    /// Perspective projection.
54    #[default]
55    Perspective,
56    /// Orthographic projection.
57    Orthographic,
58}
59
60impl From<u32> for ProjectionMode {
61    fn from(v: u32) -> Self {
62        match v {
63            0 => Self::Perspective,
64            _ => Self::Orthographic,
65        }
66    }
67}
68
69impl From<ProjectionMode> for u32 {
70    fn from(v: ProjectionMode) -> Self {
71        match v {
72            ProjectionMode::Perspective => 0,
73            ProjectionMode::Orthographic => 1,
74        }
75    }
76}
77
78/// Axis direction for up/front vectors.
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
80pub enum AxisDirection {
81    /// Positive X axis.
82    PosX,
83    /// Negative X axis.
84    NegX,
85    /// Positive Y axis (default up).
86    #[default]
87    PosY,
88    /// Negative Y axis.
89    NegY,
90    /// Positive Z axis.
91    PosZ,
92    /// Negative Z axis (default front).
93    NegZ,
94}
95
96impl From<u32> for AxisDirection {
97    fn from(v: u32) -> Self {
98        match v {
99            0 => Self::PosX,
100            1 => Self::NegX,
101            2 => Self::PosY,
102            3 => Self::NegY,
103            4 => Self::PosZ,
104            _ => Self::NegZ,
105        }
106    }
107}
108
109impl From<AxisDirection> for u32 {
110    fn from(v: AxisDirection) -> Self {
111        match v {
112            AxisDirection::PosX => 0,
113            AxisDirection::NegX => 1,
114            AxisDirection::PosY => 2,
115            AxisDirection::NegY => 3,
116            AxisDirection::PosZ => 4,
117            AxisDirection::NegZ => 5,
118        }
119    }
120}
121
122impl AxisDirection {
123    /// Returns the unit vector for this direction.
124    #[must_use]
125    pub fn to_vec3(self) -> Vec3 {
126        match self {
127            AxisDirection::PosX => Vec3::X,
128            AxisDirection::NegX => Vec3::NEG_X,
129            AxisDirection::PosY => Vec3::Y,
130            AxisDirection::NegY => Vec3::NEG_Y,
131            AxisDirection::PosZ => Vec3::Z,
132            AxisDirection::NegZ => Vec3::NEG_Z,
133        }
134    }
135
136    /// Returns display name.
137    #[must_use]
138    pub fn name(self) -> &'static str {
139        match self {
140            AxisDirection::PosX => "+X",
141            AxisDirection::NegX => "-X",
142            AxisDirection::PosY => "+Y",
143            AxisDirection::NegY => "-Y",
144            AxisDirection::PosZ => "+Z",
145            AxisDirection::NegZ => "-Z",
146        }
147    }
148
149    /// Returns the corresponding front direction for this up direction.
150    /// Follows right-hand coordinate system conventions:
151    /// - +Y up → -Z front (standard graphics convention)
152    /// - -Y up → +Z front
153    /// - +Z up → +X front (CAD/engineering convention)
154    /// - -Z up → -X front
155    /// - +X up → +Y front
156    /// - -X up → -Y front
157    #[must_use]
158    pub fn default_front_direction(self) -> AxisDirection {
159        match self {
160            AxisDirection::PosY => AxisDirection::NegZ,
161            AxisDirection::NegY => AxisDirection::PosZ,
162            AxisDirection::PosZ => AxisDirection::PosX,
163            AxisDirection::NegZ => AxisDirection::NegX,
164            AxisDirection::PosX => AxisDirection::PosY,
165            AxisDirection::NegX => AxisDirection::NegY,
166        }
167    }
168
169    /// Converts from a u32 index (used in UI) to `AxisDirection`.
170    /// Order: 0=+X, 1=-X, 2=+Y, 3=-Y, 4=+Z, 5=-Z
171    #[must_use]
172    #[allow(clippy::match_same_arms)] // 2 and _ both map to PosY (default) intentionally
173    pub fn from_index(index: u32) -> Self {
174        match index {
175            0 => AxisDirection::PosX,
176            1 => AxisDirection::NegX,
177            2 => AxisDirection::PosY,
178            3 => AxisDirection::NegY,
179            4 => AxisDirection::PosZ,
180            5 => AxisDirection::NegZ,
181            _ => AxisDirection::PosY, // Default
182        }
183    }
184
185    /// Converts to a u32 index (used in UI).
186    #[must_use]
187    pub fn to_index(self) -> u32 {
188        match self {
189            AxisDirection::PosX => 0,
190            AxisDirection::NegX => 1,
191            AxisDirection::PosY => 2,
192            AxisDirection::NegY => 3,
193            AxisDirection::PosZ => 4,
194            AxisDirection::NegZ => 5,
195        }
196    }
197}
198
199/// State for an in-progress camera flight animation.
200///
201/// Stores start and end view matrices decomposed into rotation quaternion +
202/// translation, matching C++ Polyscope's `startFlightTo()` / `updateFlight()`.
203/// The C++ code uses `glm::dualquat` but only for the rotation part (dual=0),
204/// so this is effectively quaternion lerp for rotation + linear lerp for
205/// translation, both with smoothstep easing.
206#[derive(Debug, Clone)]
207pub struct CameraFlight {
208    start_time: Instant,
209    duration_secs: f32,
210    /// Start view matrix rotation as quaternion.
211    initial_rot: Quat,
212    /// End view matrix rotation as quaternion.
213    target_rot: Quat,
214    /// Start view matrix translation component.
215    initial_t: Vec3,
216    /// End view matrix translation component.
217    target_t: Vec3,
218    /// Start FOV in radians.
219    initial_fov: f32,
220    /// End FOV in radians.
221    target_fov: f32,
222    /// Camera-to-target distance (preserved throughout flight).
223    target_dist: f32,
224}
225
226/// A 3D camera for viewing the scene.
227#[derive(Debug, Clone)]
228pub struct Camera {
229    /// Camera position in world space.
230    pub position: Vec3,
231    /// Point the camera is looking at.
232    pub target: Vec3,
233    /// Up vector.
234    pub up: Vec3,
235    /// Field of view in radians.
236    pub fov: f32,
237    /// Aspect ratio (width / height).
238    pub aspect_ratio: f32,
239    /// Near clipping plane.
240    pub near: f32,
241    /// Far clipping plane.
242    pub far: f32,
243    /// Navigation style.
244    pub navigation_style: NavigationStyle,
245    /// Projection mode.
246    pub projection_mode: ProjectionMode,
247    /// Up direction.
248    pub up_direction: AxisDirection,
249    /// Front direction.
250    pub front_direction: AxisDirection,
251    /// Movement speed multiplier.
252    pub move_speed: f32,
253    /// Orthographic scale (used when `projection_mode` is Orthographic).
254    pub ortho_scale: f32,
255    /// Active camera flight animation (if any).
256    pub flight: Option<CameraFlight>,
257}
258
259impl Camera {
260    /// Creates a new camera with default settings.
261    #[must_use]
262    pub fn new(aspect_ratio: f32) -> Self {
263        Self {
264            position: Vec3::new(0.0, 0.0, 3.0),
265            target: Vec3::ZERO,
266            up: Vec3::Y,
267            fov: std::f32::consts::FRAC_PI_4, // 45 degrees
268            aspect_ratio,
269            near: 0.01,
270            far: 1000.0,
271            navigation_style: NavigationStyle::Turntable,
272            projection_mode: ProjectionMode::Perspective,
273            up_direction: AxisDirection::PosY,
274            front_direction: AxisDirection::NegZ,
275            move_speed: 1.0,
276            ortho_scale: 1.0,
277            flight: None,
278        }
279    }
280
281    /// Sets the aspect ratio.
282    pub fn set_aspect_ratio(&mut self, aspect_ratio: f32) {
283        self.aspect_ratio = aspect_ratio;
284    }
285
286    /// Returns the view matrix.
287    #[must_use]
288    pub fn view_matrix(&self) -> Mat4 {
289        Mat4::look_at_rh(self.position, self.target, self.up)
290    }
291
292    /// Returns the projection matrix.
293    #[must_use]
294    pub fn projection_matrix(&self) -> Mat4 {
295        match self.projection_mode {
296            ProjectionMode::Perspective => {
297                Mat4::perspective_rh(self.fov, self.aspect_ratio, self.near, self.far)
298            }
299            ProjectionMode::Orthographic => {
300                let half_height = self.ortho_scale;
301                let half_width = half_height * self.aspect_ratio;
302                // For orthographic, we need a much larger depth range to avoid clipping.
303                // The camera may be far from the scene, but we want to see everything
304                // around the target point. Use a symmetric range centered on the
305                // camera-to-target distance.
306                let dist = (self.position - self.target).length();
307                // Near plane should be negative relative to target to see objects
308                // between camera and target. We use a large range to avoid clipping.
309                let ortho_depth = (dist + self.far).max(self.ortho_scale * 100.0);
310                Mat4::orthographic_rh(
311                    -half_width,
312                    half_width,
313                    -half_height,
314                    half_height,
315                    -ortho_depth, // Negative near to see behind focus point
316                    ortho_depth,
317                )
318            }
319        }
320    }
321
322    /// Returns the combined view-projection matrix.
323    #[must_use]
324    pub fn view_projection_matrix(&self) -> Mat4 {
325        self.projection_matrix() * self.view_matrix()
326    }
327
328    /// Returns the camera's forward direction.
329    #[must_use]
330    pub fn forward(&self) -> Vec3 {
331        (self.target - self.position).normalize()
332    }
333
334    /// Returns the camera's right direction.
335    #[must_use]
336    pub fn right(&self) -> Vec3 {
337        self.forward().cross(self.up).normalize()
338    }
339
340    /// Returns the camera's local up direction (from the view matrix).
341    #[must_use]
342    pub fn camera_up(&self) -> Vec3 {
343        let view = self.view_matrix();
344        let r = Mat3::from_cols(
345            view.x_axis.truncate(),
346            view.y_axis.truncate(),
347            view.z_axis.truncate(),
348        );
349        r.transpose() * Vec3::Y
350    }
351
352    // ========================================================================
353    // Per-mode orbit/rotation methods
354    // ========================================================================
355
356    /// Turntable orbit: yaw around world-space up, pitch around camera-space
357    /// right, with gimbal-lock protection. Always looks at target.
358    ///
359    /// Matches C++ Polyscope `processRotate` for `NavigateStyle::Turntable`:
360    /// operates on the view matrix directly, then reconstructs position via
361    /// `lookAt`. This avoids degenerate cross products at the poles that
362    /// occur when transforming the camera position in world space.
363    pub fn orbit_turntable(&mut self, delta_x: f32, delta_y: f32) {
364        let up_vec = self.up_direction.to_vec3();
365
366        // Get the camera frame from the current view matrix (always valid)
367        let view_mat = self.view_matrix();
368        let r = glam::Mat3::from_cols(
369            view_mat.x_axis.truncate(),
370            view_mat.y_axis.truncate(),
371            view_mat.z_axis.truncate(),
372        );
373        let rt = r.transpose();
374        let frame_look = rt * Vec3::new(0.0, 0.0, -1.0);
375        let frame_right = rt * Vec3::new(1.0, 0.0, 0.0);
376
377        // Gimbal-lock protection: prevent flipping past poles
378        // With positive clamped_dy rotating downward (toward +up pole),
379        // clamp to prevent crossing:
380        let dot = frame_look.dot(up_vec);
381        let clamped_dy = if dot > 0.99 {
382            delta_y.max(0.0) // near top pole: only allow pitching downward (away)
383        } else if dot < -0.99 {
384            delta_y.min(0.0) // near bottom pole: only allow pitching upward (away)
385        } else {
386            delta_y
387        };
388
389        // Build the new view matrix by applying rotations (matching C++ exactly):
390        // 1. Translate to center
391        let mut vm = view_mat;
392        vm *= Mat4::from_translation(self.target);
393
394        // 2. Pitch around camera-space right axis (from the view matrix frame)
395        // C++ uses: glm::rotate(identity, -delPhi, frameRightDir)
396        // glam's look_at_rh produces an inverted-Z view compared to glm::lookAt,
397        // so pitch direction must also be flipped.
398        vm *= Mat4::from_axis_angle(frame_right, clamped_dy);
399
400        // 3. Yaw around world-space up axis
401        // C++ uses: glm::rotate(identity, delTheta, getUpVec()) — no negation
402        vm *= Mat4::from_axis_angle(up_vec, delta_x);
403
404        // 4. Undo centering
405        vm *= Mat4::from_translation(-self.target);
406
407        // Extract camera world position from the new view matrix
408        let new_view = vm;
409        let inv_view = new_view.inverse();
410        let new_pos = Vec3::new(inv_view.w_axis.x, inv_view.w_axis.y, inv_view.w_axis.z);
411
412        // Enforce exact distance to prevent numerical drift
413        let radius = (self.position - self.target).length();
414        let offset = new_pos - self.target;
415        let actual_dist = offset.length();
416        if actual_dist > 1e-8 {
417            self.position = self.target + offset * (radius / actual_dist);
418        } else {
419            self.position = new_pos;
420        }
421
422        // Reconstruct view with lookAt (matches C++ line 204: lookAt(pos, center, upVec))
423        // This ensures the up vector and view matrix are always consistent.
424        let final_view = Mat4::look_at_rh(self.position, self.target, up_vec);
425        if final_view.is_finite() {
426            // Extract up from the reconstructed view matrix
427            let fr = glam::Mat3::from_cols(
428                final_view.x_axis.truncate(),
429                final_view.y_axis.truncate(),
430                final_view.z_axis.truncate(),
431            );
432            self.up = fr.transpose() * Vec3::Y;
433        }
434    }
435
436    /// Free orbit: unconstrained rotation using camera-local axes.
437    /// Both yaw and pitch use the camera's own coordinate frame.
438    ///
439    /// Matches C++ Polyscope `processRotate` for `NavigateStyle::Free`.
440    pub fn orbit_free(&mut self, delta_x: f32, delta_y: f32) {
441        let radius = (self.position - self.target).length();
442        let right_dir = self.right();
443        let up_dir = self.camera_up();
444
445        // Yaw around camera-space up, then pitch around camera-space right
446        // Negate: position-based orbit is opposite to view-matrix rotation.
447        let yaw_rot = Mat4::from_axis_angle(up_dir, -delta_x);
448        let pitch_rot = Mat4::from_axis_angle(right_dir, -delta_y);
449
450        let to_center = Mat4::from_translation(self.target);
451        let from_center = Mat4::from_translation(-self.target);
452        let transform = to_center * pitch_rot * yaw_rot * from_center;
453
454        let new_pos = transform.transform_point3(self.position);
455
456        // Re-enforce exact distance
457        let offset = new_pos - self.target;
458        let actual_dist = offset.length();
459        if actual_dist > 1e-8 {
460            self.position = self.target + offset * (radius / actual_dist);
461        } else {
462            self.position = new_pos;
463        }
464
465        // Update up vector by rotating it along with the camera
466        let rot = pitch_rot * yaw_rot;
467        self.up = rot.transform_vector3(self.up).normalize();
468    }
469
470    /// Arcball orbit: maps 2D mouse positions to a virtual sphere for rotation.
471    /// `start` and `end` are normalized screen coordinates in [-1, 1].
472    ///
473    /// Matches C++ Polyscope `processRotate` for `NavigateStyle::Arcball`.
474    pub fn orbit_arcball(&mut self, start: [f32; 2], end: [f32; 2]) {
475        let to_sphere = |v: [f32; 2]| -> Vec3 {
476            let x = v[0].clamp(-1.0, 1.0);
477            let y = v[1].clamp(-1.0, 1.0);
478            let mag = x * x + y * y;
479            if mag <= 1.0 {
480                Vec3::new(x, y, -(1.0 - mag).sqrt())
481            } else {
482                Vec3::new(x, y, 0.0).normalize()
483            }
484        };
485
486        let sphere_start = to_sphere(start);
487        let sphere_end = to_sphere(end);
488
489        let rot_axis = -sphere_start.cross(sphere_end);
490        if rot_axis.length_squared() < 1e-12 {
491            return; // No meaningful rotation
492        }
493        let rot_angle = sphere_start.dot(sphere_end).clamp(-1.0, 1.0).acos();
494        if rot_angle.abs() < 1e-8 {
495            return;
496        }
497
498        // Build rotation in camera space, then convert to world space
499        let view = self.view_matrix();
500        let r = Mat3::from_cols(
501            view.x_axis.truncate(),
502            view.y_axis.truncate(),
503            view.z_axis.truncate(),
504        );
505        let r_inv = r.transpose();
506
507        // Camera-space rotation
508        let cam_rot = Mat3::from_axis_angle(rot_axis.normalize(), rot_angle);
509
510        // World-space rotation: R^-1 * cam_rot * R
511        let world_rot = r_inv * cam_rot * r;
512        let world_rot4 = Mat4::from_mat3(world_rot);
513
514        let to_center = Mat4::from_translation(self.target);
515        let from_center = Mat4::from_translation(-self.target);
516        let transform = to_center * world_rot4 * from_center;
517
518        let radius = (self.position - self.target).length();
519        let new_pos = transform.transform_point3(self.position);
520
521        // Re-enforce distance
522        let offset = new_pos - self.target;
523        let actual_dist = offset.length();
524        if actual_dist > 1e-8 {
525            self.position = self.target + offset * (radius / actual_dist);
526        } else {
527            self.position = new_pos;
528        }
529
530        // Rotate up vector
531        self.up = (world_rot * self.up).normalize();
532    }
533
534    /// First-person mouse look: yaw around world up, pitch around camera right.
535    /// Unlike orbit modes, this moves the target (look direction) rather than
536    /// orbiting around a fixed target.
537    ///
538    /// Matches C++ Polyscope `processRotate` for `NavigateStyle::FirstPerson`.
539    pub fn mouse_look(&mut self, delta_x: f32, delta_y: f32) {
540        let up_vec = self.up_direction.to_vec3();
541        let look_dir = self.forward();
542
543        // Gimbal-lock protection for pitch
544        let dot = look_dir.dot(up_vec);
545        let clamped_dy = if dot > 0.99 {
546            delta_y.min(0.0)
547        } else if dot < -0.99 {
548            delta_y.max(0.0)
549        } else {
550            delta_y
551        };
552
553        // Yaw around world up
554        // Negate: positive mouse delta_x (drag right) should turn view right
555        let yaw_rot = Quat::from_axis_angle(up_vec, -delta_x);
556        // Pitch around camera right
557        let right_dir = self.right();
558        let pitch_rot = Quat::from_axis_angle(right_dir, -clamped_dy);
559
560        // Apply rotations to look direction
561        let new_look = (pitch_rot * yaw_rot * look_dir).normalize();
562
563        // Move the target while keeping position fixed
564        let dist = (self.target - self.position).length();
565        self.target = self.position + new_look * dist;
566
567        // Update up to stay perpendicular to look direction
568        let new_right = new_look.cross(up_vec).normalize();
569        self.up = new_right.cross(new_look).normalize();
570        if self.up.length_squared() < 0.5 {
571            self.up = up_vec;
572        }
573    }
574
575    /// First-person WASD movement in camera-local coordinates.
576    /// `delta` is (right, up, forward) movement in camera space,
577    /// pre-scaled by `move_speed` and delta time by the caller.
578    pub fn move_first_person(&mut self, delta: Vec3) {
579        let fwd = self.forward();
580        let right = self.right();
581        let cam_up = self.camera_up();
582
583        let world_offset = right * delta.x + cam_up * delta.y + fwd * delta.z;
584        self.position += world_offset;
585        self.target += world_offset;
586    }
587
588    /// Legacy orbit method — delegates to `orbit_turntable`.
589    pub fn orbit(&mut self, delta_x: f32, delta_y: f32) {
590        self.orbit_turntable(delta_x, delta_y);
591    }
592
593    /// Pans the camera (translates position and target together).
594    /// For Turntable mode, this moves the orbit center.
595    pub fn pan(&mut self, delta_x: f32, delta_y: f32) {
596        let right = self.right();
597        let up_dir = self.camera_up();
598        let offset = right * delta_x + up_dir * delta_y;
599        self.position += offset;
600        self.target += offset;
601    }
602
603    /// Zooms the camera (moves toward/away from target for perspective,
604    /// adjusts `ortho_scale` for orthographic).
605    pub fn zoom(&mut self, delta: f32) {
606        match self.projection_mode {
607            ProjectionMode::Perspective => {
608                let direction = self.forward();
609                let distance = (self.position - self.target).length();
610                let new_distance = (distance - delta).max(0.1);
611                self.position = self.target - direction * new_distance;
612            }
613            ProjectionMode::Orthographic => {
614                // For orthographic, adjust the scale (smaller = zoom in, larger = zoom out)
615                // delta > 0 means zoom in (scroll up), so decrease scale
616                // Use a proportional factor based on current scale for consistent feel
617                let zoom_factor = 1.0 - delta * 0.4;
618                self.ortho_scale = (self.ortho_scale * zoom_factor).clamp(0.01, 1000.0);
619            }
620        }
621    }
622
623    /// Resets the camera to look at the given bounding box.
624    pub fn look_at_box(&mut self, min: Vec3, max: Vec3) {
625        let center = (min + max) * 0.5;
626        let size = (max - min).length();
627        let extents = max - min;
628
629        self.target = center;
630
631        // Compute camera distance using FOV so the bounding sphere is fully visible.
632        // self.fov is in radians already.
633        let half_fov_v = self.fov * 0.5;
634        let half_fov_h = (half_fov_v.tan() * self.aspect_ratio).atan();
635        let half_fov = half_fov_v.min(half_fov_h); // use the tighter angle
636        let radius = size * 0.5;
637        // Add a small margin (1.1x) so objects don't touch the viewport edge
638        let distance = (radius / half_fov.tan()) * 1.1;
639        self.position = center + Vec3::new(0.0, 0.0, distance);
640
641        self.near = size * 0.001;
642        self.far = size * 100.0;
643
644        // Set ortho_scale to fit the model in view
645        // Use the larger of height or width/aspect_ratio to ensure model fits
646        let half_height = extents.y.max(extents.x / self.aspect_ratio) * 0.6;
647        self.ortho_scale = half_height.max(0.1);
648    }
649
650    /// Sets the navigation style.
651    pub fn set_navigation_style(&mut self, style: NavigationStyle) {
652        self.navigation_style = style;
653    }
654
655    /// Sets the projection mode.
656    pub fn set_projection_mode(&mut self, mode: ProjectionMode) {
657        self.projection_mode = mode;
658    }
659
660    /// Sets the up direction and updates both the up vector and front direction.
661    /// The front direction is automatically derived using right-hand coordinate conventions.
662    pub fn set_up_direction(&mut self, direction: AxisDirection) {
663        self.up_direction = direction;
664        self.up = direction.to_vec3();
665        self.front_direction = direction.default_front_direction();
666    }
667
668    /// Sets the movement speed.
669    pub fn set_move_speed(&mut self, speed: f32) {
670        self.move_speed = speed.max(0.01);
671    }
672
673    /// Sets the orthographic scale.
674    pub fn set_ortho_scale(&mut self, scale: f32) {
675        self.ortho_scale = scale.max(0.01);
676    }
677
678    /// Sets the field of view in radians.
679    pub fn set_fov(&mut self, fov: f32) {
680        self.fov = fov.clamp(0.1, std::f32::consts::PI - 0.1);
681    }
682
683    /// Sets the near clipping plane.
684    pub fn set_near(&mut self, near: f32) {
685        self.near = near.max(0.001);
686    }
687
688    /// Sets the far clipping plane.
689    pub fn set_far(&mut self, far: f32) {
690        self.far = far.max(self.near + 0.1);
691    }
692
693    // ========================================================================
694    // Camera flight animation
695    // ========================================================================
696
697    /// Decomposes a 4x4 view matrix into a rotation quaternion and translation vector.
698    fn decompose_view_matrix(view: &Mat4) -> (Quat, Vec3) {
699        let rot_mat = Mat3::from_cols(
700            view.x_axis.truncate(),
701            view.y_axis.truncate(),
702            view.z_axis.truncate(),
703        );
704        let rot = Quat::from_mat3(&rot_mat);
705        let t = Vec3::new(view.w_axis.x, view.w_axis.y, view.w_axis.z);
706        (rot, t)
707    }
708
709    /// Reconstructs camera position, target, and up from a view matrix rotation
710    /// and translation, preserving the given camera-to-target distance.
711    fn camera_from_view_matrix(rot: &Quat, t: &Vec3, target_dist: f32) -> (Vec3, Vec3, Vec3) {
712        let rot_mat = Mat3::from_quat(*rot);
713        let rot_t = rot_mat.transpose();
714        // Camera position in world space: -R^T * t
715        let position = -(rot_t * *t);
716        // Forward direction: camera looks down -Z in eye space
717        let forward = -(rot_t * Vec3::Z);
718        // Up direction: +Y in eye space
719        let up = rot_t * Vec3::Y;
720        let target = position + forward * target_dist;
721        (position, target, up)
722    }
723
724    /// Starts a smooth animated flight to the given view matrix and FOV.
725    ///
726    /// The `target_view` is a 4x4 view matrix (world-to-eye). The `target_fov`
727    /// is in radians. `duration_secs` controls the flight length (C++ Polyscope
728    /// default is 0.4 seconds).
729    pub fn start_flight_to(&mut self, target_view: Mat4, target_fov: f32, duration_secs: f32) {
730        let current_view = self.view_matrix();
731        let current_dist = (self.position - self.target).length().max(0.01);
732
733        let (rot_start, t_start) = Self::decompose_view_matrix(&current_view);
734        let (rot_end, t_end) = Self::decompose_view_matrix(&target_view);
735
736        self.flight = Some(CameraFlight {
737            start_time: Instant::now(),
738            duration_secs,
739            initial_rot: rot_start,
740            target_rot: rot_end,
741            initial_t: t_start,
742            target_t: t_end,
743            initial_fov: self.fov,
744            target_fov,
745            target_dist: current_dist,
746        });
747    }
748
749    /// Updates the camera flight animation. Call once per frame.
750    ///
751    /// When the flight completes, the camera is set exactly to the target
752    /// position and `self.flight` is cleared.
753    pub fn update_flight(&mut self) {
754        let Some(flight) = &self.flight else {
755            return;
756        };
757
758        let elapsed = flight.start_time.elapsed().as_secs_f32();
759        let t = (elapsed / flight.duration_secs).min(1.0);
760        let dist = flight.target_dist;
761
762        if t >= 1.0 {
763            // Flight complete — set final position exactly
764            let (position, target, up) =
765                Self::camera_from_view_matrix(&flight.target_rot, &flight.target_t, dist);
766            self.position = position;
767            self.target = target;
768            self.up = up;
769            self.fov = flight.target_fov;
770            self.flight = None;
771        } else {
772            // Smoothstep easing: 3t^2 - 2t^3
773            let t_smooth = t * t * (3.0 - 2.0 * t);
774
775            // Quaternion lerp for rotation (matching C++ glm::lerp on dualquat with dual=0)
776            // Ensure shortest path
777            let target_rot = if flight.initial_rot.dot(flight.target_rot) < 0.0 {
778                -flight.target_rot
779            } else {
780                flight.target_rot
781            };
782            let interp_rot = flight.initial_rot.lerp(target_rot, t_smooth).normalize();
783
784            // Linear interpolation for translation with smoothstep
785            let interp_t = flight.initial_t.lerp(flight.target_t, t_smooth);
786
787            // Interpolate FOV with raw t (matching C++ Polyscope)
788            let fov = (1.0 - t) * flight.initial_fov + t * flight.target_fov;
789
790            let (position, target, up) =
791                Self::camera_from_view_matrix(&interp_rot, &interp_t, dist);
792            self.position = position;
793            self.target = target;
794            self.up = up;
795            self.fov = fov;
796        }
797    }
798
799    /// Cancels any active camera flight animation.
800    pub fn cancel_flight(&mut self) {
801        self.flight = None;
802    }
803
804    /// Returns whether a camera flight animation is currently active.
805    #[must_use]
806    pub fn is_in_flight(&self) -> bool {
807        self.flight.is_some()
808    }
809
810    /// Returns FOV in degrees.
811    #[must_use]
812    pub fn fov_degrees(&self) -> f32 {
813        self.fov.to_degrees()
814    }
815
816    /// Sets FOV from degrees.
817    pub fn set_fov_degrees(&mut self, degrees: f32) {
818        self.set_fov(degrees.to_radians());
819    }
820}
821
822impl Default for Camera {
823    fn default() -> Self {
824        Self::new(16.0 / 9.0)
825    }
826}
827
828#[cfg(test)]
829mod tests {
830    use super::*;
831
832    #[test]
833    fn test_projection_mode_perspective() {
834        let camera = Camera::new(1.0);
835        let proj = camera.projection_matrix();
836        // Perspective matrix has non-zero w division
837        assert!(proj.w_axis.z != 0.0);
838    }
839
840    #[test]
841    fn test_projection_mode_orthographic() {
842        let mut camera = Camera::new(1.0);
843        camera.projection_mode = ProjectionMode::Orthographic;
844        camera.ortho_scale = 5.0;
845        let proj = camera.projection_matrix();
846        // Orthographic matrix has w_axis.w = 1.0, w_axis.z = 0.0
847        assert!((proj.w_axis.w - 1.0).abs() < 0.001);
848    }
849
850    #[test]
851    fn test_set_fov_clamping() {
852        let mut camera = Camera::new(1.0);
853        camera.set_fov(0.0); // Too small
854        assert!(camera.fov >= 0.1);
855
856        camera.set_fov(std::f32::consts::PI); // Too large
857        assert!(camera.fov < std::f32::consts::PI);
858    }
859
860    #[test]
861    fn test_zoom_perspective() {
862        let mut camera = Camera::new(1.0);
863        camera.projection_mode = ProjectionMode::Perspective;
864        camera.position = Vec3::new(0.0, 0.0, 5.0);
865        camera.target = Vec3::ZERO;
866
867        let initial_distance = camera.position.distance(camera.target);
868        camera.zoom(1.0); // Zoom in
869        let new_distance = camera.position.distance(camera.target);
870
871        assert!(
872            new_distance < initial_distance,
873            "Perspective zoom in should decrease distance"
874        );
875    }
876
877    #[test]
878    fn test_zoom_orthographic() {
879        let mut camera = Camera::new(1.0);
880        camera.projection_mode = ProjectionMode::Orthographic;
881        camera.ortho_scale = 5.0;
882
883        let initial_scale = camera.ortho_scale;
884        camera.zoom(1.0); // Zoom in (positive delta)
885        let new_scale = camera.ortho_scale;
886
887        assert!(
888            new_scale < initial_scale,
889            "Orthographic zoom in should decrease scale"
890        );
891    }
892}