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