1use glam::{Vec2, Vec3, Vec4, Mat4, Quat};
22use std::f32::consts::{PI, FRAC_PI_2};
23
24#[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#[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#[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 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#[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
155fn 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#[derive(Debug, Clone)]
177pub struct EditorCamera {
178 pub focal_point: Vec3,
180 pub azimuth: f32, pub elevation: f32, pub distance: f32, pub target_focal: Vec3,
186 pub target_az: f32,
187 pub target_el: f32,
188 pub target_dist: f32,
189
190 vel_focal: Vec3,
192 vel_az: f32,
193 vel_el: f32,
194 vel_dist: f32,
195
196 pub position: Vec3,
198 pub yaw: f32,
199 pub pitch: f32,
200 fly_vel: Vec3,
201
202 pub projection: Projection,
204 pub aspect: f32,
205
206 pub mode: CameraMode,
208
209 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 pub trauma: f32,
220 pub trauma_decay: f32,
221 shake_time: f32,
222
223 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 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 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 pub fn view_proj(&self) -> Mat4 {
292 self.projection.matrix(self.aspect) * self.view_matrix()
293 }
294
295 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 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 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 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 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 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 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 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 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 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 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 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#[cfg(test)]
499mod tests {
500 use super::*;
501
502 #[test]
503 fn orbit_position_at_zero() {
504 let cam = EditorCamera::new();
505 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}