Skip to main content

proof_engine/editor/
camera_controller.rs

1//! Camera Controller — orbit, pan, zoom, free-fly, and snap views for the
2//! editor viewport.
3//!
4//! # Modes
5//!
6//! - **Orbit**: rotate around a focal point.  Middle-mouse drag or Alt+LMB.
7//! - **Pan**: translate camera and focal point together.  Shift+Middle-mouse.
8//! - **Zoom**: move camera along view axis.  Scroll wheel or Ctrl+Middle-mouse.
9//! - **FreeFly**: WASD/QE + mouse look.  Activated by pressing F.
10//! - **Snap**: jump to canonical views (Front, Back, Left, Right, Top, Bottom,
11//!   Iso).  Numpad shortcuts.
12//!
13//! # Spring physics
14//!
15//! The camera has a `target_*` / current `*` dual: actual values are
16//! spring-damped toward targets each frame.  The spring constants and damping
17//! are tuned by the kit panel's Camera group.  This also handles trauma-based
18//! screen shake: adding to `trauma` displaces and rolls the camera according to
19//! a Perlin-like hash.
20
21use glam::{Vec2, Vec3, Vec4, Mat4, Quat};
22use std::f32::consts::{PI, FRAC_PI_2};
23
24// ─────────────────────────────────────────────────────────────────────────────
25// Projection
26// ─────────────────────────────────────────────────────────────────────────────
27
28#[derive(Debug, Clone, Copy, PartialEq)]
29pub enum Projection {
30    Perspective { fov_y_deg: f32, near: f32, far: f32 },
31    Orthographic { size: f32, near: f32, far: f32 },
32}
33
34impl Default for Projection {
35    fn default() -> Self {
36        Projection::Perspective { fov_y_deg: 65.0, near: 0.01, far: 1000.0 }
37    }
38}
39
40impl Projection {
41    pub fn matrix(self, aspect: f32) -> Mat4 {
42        match self {
43            Projection::Perspective { fov_y_deg, near, far } => {
44                let fov_rad = fov_y_deg * PI / 180.0;
45                Mat4::perspective_rh(fov_rad, aspect, near, far)
46            }
47            Projection::Orthographic { size, near, far } => {
48                let half_w = size * aspect * 0.5;
49                let half_h = size * 0.5;
50                Mat4::orthographic_rh(-half_w, half_w, -half_h, half_h, near, far)
51            }
52        }
53    }
54
55    pub fn toggle_ortho(&self) -> Self {
56        match *self {
57            Projection::Perspective { near, far, fov_y_deg } => {
58                Projection::Orthographic { size: 5.0, near, far }
59            }
60            Projection::Orthographic { near, far, .. } => {
61                Projection::Perspective { fov_y_deg: 65.0, near, far }
62            }
63        }
64    }
65
66    pub fn is_orthographic(&self) -> bool { matches!(self, Projection::Orthographic{..}) }
67    pub fn is_perspective(&self)  -> bool { matches!(self, Projection::Perspective{..}) }
68}
69
70// ─────────────────────────────────────────────────────────────────────────────
71// CameraMode
72// ─────────────────────────────────────────────────────────────────────────────
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
75pub enum CameraMode {
76    #[default]
77    Orbit,
78    Pan,
79    Zoom,
80    FreeFly,
81}
82
83impl CameraMode {
84    pub fn label(self) -> &'static str {
85        match self {
86            Self::Orbit   => "Orbit",
87            Self::Pan     => "Pan",
88            Self::Zoom    => "Zoom",
89            Self::FreeFly => "FreeFly",
90        }
91    }
92}
93
94// ─────────────────────────────────────────────────────────────────────────────
95// SnapView
96// ─────────────────────────────────────────────────────────────────────────────
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum SnapView {
100    Front, Back, Left, Right, Top, Bottom,
101    IsoFrontRight, IsoFrontLeft, IsoBackRight, IsoBackLeft,
102}
103
104impl SnapView {
105    pub fn label(self) -> &'static str {
106        match self {
107            Self::Front         => "Front",
108            Self::Back          => "Back",
109            Self::Left          => "Left",
110            Self::Right         => "Right",
111            Self::Top           => "Top",
112            Self::Bottom        => "Bottom",
113            Self::IsoFrontRight => "Iso FR",
114            Self::IsoFrontLeft  => "Iso FL",
115            Self::IsoBackRight  => "Iso BR",
116            Self::IsoBackLeft   => "Iso BL",
117        }
118    }
119
120    /// The (azimuth, elevation) angles in radians for this view.
121    pub fn angles(self) -> (f32, f32) {
122        match self {
123            Self::Front         => (0.0,                0.0),
124            Self::Back          => (PI,                 0.0),
125            Self::Right         => (FRAC_PI_2,          0.0),
126            Self::Left          => (-FRAC_PI_2,         0.0),
127            Self::Top           => (0.0,                FRAC_PI_2 - 0.001),
128            Self::Bottom        => (0.0,               -FRAC_PI_2 + 0.001),
129            Self::IsoFrontRight => (PI * 0.25,          PI / 6.0),
130            Self::IsoFrontLeft  => (-PI * 0.25,         PI / 6.0),
131            Self::IsoBackRight  => (PI * 0.75,          PI / 6.0),
132            Self::IsoBackLeft   => (-PI * 0.75,         PI / 6.0),
133        }
134    }
135}
136
137// ─────────────────────────────────────────────────────────────────────────────
138// FreeFlyInput
139// ─────────────────────────────────────────────────────────────────────────────
140
141/// Input state for free-fly mode.
142#[derive(Debug, Clone, Default)]
143pub struct FreeFlyInput {
144    pub forward:  bool,
145    pub backward: bool,
146    pub left:     bool,
147    pub right:    bool,
148    pub up:       bool,
149    pub down:     bool,
150    pub boost:    bool,
151    pub mouse_dx: f32,
152    pub mouse_dy: f32,
153}
154
155// ─────────────────────────────────────────────────────────────────────────────
156// Spring helper
157// ─────────────────────────────────────────────────────────────────────────────
158
159fn spring_damp_f32(current: f32, target: f32, velocity: &mut f32, k: f32, damp: f32, dt: f32) -> f32 {
160    let force = k * (target - current) - damp * *velocity;
161    *velocity += force * dt;
162    current + *velocity * dt
163}
164
165fn spring_damp_vec3(current: Vec3, target: Vec3, velocity: &mut Vec3, k: f32, damp: f32, dt: f32) -> Vec3 {
166    let force = (target - current) * k - *velocity * damp;
167    *velocity += force * dt;
168    current + *velocity * dt
169}
170
171// ─────────────────────────────────────────────────────────────────────────────
172// EditorCamera
173// ─────────────────────────────────────────────────────────────────────────────
174
175/// The main editor camera.
176#[derive(Debug, Clone)]
177pub struct EditorCamera {
178    // ── Orbit parameters ──────────────────────────────────────────────────
179    pub focal_point:  Vec3,
180    pub azimuth:      f32,  // radians
181    pub elevation:    f32,  // radians
182    pub distance:     f32,  // units from focal point
183
184    // ── Target (spring-damped to) ─────────────────────────────────────────
185    pub target_focal: Vec3,
186    pub target_az:    f32,
187    pub target_el:    f32,
188    pub target_dist:  f32,
189
190    // ── Spring velocities ─────────────────────────────────────────────────
191    vel_focal:   Vec3,
192    vel_az:      f32,
193    vel_el:      f32,
194    vel_dist:    f32,
195
196    // ── Free-fly state ────────────────────────────────────────────────────
197    pub position:     Vec3,
198    pub yaw:          f32,
199    pub pitch:        f32,
200    fly_vel:          Vec3,
201
202    // ── Projection ───────────────────────────────────────────────────────
203    pub projection:   Projection,
204    pub aspect:       f32,
205
206    // ── Mode ─────────────────────────────────────────────────────────────
207    pub mode:         CameraMode,
208
209    // ── Spring config ─────────────────────────────────────────────────────
210    pub spring_k:     f32,
211    pub spring_damp:  f32,
212    pub orbit_speed:  f32,
213    pub pan_speed:    f32,
214    pub zoom_speed:   f32,
215    pub fly_speed:    f32,
216
217    // ── Screen shake ─────────────────────────────────────────────────────
218    /// Trauma ∈ [0, 1]: shake magnitude. Decays over time.
219    pub trauma:       f32,
220    pub trauma_decay: f32,
221    shake_time:       f32,
222
223    // ── Snapping ─────────────────────────────────────────────────────────
224    pub snap_align:   bool,
225    last_snap:        Option<SnapView>,
226}
227
228impl EditorCamera {
229    pub fn new() -> Self {
230        Self {
231            focal_point: Vec3::ZERO,
232            azimuth:     0.0,
233            elevation:   0.2,
234            distance:    4.0,
235            target_focal: Vec3::ZERO,
236            target_az:    0.0,
237            target_el:    0.2,
238            target_dist:  4.0,
239            vel_focal:    Vec3::ZERO,
240            vel_az:       0.0,
241            vel_el:       0.0,
242            vel_dist:     0.0,
243            position:     Vec3::new(0.0, 0.0, 4.0),
244            yaw:          0.0,
245            pitch:        0.0,
246            fly_vel:      Vec3::ZERO,
247            projection:   Projection::default(),
248            aspect:       16.0 / 9.0,
249            mode:         CameraMode::Orbit,
250            spring_k:     8.0,
251            spring_damp:  6.0,
252            orbit_speed:  1.5,
253            pan_speed:    2.0,
254            zoom_speed:   1.5,
255            fly_speed:    5.0,
256            trauma:       0.0,
257            trauma_decay: 3.0,
258            shake_time:   0.0,
259            snap_align:   true,
260            last_snap:    None,
261        }
262    }
263
264    // ── World position ────────────────────────────────────────────────────
265
266    /// Current camera position in orbit mode.
267    pub fn orbit_position(&self) -> Vec3 {
268        let x = self.distance * self.elevation.cos() * self.azimuth.sin();
269        let y = self.distance * self.elevation.sin();
270        let z = self.distance * self.elevation.cos() * self.azimuth.cos();
271        self.focal_point + Vec3::new(x, y, z)
272    }
273
274    /// Current view matrix (world → camera).
275    pub fn view_matrix(&self) -> Mat4 {
276        match self.mode {
277            CameraMode::FreeFly => {
278                let rot = Quat::from_euler(glam::EulerRot::YXZ, self.yaw, self.pitch, 0.0);
279                let forward = rot.mul_vec3(-Vec3::Z);
280                Mat4::look_at_rh(self.position, self.position + forward, Vec3::Y)
281            }
282            _ => {
283                let eye = self.orbit_position();
284                let shake_offset = self.shake_offset();
285                Mat4::look_at_rh(eye + shake_offset, self.focal_point + shake_offset, Vec3::Y)
286            }
287        }
288    }
289
290    /// Projection × View combined matrix.
291    pub fn view_proj(&self) -> Mat4 {
292        self.projection.matrix(self.aspect) * self.view_matrix()
293    }
294
295    // ── Shake ─────────────────────────────────────────────────────────────
296
297    fn shake_offset(&self) -> Vec3 {
298        if self.trauma < 0.001 { return Vec3::ZERO; }
299        let magnitude = self.trauma * self.trauma;
300        let t = self.shake_time;
301        let px = magnitude * 0.08 * ((t * 31.0).sin() + (t * 17.0 + 0.5).sin());
302        let py = magnitude * 0.08 * ((t * 29.0 + 1.0).sin() + (t * 13.0).sin());
303        Vec3::new(px, py, 0.0)
304    }
305
306    pub fn add_trauma(&mut self, amount: f32) {
307        self.trauma = (self.trauma + amount).min(1.0);
308    }
309
310    // ── Update ────────────────────────────────────────────────────────────
311
312    /// Advance camera by `dt` seconds.  Returns the updated view matrix.
313    pub fn update(&mut self, dt: f32, fly_input: Option<&FreeFlyInput>) -> Mat4 {
314        self.shake_time += dt;
315        self.trauma = (self.trauma - self.trauma_decay * dt).max(0.0);
316
317        if self.mode == CameraMode::FreeFly {
318            self.update_free_fly(dt, fly_input);
319        } else {
320            // Spring-damp to targets
321            let k = self.spring_k;
322            let d = self.spring_damp;
323            self.focal_point = spring_damp_vec3(self.focal_point, self.target_focal, &mut self.vel_focal, k, d, dt);
324            self.azimuth  = spring_damp_f32(self.azimuth,  self.target_az,   &mut self.vel_az,   k, d, dt);
325            self.elevation= spring_damp_f32(self.elevation,self.target_el,   &mut self.vel_el,   k, d, dt);
326            self.distance = spring_damp_f32(self.distance, self.target_dist, &mut self.vel_dist, k, d, dt);
327            self.elevation = self.elevation.clamp(-FRAC_PI_2 + 0.01, FRAC_PI_2 - 0.01);
328        }
329        self.view_matrix()
330    }
331
332    fn update_free_fly(&mut self, dt: f32, input: Option<&FreeFlyInput>) {
333        let Some(inp) = input else { return; };
334        let boost = if inp.boost { 3.0 } else { 1.0 };
335        let spd = self.fly_speed * boost;
336        let rot = Quat::from_euler(glam::EulerRot::YXZ, self.yaw, self.pitch, 0.0);
337        let forward = rot.mul_vec3(-Vec3::Z);
338        let right   = rot.mul_vec3( Vec3::X);
339        let up      = Vec3::Y;
340
341        let mut accel = Vec3::ZERO;
342        if inp.forward  { accel += forward; }
343        if inp.backward { accel -= forward; }
344        if inp.right    { accel += right; }
345        if inp.left     { accel -= right; }
346        if inp.up       { accel += up; }
347        if inp.down     { accel -= up; }
348        if accel.length() > 0.0 { accel = accel.normalize() * spd; }
349
350        let drag = 8.0;
351        self.fly_vel = self.fly_vel + (accel - self.fly_vel * drag) * dt;
352        self.position += self.fly_vel * dt;
353
354        self.yaw   -= inp.mouse_dx * 0.003;
355        self.pitch -= inp.mouse_dy * 0.003;
356        self.pitch  = self.pitch.clamp(-FRAC_PI_2 + 0.01, FRAC_PI_2 - 0.01);
357    }
358
359    // ── Orbit inputs ──────────────────────────────────────────────────────
360
361    pub fn orbit_drag(&mut self, delta: Vec2) {
362        self.target_az  -= delta.x * self.orbit_speed * 0.01;
363        self.target_el  += delta.y * self.orbit_speed * 0.01;
364        self.target_el   = self.target_el.clamp(-FRAC_PI_2 + 0.05, FRAC_PI_2 - 0.05);
365        self.last_snap = None;
366    }
367
368    pub fn pan_drag(&mut self, delta: Vec2) {
369        let right = self.right_vector();
370        let up    = self.up_vector();
371        let scale = self.pan_speed * self.distance * 0.001;
372        self.target_focal -= right * delta.x * scale;
373        self.target_focal += up    * delta.y * scale;
374    }
375
376    pub fn scroll_zoom(&mut self, delta: f32) {
377        self.target_dist *= (1.0 - delta * self.zoom_speed * 0.1).clamp(0.5, 2.0);
378        self.target_dist  = self.target_dist.clamp(0.05, 500.0);
379    }
380
381    pub fn dolly(&mut self, amount: f32) {
382        let forward = (self.focal_point - self.orbit_position()).normalize_or_zero();
383        self.target_focal += forward * amount;
384    }
385
386    pub fn frame_selection(&mut self, center: Vec3, radius: f32) {
387        self.target_focal = center;
388        let fov = match self.projection {
389            Projection::Perspective { fov_y_deg, .. } => fov_y_deg * PI / 180.0,
390            _ => 1.0,
391        };
392        self.target_dist = (radius / (fov * 0.5).tan()).max(radius * 1.5);
393    }
394
395    // ── Snap views ────────────────────────────────────────────────────────
396
397    pub fn snap_to(&mut self, view: SnapView) {
398        let (az, el) = view.angles();
399        self.target_az = az;
400        self.target_el = el;
401        // Instant snap (no spring) for numpad views
402        self.azimuth  = az;
403        self.elevation= el;
404        self.vel_az   = 0.0;
405        self.vel_el   = 0.0;
406        self.last_snap = Some(view);
407        // Switch to orthographic for canonical axis-aligned views
408        if self.snap_align {
409            match view {
410                SnapView::Front | SnapView::Back | SnapView::Left |
411                SnapView::Right | SnapView::Top  | SnapView::Bottom => {
412                    let (near, far) = match self.projection {
413                        Projection::Perspective  { near, far, .. } => (near, far),
414                        Projection::Orthographic { near, far, .. } => (near, far),
415                    };
416                    self.projection = Projection::Orthographic { size: self.distance, near, far };
417                }
418                _ => {}
419            }
420        }
421    }
422
423    // ── Mode switching ────────────────────────────────────────────────────
424
425    pub fn enter_free_fly(&mut self) {
426        self.mode = CameraMode::FreeFly;
427        self.position = self.orbit_position();
428        let fwd = (self.focal_point - self.position).normalize_or_zero();
429        self.yaw   = fwd.x.atan2(fwd.z);
430        self.pitch = fwd.y.asin().clamp(-FRAC_PI_2 + 0.01, FRAC_PI_2 - 0.01);
431        self.fly_vel = Vec3::ZERO;
432    }
433
434    pub fn exit_free_fly(&mut self) {
435        self.mode = CameraMode::Orbit;
436        // Reconstruct orbit parameters from free-fly position
437        let to_origin = self.focal_point - self.position;
438        self.distance  = to_origin.length();
439        self.azimuth   = to_origin.x.atan2(to_origin.z);
440        self.elevation = (to_origin.y / self.distance.max(1e-6)).asin();
441        self.target_az    = self.azimuth;
442        self.target_el    = self.elevation;
443        self.target_dist  = self.distance;
444    }
445
446    // ── Vectors ───────────────────────────────────────────────────────────
447
448    pub fn forward_vector(&self) -> Vec3 {
449        (self.focal_point - self.orbit_position()).normalize_or_zero()
450    }
451
452    pub fn right_vector(&self) -> Vec3 {
453        self.forward_vector().cross(Vec3::Y).normalize_or_zero()
454    }
455
456    pub fn up_vector(&self) -> Vec3 {
457        self.right_vector().cross(self.forward_vector()).normalize_or_zero()
458    }
459
460    // ── Raycasting ───────────────────────────────────────────────────────
461
462    /// Compute a world-space ray from a normalised screen point (x, y) ∈ [-1,1].
463    pub fn screen_to_ray(&self, ndc: Vec2) -> (Vec3, Vec3) {
464        let inv_vp = self.view_proj().inverse();
465        let near = inv_vp.project_point3(ndc.extend(-1.0));
466        let far  = inv_vp.project_point3(ndc.extend( 1.0));
467        let origin = match self.mode {
468            CameraMode::FreeFly => self.position,
469            _ => self.orbit_position(),
470        };
471        (origin, (far - near).normalize_or_zero())
472    }
473
474    // ── Display ───────────────────────────────────────────────────────────
475
476    pub fn status_line(&self) -> String {
477        let pos = self.orbit_position();
478        let snap_str = self.last_snap.map(|s| s.label()).unwrap_or("-");
479        format!(
480            "Camera [{:?}] pos ({:.2},{:.2},{:.2}) focus ({:.2},{:.2},{:.2}) dist={:.2} az={:.1}° el={:.1}° snap={}",
481            self.mode,
482            pos.x, pos.y, pos.z,
483            self.focal_point.x, self.focal_point.y, self.focal_point.z,
484            self.distance,
485            self.azimuth.to_degrees(),
486            self.elevation.to_degrees(),
487            snap_str,
488        )
489    }
490}
491
492impl Default for EditorCamera { fn default() -> Self { Self::new() } }
493
494// ─────────────────────────────────────────────────────────────────────────────
495// Tests
496// ─────────────────────────────────────────────────────────────────────────────
497
498#[cfg(test)]
499mod tests {
500    use super::*;
501
502    #[test]
503    fn orbit_position_at_zero() {
504        let cam = EditorCamera::new();
505        // azimuth=0, elevation=0.2, distance=4
506        let pos = cam.orbit_position();
507        assert!(pos.length() > 0.0);
508    }
509
510    #[test]
511    fn view_matrix_not_nan() {
512        let cam = EditorCamera::new();
513        let m = cam.view_matrix();
514        assert!(m.col(0).x.is_finite());
515    }
516
517    #[test]
518    fn snap_front() {
519        let mut cam = EditorCamera::new();
520        cam.snap_to(SnapView::Front);
521        assert!((cam.azimuth).abs() < 1e-5);
522    }
523
524    #[test]
525    fn scroll_zoom_clamps() {
526        let mut cam = EditorCamera::new();
527        for _ in 0..100 { cam.scroll_zoom(10.0); }
528        cam.distance = cam.target_dist;
529        assert!(cam.distance >= 0.05);
530    }
531
532    #[test]
533    fn enter_exit_free_fly() {
534        let mut cam = EditorCamera::new();
535        let orig_dist = cam.distance;
536        cam.enter_free_fly();
537        assert_eq!(cam.mode, CameraMode::FreeFly);
538        cam.exit_free_fly();
539        assert_eq!(cam.mode, CameraMode::Orbit);
540        assert!((cam.distance - orig_dist).abs() < 0.1);
541    }
542
543    #[test]
544    fn spring_converges() {
545        let mut vel = 0.0f32;
546        let mut val = 0.0f32;
547        for _ in 0..60 {
548            val = spring_damp_f32(val, 1.0, &mut vel, 8.0, 6.0, 1.0/60.0);
549        }
550        assert!((val - 1.0).abs() < 0.01);
551    }
552
553    #[test]
554    fn shake_zero_trauma() {
555        let cam = EditorCamera::new();
556        let off = cam.shake_offset();
557        assert!(off.length() < 1e-5);
558    }
559
560    #[test]
561    fn screen_to_ray_centre() {
562        let cam = EditorCamera::new();
563        let (_, dir) = cam.screen_to_ray(Vec2::ZERO);
564        assert!(dir.length() > 0.9);
565    }
566}