1use crate::core::interaction::Modifiers;
7use crate::core::{ClipPolicy, DepthMode};
8use glam::{Mat4, Quat, Vec2, Vec3};
9
10#[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#[derive(Debug, Clone)]
51pub struct Camera {
52 pub position: Vec3,
54 pub target: Vec3,
55 pub up: Vec3,
56
57 pub projection: ProjectionType,
59 pub aspect_ratio: f32,
60 pub depth_mode: DepthMode,
62
63 pub zoom: f32,
65 pub rotation: Quat,
66
67 pub pan_sensitivity: f32,
69 pub zoom_sensitivity: f32,
70 pub rotate_sensitivity: f32,
71
72 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 pub fn new() -> Self {
87 let mut camera = Self {
88 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 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 position: Vec3::new(center_x, center_y, 1.0),
118 target: Vec3::new(center_x, center_y, 0.0),
119 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, view_matrix: Mat4::IDENTITY,
137 projection_matrix: Mat4::IDENTITY,
138 view_proj_dirty: true,
139 };
140 camera.update_matrices();
141 camera
142 }
143
144 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 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 pub fn mark_dirty(&mut self) {
160 self.view_proj_dirty = true;
161 }
162
163 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 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 pub fn pan(&mut self, delta: Vec2) {
181 let view = self.view_matrix();
183 let right = view.x_axis.truncate();
184 let up = view.y_axis.truncate();
185
186 let dist = (self.position - self.target).length().max(1e-3);
188 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 pub fn zoom(&mut self, delta: f32) {
201 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 let delta_vec = self.position - self.target;
213 let distance = delta_vec.length();
214 if !distance.is_finite() || distance < 1e-4 {
215 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 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 pub fn rotate(&mut self, delta: Vec2) {
247 if self.rotate_sensitivity == 0.0 {
248 return; }
250
251 let yaw = -delta.x * self.rotate_sensitivity;
258 let pitch = -delta.y * self.rotate_sensitivity;
259
260 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 let yaw_rot = Quat::from_axis_angle(world_up, yaw);
270 offset = yaw_rot * offset;
271
272 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 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 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 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 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; self.target = center;
340 let direction = (self.position - self.target).normalize();
341 self.position = self.target + direction * distance;
342
343 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; let width = size.x * (1.0 + margin);
362 let height = size.y * (1.0 + margin);
363
364 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 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 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 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 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 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 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 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 fn update_matrices(&mut self) {
482 self.view_matrix = Mat4::look_at_rh(self.position, self.target, self.up);
484
485 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 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 let m22 = near / nf;
501 let m32 = (near * far) / nf;
502 Mat4::from_cols_array(&[
503 m00, 0.0, 0.0, 0.0, 0.0, m11, 0.0, 0.0, 0.0, 0.0, m22, -1.0, 0.0, 0.0, m32, 0.0, ])
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#[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 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 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 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 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 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 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 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 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 let dx = -self.mouse_delta.x * (width / vw);
675 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 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 let delta_y = delta.y;
707
708 match &mut camera.projection {
709 ProjectionType::Perspective { .. } => {
710 if modifiers.shift {
711 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 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 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 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 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 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#[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 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}