Skip to main content

runmat_plot/core/
camera.rs

1//! Camera system for 3D navigation and 2D plotting
2//!
3//! Provides both perspective and orthographic cameras with smooth
4//! navigation controls for interactive plotting.
5
6use crate::core::interaction::Modifiers;
7use crate::core::{ClipPolicy, DepthMode};
8use glam::{Mat4, Quat, Vec2, Vec3};
9
10/// Camera projection type
11#[derive(Debug, Clone, Copy, PartialEq)]
12pub enum ProjectionType {
13    Perspective {
14        fov: f32,
15        near: f32,
16        far: f32,
17    },
18    Orthographic {
19        left: f32,
20        right: f32,
21        bottom: f32,
22        top: f32,
23        near: f32,
24        far: f32,
25    },
26}
27
28impl Default for ProjectionType {
29    fn default() -> Self {
30        Self::Perspective {
31            fov: 45.0_f32.to_radians(),
32            near: 0.1,
33            far: 100.0,
34        }
35    }
36}
37
38/// Interactive camera for 3D plotting with smooth navigation
39#[derive(Debug, Clone)]
40pub struct Camera {
41    // Position and orientation
42    pub position: Vec3,
43    pub target: Vec3,
44    pub up: Vec3,
45
46    // Projection parameters
47    pub projection: ProjectionType,
48    pub aspect_ratio: f32,
49    /// Depth mapping mode used when building projection matrices.
50    pub depth_mode: DepthMode,
51
52    // Navigation state
53    pub zoom: f32,
54    pub rotation: Quat,
55
56    // Interaction settings
57    pub pan_sensitivity: f32,
58    pub zoom_sensitivity: f32,
59    pub rotate_sensitivity: f32,
60
61    // Cached matrices
62    view_matrix: Mat4,
63    projection_matrix: Mat4,
64    view_proj_dirty: bool,
65}
66
67impl Default for Camera {
68    fn default() -> Self {
69        Self::new()
70    }
71}
72
73impl Camera {
74    /// Create a new camera with default 3D settings
75    pub fn new() -> Self {
76        let mut camera = Self {
77            // CAD-like default: Z-up, with an isometric-ish starting view.
78            position: Vec3::new(3.5, 3.5, 3.5),
79            target: Vec3::ZERO,
80            up: Vec3::Z,
81            projection: ProjectionType::default(),
82            aspect_ratio: 16.0 / 9.0,
83            depth_mode: DepthMode::default(),
84            zoom: 1.0,
85            rotation: Quat::IDENTITY,
86            pan_sensitivity: 0.01,
87            zoom_sensitivity: 0.1,
88            rotate_sensitivity: 0.005,
89            view_matrix: Mat4::IDENTITY,
90            projection_matrix: Mat4::IDENTITY,
91            view_proj_dirty: true,
92        };
93        camera.update_matrices();
94        camera
95    }
96
97    /// Create a 2D orthographic camera for 2D plotting
98    pub fn new_2d(bounds: (f32, f32, f32, f32)) -> Self {
99        let (left, right, bottom, top) = bounds;
100        let center_x = (left + right) / 2.0;
101        let center_y = (bottom + top) / 2.0;
102        let mut camera = Self {
103            // Keep the eye directly above the data center for true 2D top-down view.
104            // If position.x/y differ from target.x/y, the look-at matrix tilts and the plot
105            // appears skewed / edge-on for data ranges far from the origin.
106            position: Vec3::new(center_x, center_y, 1.0),
107            target: Vec3::new(center_x, center_y, 0.0),
108            // For 2D views we look down -Z; keep a stable screen-up (+Y).
109            up: Vec3::Y,
110            projection: ProjectionType::Orthographic {
111                left,
112                right,
113                bottom,
114                top,
115                near: -1.0,
116                far: 1.0,
117            },
118            aspect_ratio: (right - left) / (top - bottom),
119            depth_mode: DepthMode::default(),
120            zoom: 1.0,
121            rotation: Quat::IDENTITY,
122            pan_sensitivity: 0.01,
123            zoom_sensitivity: 0.1,
124            rotate_sensitivity: 0.0, // Disable rotation for 2D
125            view_matrix: Mat4::IDENTITY,
126            projection_matrix: Mat4::IDENTITY,
127            view_proj_dirty: true,
128        };
129        camera.update_matrices();
130        camera
131    }
132
133    /// Update aspect ratio (call when window resizes)
134    pub fn update_aspect_ratio(&mut self, aspect_ratio: f32) {
135        self.aspect_ratio = aspect_ratio;
136        self.view_proj_dirty = true;
137    }
138
139    /// Get the view-projection matrix
140    pub fn view_proj_matrix(&mut self) -> Mat4 {
141        if self.view_proj_dirty {
142            self.update_matrices();
143        }
144        self.projection_matrix * self.view_matrix
145    }
146
147    /// Mark the camera matrices as dirty (call after manually modifying projection)
148    pub fn mark_dirty(&mut self) {
149        self.view_proj_dirty = true;
150    }
151
152    /// Get the view matrix
153    pub fn view_matrix(&mut self) -> Mat4 {
154        if self.view_proj_dirty {
155            self.update_matrices();
156        }
157        self.view_matrix
158    }
159
160    /// Get the projection matrix
161    pub fn projection_matrix(&mut self) -> Mat4 {
162        if self.view_proj_dirty {
163            self.update_matrices();
164        }
165        self.projection_matrix
166    }
167
168    /// Pan the camera (screen-space movement)
169    pub fn pan(&mut self, delta: Vec2) {
170        // Ensure view axes are up-to-date before using them.
171        let view = self.view_matrix();
172        let right = view.x_axis.truncate();
173        let up = view.y_axis.truncate();
174
175        // Scale pan by camera distance in 3D so it feels consistent while zooming.
176        let dist = (self.position - self.target).length().max(1e-3);
177        // CAD-style "grab": dragging right moves the scene right (camera left).
178        // Screen Y increases downward, so we keep the Y sign (drag down pans down).
179        let delta = Vec2::new(-delta.x, delta.y);
180        let pan_amount = delta * self.pan_sensitivity * dist;
181        let world_delta = right * pan_amount.x + up * pan_amount.y;
182
183        self.position += world_delta;
184        self.target += world_delta;
185        self.view_proj_dirty = true;
186    }
187
188    /// Zoom the camera (positive = zoom in, negative = zoom out)
189    pub fn zoom(&mut self, delta: f32) {
190        // Convert wheel delta into multiplicative zoom with deadzone and clamping
191        let mut factor = 1.0 - delta * self.zoom_sensitivity;
192        if factor.abs() < 1e-3 {
193            return;
194        }
195        factor = factor.clamp(0.2, 5.0);
196        self.zoom = (self.zoom * factor).clamp(0.01, 100.0);
197
198        match &mut self.projection {
199            ProjectionType::Perspective { .. } => {
200                // For perspective, dolly camera closer/farther to target.
201                let delta_vec = self.position - self.target;
202                let distance = delta_vec.length();
203                if !distance.is_finite() || distance < 1e-4 {
204                    // Avoid NaNs from normalizing a zero-length vector (which would make the scene vanish).
205                    return;
206                }
207                let direction = delta_vec / distance;
208                let new_distance = (distance * factor).clamp(0.1, 1000.0);
209                self.position = self.target + direction * new_distance;
210            }
211            ProjectionType::Orthographic {
212                left,
213                right,
214                bottom,
215                top,
216                ..
217            } => {
218                // For orthographic, scale the view bounds
219                let center_x = (*left + *right) / 2.0;
220                let center_y = (*bottom + *top) / 2.0;
221                let width = (*right - *left) * factor;
222                let height = (*top - *bottom) * factor;
223
224                *left = center_x - width / 2.0;
225                *right = center_x + width / 2.0;
226                *bottom = center_y - height / 2.0;
227                *top = center_y + height / 2.0;
228            }
229        }
230
231        self.view_proj_dirty = true;
232    }
233
234    /// Rotate the camera around the target (for 3D)
235    pub fn rotate(&mut self, delta: Vec2) {
236        if self.rotate_sensitivity == 0.0 {
237            return; // Rotation disabled (e.g., for 2D mode)
238        }
239
240        // Orbit-like controls:
241        // - yaw around world up
242        // - pitch around camera right
243        //
244        // This feels closer to typical 3D viewport / game-camera interaction than pitching around
245        // a fixed world X axis.
246        let yaw = -delta.x * self.rotate_sensitivity;
247        let pitch = -delta.y * self.rotate_sensitivity;
248
249        // Keep orbit constraints aligned to true world-up (+Z) even if the camera
250        // is rolled for a CAD-like default orientation.
251        let world_up = Vec3::Z;
252        let mut offset = self.position - self.target;
253        if offset.length_squared() < 1e-9 {
254            offset = Vec3::new(0.0, 0.0, 1.0);
255        }
256
257        // Yaw around world up.
258        let yaw_rot = Quat::from_axis_angle(world_up, yaw);
259        offset = yaw_rot * offset;
260
261        // Pitch around camera right axis after yaw.
262        let forward = (-offset).normalize_or_zero();
263        let right = forward.cross(world_up).normalize_or_zero();
264        if right.length_squared() > 1e-9 {
265            let pitch_rot = Quat::from_axis_angle(right, pitch);
266            let candidate = pitch_rot * offset;
267            // Avoid flipping over the poles (when looking straight up/down).
268            let up_dot = candidate.normalize_or_zero().dot(world_up).abs();
269            if up_dot < 0.995 {
270                offset = candidate;
271            }
272        }
273
274        self.position = self.target + offset;
275
276        self.view_proj_dirty = true;
277    }
278
279    /// Set camera to look at a specific target
280    pub fn look_at(&mut self, target: Vec3, distance: Option<f32>) {
281        self.target = target;
282
283        if let Some(dist) = distance {
284            let direction = (self.position - self.target).normalize();
285            self.position = self.target + direction * dist;
286        }
287
288        self.view_proj_dirty = true;
289    }
290
291    pub fn set_view_angles_deg(&mut self, azimuth_deg: f32, elevation_deg: f32) {
292        let distance = (self.position - self.target).length().max(0.1);
293        let az = azimuth_deg.to_radians();
294        let el = elevation_deg.to_radians();
295        let dir = Vec3::new(el.cos() * az.cos(), el.cos() * az.sin(), el.sin());
296        self.up = Vec3::Z;
297        self.position = self.target + dir * distance;
298        self.view_proj_dirty = true;
299    }
300
301    /// Reset camera to default position
302    pub fn reset(&mut self) {
303        match self.projection {
304            ProjectionType::Perspective { .. } => {
305                self.position = Vec3::new(3.5, 3.5, 3.5);
306                self.target = Vec3::ZERO;
307                self.rotation = Quat::IDENTITY;
308                self.up = Vec3::Z;
309            }
310            ProjectionType::Orthographic { .. } => {
311                self.zoom = 1.0;
312                self.target = Vec3::ZERO;
313            }
314        }
315        self.view_proj_dirty = true;
316    }
317
318    /// Fit the camera to show all data within the given bounds
319    pub fn fit_bounds(&mut self, min_bounds: Vec3, max_bounds: Vec3) {
320        let center = (min_bounds + max_bounds) / 2.0;
321        let size = max_bounds - min_bounds;
322
323        match &mut self.projection {
324            ProjectionType::Perspective { near, far, .. } => {
325                let max_size = size.x.max(size.y).max(size.z);
326                let distance = max_size * 2.0; // Ensure everything fits
327
328                self.target = center;
329                let direction = (self.position - self.target).normalize();
330                self.position = self.target + direction * distance;
331
332                // Keep clip planes sane relative to the new view distance.
333                // Animated surfaces can have very large Z ranges; if `far` is too small,
334                // everything gets clipped and the plot appears to "clear".
335                let radius = (size.length() * 0.5).max(1e-3);
336                let dist = (self.position - self.target).length().max(1e-3);
337                let desired_near = (dist - radius * 4.0).max(0.01);
338                let desired_far = (dist + radius * 4.0).max(desired_near + 1.0);
339                *near = desired_near;
340                *far = desired_far;
341            }
342            ProjectionType::Orthographic {
343                left,
344                right,
345                bottom,
346                top,
347                ..
348            } => {
349                let margin = 0.1; // 10% margin
350                let width = size.x * (1.0 + margin);
351                let height = size.y * (1.0 + margin);
352
353                // Maintain aspect ratio
354                let display_width = width.max(height * self.aspect_ratio);
355                let display_height = height.max(width / self.aspect_ratio);
356
357                *left = center.x - display_width / 2.0;
358                *right = center.x + display_width / 2.0;
359                *bottom = center.y - display_height / 2.0;
360                *top = center.y + display_height / 2.0;
361
362                self.target = center;
363            }
364        }
365
366        self.view_proj_dirty = true;
367    }
368
369    pub fn set_clip_planes(&mut self, near: f32, far: f32) {
370        match &mut self.projection {
371            ProjectionType::Perspective {
372                near: n, far: f, ..
373            } => {
374                *n = near.max(1e-4);
375                *f = far.max(*n + 1e-3);
376                self.view_proj_dirty = true;
377            }
378            ProjectionType::Orthographic {
379                near: n, far: f, ..
380            } => {
381                *n = near;
382                *f = far;
383                self.view_proj_dirty = true;
384            }
385        }
386    }
387
388    /// Update near/far clip planes from a world-space AABB using a policy.
389    ///
390    /// This is meant to be run per-frame for 3D (CAD-like) robustness: keep near as large as
391    /// possible without slicing the scene, and keep far just large enough to contain it.
392    pub fn update_clip_planes_from_world_aabb(
393        &mut self,
394        world_min: Vec3,
395        world_max: Vec3,
396        policy: &ClipPolicy,
397    ) {
398        if !policy.dynamic {
399            return;
400        }
401        let ProjectionType::Perspective { .. } = self.projection else {
402            return;
403        };
404
405        // View space from current pose (independent of projection).
406        let view = Mat4::look_at_rh(self.position, self.target, self.up);
407        let corners = [
408            Vec3::new(world_min.x, world_min.y, world_min.z),
409            Vec3::new(world_max.x, world_min.y, world_min.z),
410            Vec3::new(world_min.x, world_max.y, world_min.z),
411            Vec3::new(world_max.x, world_max.y, world_min.z),
412            Vec3::new(world_min.x, world_min.y, world_max.z),
413            Vec3::new(world_max.x, world_min.y, world_max.z),
414            Vec3::new(world_min.x, world_max.y, world_max.z),
415            Vec3::new(world_max.x, world_max.y, world_max.z),
416        ];
417
418        let mut min_depth = f32::INFINITY;
419        let mut max_depth = f32::NEG_INFINITY;
420        for c in corners {
421            let v = (view * c.extend(1.0)).truncate();
422            if !(v.x.is_finite() && v.y.is_finite() && v.z.is_finite()) {
423                continue;
424            }
425            // RH look-at: camera looks down -Z; points in front have negative z.
426            let depth = (-v.z).max(0.0);
427            if depth > 0.0 {
428                min_depth = min_depth.min(depth);
429                max_depth = max_depth.max(depth);
430            }
431        }
432        if !min_depth.is_finite() || !max_depth.is_finite() || max_depth <= 0.0 {
433            return;
434        }
435
436        let mut near = (min_depth * policy.near_padding).max(policy.min_near);
437        let mut far = (max_depth * policy.far_padding).max(near + 1.0);
438        if far > policy.max_far {
439            far = policy.max_far.max(near + 1.0);
440        }
441        // Keep a modest ratio when possible to preserve precision (avoid near->0).
442        if (far / near).is_finite() && far / near > 1.0e6 {
443            near = (far / 1.0e6).max(policy.min_near);
444        }
445        self.set_clip_planes(near, far);
446    }
447
448    /// Convert screen coordinates to world coordinates (for picking)
449    pub fn screen_to_world(&mut self, screen_pos: Vec2, screen_size: Vec2, depth: f32) -> Vec3 {
450        if self.view_proj_dirty {
451            self.update_matrices();
452        }
453        // Convert screen coordinates to normalized device coordinates
454        let ndc_x = (2.0 * screen_pos.x) / screen_size.x - 1.0;
455        let ndc_y = 1.0 - (2.0 * screen_pos.y) / screen_size.y;
456        let ndc = Vec3::new(ndc_x, ndc_y, depth * 2.0 - 1.0);
457
458        // Unproject to world coordinates
459        let view_proj_inv = (self.projection_matrix * self.view_matrix).inverse();
460        let world_pos = view_proj_inv * ndc.extend(1.0);
461
462        if world_pos.w != 0.0 {
463            world_pos.truncate() / world_pos.w
464        } else {
465            world_pos.truncate()
466        }
467    }
468
469    /// Update the view and projection matrices
470    fn update_matrices(&mut self) {
471        // Update view matrix
472        self.view_matrix = Mat4::look_at_rh(self.position, self.target, self.up);
473
474        // Update projection matrix
475        self.projection_matrix = match self.projection {
476            ProjectionType::Perspective { fov, near, far } => {
477                match self.depth_mode {
478                    DepthMode::Standard => Mat4::perspective_rh(fov, self.aspect_ratio, near, far),
479                    DepthMode::ReversedZ => {
480                        // Right-handed, depth range 0..1, reversed-Z mapping.
481                        // This keeps the same clip volume but flips the depth distribution so
482                        // far distances retain more precision (game-engine style).
483                        let f = 1.0 / (0.5 * fov).tan();
484                        let a = self.aspect_ratio.max(1e-6);
485                        let nf = (far - near).max(1e-6);
486                        let m00 = f / a;
487                        let m11 = f;
488                        // Reversed-Z: near->1, far->0
489                        let m22 = near / nf;
490                        let m32 = (near * far) / nf;
491                        Mat4::from_cols_array(&[
492                            m00, 0.0, 0.0, 0.0, //
493                            0.0, m11, 0.0, 0.0, //
494                            0.0, 0.0, m22, -1.0, //
495                            0.0, 0.0, m32, 0.0, //
496                        ])
497                    }
498                }
499            }
500            ProjectionType::Orthographic {
501                left,
502                right,
503                bottom,
504                top,
505                near,
506                far,
507            } => {
508                log::trace!(
509                    target: "runmat_plot",
510                    "ortho matrix bounds l={} r={} b={} t={} n={} f={}",
511                    left, right, bottom, top, near, far
512                );
513                log::trace!(target: "runmat_plot", "camera aspect_ratio={}", self.aspect_ratio);
514                Mat4::orthographic_rh(left, right, bottom, top, near, far)
515            }
516        };
517
518        self.view_proj_dirty = false;
519    }
520}
521
522/// Camera controller for handling input events
523#[derive(Debug, Default)]
524pub struct CameraController {
525    pub active_button: Option<MouseButton>,
526    pub last_mouse_pos: Vec2,
527    pub mouse_delta: Vec2,
528}
529
530impl CameraController {
531    pub fn new() -> Self {
532        Self::default()
533    }
534
535    /// Handle mouse press
536    pub fn mouse_press(&mut self, position: Vec2, button: MouseButton, _modifiers: Modifiers) {
537        self.last_mouse_pos = position;
538        self.active_button = Some(button);
539    }
540
541    /// Handle mouse release
542    pub fn mouse_release(&mut self, _position: Vec2, button: MouseButton, _modifiers: Modifiers) {
543        if self.active_button == Some(button) {
544            self.active_button = None;
545        }
546    }
547
548    /// Handle mouse movement
549    ///
550    /// For 3D (perspective) cameras:
551    /// - left drag: orbit/rotate
552    /// - right drag: pan
553    ///
554    /// For 2D (orthographic) cameras, we treat drag as pan by shifting the
555    /// orthographic bounds in data-space. We avoid translating the view matrix in X/Y
556    /// because the ortho bounds already live in data coordinates.
557    pub fn mouse_move(
558        &mut self,
559        position: Vec2,
560        delta: Vec2,
561        viewport_px: (u32, u32),
562        modifiers: Modifiers,
563        camera: &mut Camera,
564    ) {
565        let Some(button) = self.active_button else {
566            self.last_mouse_pos = position;
567            return;
568        };
569        // Prefer the host-provided delta; fall back to position diff.
570        self.mouse_delta = if delta.length_squared() > 0.0 {
571            delta
572        } else {
573            position - self.last_mouse_pos
574        };
575
576        match camera.projection {
577            ProjectionType::Perspective { .. } => {
578                // CAD-like bindings (support common schemes simultaneously):
579                // - MMB drag: orbit; Shift+MMB: pan
580                // - RMB drag: orbit; Shift+RMB: pan
581                // - Alt+LMB: orbit; Alt+MMB: pan; Alt+RMB: dolly/zoom
582                //
583                // Also keep LMB orbit + Shift+LMB pan as a convenient fallback.
584                let fine = if modifiers.ctrl || modifiers.meta {
585                    0.35
586                } else {
587                    1.0
588                };
589                let d = self.mouse_delta * fine;
590
591                if modifiers.alt {
592                    match button {
593                        MouseButton::Left => camera.rotate(d),
594                        MouseButton::Middle => camera.pan(d),
595                        MouseButton::Right => {
596                            // Alt+RMB drag zoom (dolly). Positive drag up should zoom in.
597                            let zoom_delta = (-d.y / 120.0).clamp(-5.0, 5.0);
598                            self.mouse_wheel(
599                                Vec2::new(0.0, zoom_delta),
600                                position,
601                                viewport_px,
602                                modifiers,
603                                camera,
604                            );
605                        }
606                    }
607                } else {
608                    let want_pan = modifiers.shift;
609                    match button {
610                        MouseButton::Middle | MouseButton::Right => {
611                            if want_pan {
612                                camera.pan(d);
613                            } else if modifiers.ctrl || modifiers.meta {
614                                // Ctrl/Cmd + (MMB/RMB) drag: zoom/dolly (very common in CAD/DCC).
615                                let zoom_delta = (-d.y / 120.0).clamp(-5.0, 5.0);
616                                self.mouse_wheel(
617                                    Vec2::new(0.0, zoom_delta),
618                                    position,
619                                    viewport_px,
620                                    modifiers,
621                                    camera,
622                                );
623                            } else {
624                                camera.rotate(d);
625                            }
626                        }
627                        MouseButton::Left => {
628                            if want_pan {
629                                camera.pan(d);
630                            } else if modifiers.ctrl || modifiers.meta {
631                                let zoom_delta = (-d.y / 120.0).clamp(-5.0, 5.0);
632                                self.mouse_wheel(
633                                    Vec2::new(0.0, zoom_delta),
634                                    position,
635                                    viewport_px,
636                                    modifiers,
637                                    camera,
638                                );
639                            } else {
640                                camera.rotate(d);
641                            }
642                        }
643                    }
644                }
645            }
646            ProjectionType::Orthographic {
647                ref mut left,
648                ref mut right,
649                ref mut bottom,
650                ref mut top,
651                ..
652            } => {
653                // For 2D, treat any drag (and Shift-drag) as panning the ortho bounds.
654                let _ = (button, modifiers);
655                {
656                    let (vw, vh) = (viewport_px.0.max(1) as f32, viewport_px.1.max(1) as f32);
657                    let width = (*right - *left).abs().max(1e-6);
658                    let height = (*top - *bottom).abs().max(1e-6);
659
660                    // Convert pixel delta to data-space delta.
661                    // Screen +X should move the view right; dragging right should move the data left,
662                    // so we subtract.
663                    let dx = -self.mouse_delta.x * (width / vw);
664                    // Screen +Y is down in most DOM coordinate systems; dragging down should move
665                    // the data up, so we add.
666                    let dy = self.mouse_delta.y * (height / vh);
667
668                    *left += dx;
669                    *right += dx;
670                    *bottom += dy;
671                    *top += dy;
672                    camera.mark_dirty();
673                }
674            }
675        }
676
677        self.last_mouse_pos = position;
678    }
679
680    /// Handle mouse wheel
681    pub fn mouse_wheel(
682        &mut self,
683        delta: Vec2,
684        position_px: Vec2,
685        viewport_px: (u32, u32),
686        modifiers: Modifiers,
687        camera: &mut Camera,
688    ) {
689        // CAD-ish wheel semantics:
690        // - default: zoom/dolly to cursor using vertical wheel component
691        // - Shift: pan (screen-space) using both wheel components
692        //
693        // Don't treat Ctrl/Cmd as "fine wheel" because macOS trackpad pinch-to-zoom gestures
694        // report Ctrl as pressed. Keeping wheel zoom consistent feels more natural.
695        let delta_y = delta.y;
696
697        match &mut camera.projection {
698            ProjectionType::Perspective { .. } => {
699                if modifiers.shift {
700                    // Wheel-pan in the view plane. Scale by distance for a consistent feel.
701                    // Positive wheel deltas should pan "with" the gesture (down scroll moves view down).
702                    // NOTE: `Camera::pan` already scales by camera distance; don't multiply by distance here.
703                    let pan_px = Vec2::new(delta.x, -delta.y);
704                    camera.pan(pan_px * 6.0);
705                    return;
706                }
707
708                let sens = camera.zoom_sensitivity;
709                let mut factor = 1.0 - delta_y * sens;
710                if factor.abs() < 1e-3 {
711                    return;
712                }
713                factor = factor.clamp(0.2, 5.0);
714
715                let (vw, vh) = (viewport_px.0.max(1) as f32, viewport_px.1.max(1) as f32);
716                let screen_size = Vec2::new(vw, vh);
717                let pos = Vec2::new(position_px.x.clamp(0.0, vw), position_px.y.clamp(0.0, vh));
718
719                // Build a ray from the cursor through the view frustum.
720                let p_near = camera.screen_to_world(pos, screen_size, 0.0);
721                let p_far = camera.screen_to_world(pos, screen_size, 1.0);
722                let dir = (p_far - p_near).normalize_or_zero();
723                if dir.length_squared() < 1e-9 {
724                    return;
725                }
726
727                // Prefer anchoring to the XY plane (Z=0). If near-parallel, fall back to a plane
728                // through the current target perpendicular to the view direction.
729                let origin = camera.position;
730                let mut pivot = None;
731                if dir.z.abs() > 1e-6 {
732                    let t = (-origin.z) / dir.z;
733                    if t.is_finite() && t > 0.0 {
734                        pivot = Some(origin + dir * t);
735                    }
736                }
737                if pivot.is_none() {
738                    let forward = (camera.target - camera.position).normalize_or_zero();
739                    let denom = dir.dot(forward);
740                    if denom.abs() > 1e-6 {
741                        let t = (camera.target - origin).dot(forward) / denom;
742                        if t.is_finite() && t > 0.0 {
743                            pivot = Some(origin + dir * t);
744                        }
745                    }
746                }
747                let pivot = pivot.unwrap_or(camera.target);
748
749                let s = (pivot - origin).length().max(1e-3);
750                let new_s = (s * factor).clamp(0.05, 1.0e9);
751                let delta_dist = s - new_s;
752                let translate = dir * delta_dist;
753
754                // Dolly along the cursor ray while keeping orientation stable (translate both
755                // position + target so the cursor stays anchored).
756                camera.position += translate;
757                camera.target += translate;
758                camera.view_proj_dirty = true;
759            }
760            ProjectionType::Orthographic {
761                left,
762                right,
763                bottom,
764                top,
765                ..
766            } => {
767                if modifiers.shift {
768                    // Wheel-pan in 2D (treat wheel deltas as pixel-ish movement).
769                    let vw = viewport_px.0.max(1) as f32;
770                    let vh = viewport_px.1.max(1) as f32;
771                    let w = (*right - *left).max(1e-6);
772                    let h = (*top - *bottom).max(1e-6);
773                    let dx = -delta.x * (w / vw);
774                    let dy = delta.y * (h / vh);
775                    *left += dx;
776                    *right += dx;
777                    *bottom += dy;
778                    *top += dy;
779                    camera.mark_dirty();
780                    return;
781                }
782
783                let sens = camera.zoom_sensitivity;
784                let mut factor = 1.0 - delta_y * sens;
785                if factor.abs() < 1e-3 {
786                    return;
787                }
788                factor = factor.clamp(0.2, 5.0);
789
790                // Cursor-anchored 2D zoom: scale the ortho bounds around the cursor.
791                let w = (*right - *left).max(1e-6);
792                let h = (*top - *bottom).max(1e-6);
793                let vw = viewport_px.0.max(1) as f32;
794                let vh = viewport_px.1.max(1) as f32;
795                let tx = (position_px.x / vw).clamp(0.0, 1.0);
796                let ty = (position_px.y / vh).clamp(0.0, 1.0);
797                let pivot_x = *left + tx * w;
798                let pivot_y = *top - ty * h;
799                let new_left = pivot_x - (pivot_x - *left) * factor;
800                let new_right = pivot_x + (*right - pivot_x) * factor;
801                let new_bottom = pivot_y - (pivot_y - *bottom) * factor;
802                let new_top = pivot_y + (*top - pivot_y) * factor;
803                *left = new_left;
804                *right = new_right;
805                *bottom = new_bottom;
806                *top = new_top;
807                camera.mark_dirty();
808            }
809        }
810    }
811}
812
813/// Mouse button enum for camera control
814#[derive(Debug, Clone, Copy, PartialEq, Eq)]
815pub enum MouseButton {
816    Left,
817    Right,
818    Middle,
819}
820
821#[cfg(test)]
822mod tests {
823    use super::*;
824
825    #[test]
826    fn test_camera_creation() {
827        let camera = Camera::new();
828        assert_eq!(camera.position, Vec3::new(3.5, 3.5, 3.5));
829        assert_eq!(camera.target, Vec3::ZERO);
830        assert_eq!(camera.up, Vec3::Z);
831    }
832
833    #[test]
834    fn test_2d_camera() {
835        let camera = Camera::new_2d((-10.0, 10.0, -10.0, 10.0));
836        assert_eq!(camera.position, Vec3::new(0.0, 0.0, 1.0));
837        assert_eq!(camera.target, Vec3::new(0.0, 0.0, 0.0));
838        match camera.projection {
839            ProjectionType::Orthographic {
840                left,
841                right,
842                bottom,
843                top,
844                ..
845            } => {
846                assert_eq!(left, -10.0);
847                assert_eq!(right, 10.0);
848                assert_eq!(bottom, -10.0);
849                assert_eq!(top, 10.0);
850            }
851            _ => panic!("Expected orthographic projection"),
852        }
853    }
854
855    #[test]
856    fn test_camera_bounds_fitting() {
857        let mut camera = Camera::new_2d((-1.0, 1.0, -1.0, 1.0));
858        let min_bounds = Vec3::new(-5.0, -3.0, 0.0);
859        let max_bounds = Vec3::new(5.0, 3.0, 0.0);
860
861        camera.fit_bounds(min_bounds, max_bounds);
862
863        // Check that the bounds were expanded appropriately
864        match camera.projection {
865            ProjectionType::Orthographic {
866                left,
867                right,
868                bottom,
869                top,
870                ..
871            } => {
872                assert!(left <= -5.0);
873                assert!(right >= 5.0);
874                assert!(bottom <= -3.0);
875                assert!(top >= 3.0);
876            }
877            _ => panic!("Expected orthographic projection"),
878        }
879    }
880
881    #[test]
882    fn test_2d_camera_tracks_non_origin_bounds_center() {
883        let camera = Camera::new_2d((10.0, 30.0, -2.0, 2.0));
884        assert_eq!(camera.position, Vec3::new(20.0, 0.0, 1.0));
885        assert_eq!(camera.target, Vec3::new(20.0, 0.0, 0.0));
886    }
887
888    #[test]
889    fn test_set_view_angles_preserves_distance() {
890        let mut camera = Camera::new();
891        camera.target = Vec3::new(1.0, 2.0, 3.0);
892        camera.position = camera.target + Vec3::new(2.0, 0.0, 0.0);
893        camera.set_view_angles_deg(90.0, 0.0);
894        let offset = camera.position - camera.target;
895        assert!((offset.length() - 2.0).abs() < 1e-5);
896        assert!(offset.x.abs() < 1e-4);
897        assert!((offset.y - 2.0).abs() < 1e-4);
898    }
899}