1use crate::camera_projection::CameraProjection;
43use crate::input::InputEvent;
44use glam::{DMat4, DVec3, DVec4};
45use rustial_math::{Ellipsoid, GeoCoord, Globe, WorldCoord};
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
53pub enum CameraMode {
54 Orthographic,
56 #[default]
58 Perspective,
59}
60
61#[derive(Debug, Clone)]
86pub struct Camera {
87 target: GeoCoord,
89 projection: CameraProjection,
91 distance: f64,
93 pitch: f64,
95 yaw: f64,
98 mode: CameraMode,
100 fov_y: f64,
102 viewport_width: u32,
104 viewport_height: u32,
106}
107
108const MAX_PITCH: f64 = std::f64::consts::FRAC_PI_2 - 0.001;
110
111#[inline]
113fn normalize_yaw(yaw: f64) -> f64 {
114 let two_pi = std::f64::consts::TAU;
115 let mut y = yaw % two_pi;
116 if y > std::f64::consts::PI {
117 y -= two_pi;
118 }
119 if y < -std::f64::consts::PI {
120 y += two_pi;
121 }
122 y
123}
124
125impl Default for Camera {
126 fn default() -> Self {
127 Self {
128 target: GeoCoord::from_lat_lon(0.0, 0.0),
129 projection: CameraProjection::default(),
130 distance: 10_000_000.0,
131 pitch: 0.0,
132 yaw: 0.0,
133 mode: CameraMode::default(),
134 fov_y: std::f64::consts::FRAC_PI_4,
135 viewport_width: 800,
136 viewport_height: 600,
137 }
138 }
139}
140
141impl Camera {
142 fn sync_projection_state(&mut self) {
143 if matches!(
144 self.projection,
145 CameraProjection::VerticalPerspective { .. }
146 ) {
147 self.projection = CameraProjection::vertical_perspective(self.target, self.distance);
148 }
149 }
150
151 fn local_basis(&self) -> (DVec3, DVec3, DVec3) {
152 match self.projection {
153 CameraProjection::Globe => {
154 let lat = self.target.lat.to_radians();
155 let lon = self.target.lon.to_radians();
156 let (sin_lat, cos_lat) = lat.sin_cos();
157 let (sin_lon, cos_lon) = lon.sin_cos();
158
159 let east = DVec3::new(-sin_lon, cos_lon, 0.0);
160 let north = DVec3::new(-sin_lat * cos_lon, -sin_lat * sin_lon, cos_lat);
161 let up = DVec3::new(cos_lat * cos_lon, cos_lat * sin_lon, sin_lat);
162 (east, north, up)
163 }
164 _ => (DVec3::X, DVec3::Y, DVec3::Z),
165 }
166 }
167
168 fn view_up_from_eye(&self, eye: DVec3, target_world: DVec3) -> DVec3 {
169 const BLEND_RAD: f64 = 0.15;
170 let (sy, cy) = self.yaw.sin_cos();
171 let (east, north, _) = self.local_basis();
172
173 let yaw_up = east * sy + north * cy;
174 let right = east * cy - north * sy;
175 let look = (target_world - eye).normalize_or_zero();
176 let pitched_up = right.cross(look).normalize_or_zero();
177
178 let t = (self.pitch / BLEND_RAD).clamp(0.0, 1.0);
179 let up = (pitched_up * t + yaw_up * (1.0 - t)).normalize_or_zero();
180 if up.length_squared() < 0.5 {
181 DVec3::Z
182 } else {
183 up
184 }
185 }
186
187 fn screen_to_geo_on_globe(&self, px: f64, py: f64) -> Option<GeoCoord> {
188 let (origin, dir) = self.screen_to_ray(px, py);
189 let radius = Ellipsoid::WGS84.a;
190 let a = dir.dot(dir);
191 let b = 2.0 * origin.dot(dir);
192 let c = origin.dot(origin) - radius * radius;
193 let disc = b * b - 4.0 * a * c;
194 if disc < 0.0 {
195 return None;
196 }
197 let sqrt_disc = disc.sqrt();
198 let t0 = (-b - sqrt_disc) / (2.0 * a);
199 let t1 = (-b + sqrt_disc) / (2.0 * a);
200 let t = [t0, t1]
201 .into_iter()
202 .filter(|t| *t >= 0.0)
203 .min_by(|a, b| a.total_cmp(b))?;
204 let hit = origin + dir * t;
205 Some(Globe::unproject(&WorldCoord::new(hit.x, hit.y, hit.z)))
206 }
207
208 pub fn view_up_vector(&self) -> DVec3 {
210 let eye = self.eye_offset();
211 self.view_up_from_eye(eye, DVec3::ZERO)
212 }
213
214 #[inline]
218 pub fn target(&self) -> &GeoCoord {
219 &self.target
220 }
221
222 #[inline]
224 pub fn distance(&self) -> f64 {
225 self.distance
226 }
227
228 #[inline]
230 pub fn projection(&self) -> CameraProjection {
231 self.projection
232 }
233
234 #[inline]
236 pub fn pitch(&self) -> f64 {
237 self.pitch
238 }
239
240 #[inline]
242 pub fn yaw(&self) -> f64 {
243 self.yaw
244 }
245
246 #[inline]
248 pub fn mode(&self) -> CameraMode {
249 self.mode
250 }
251
252 #[inline]
254 pub fn fov_y(&self) -> f64 {
255 self.fov_y
256 }
257
258 #[inline]
260 pub fn viewport_width(&self) -> u32 {
261 self.viewport_width
262 }
263
264 #[inline]
266 pub fn viewport_height(&self) -> u32 {
267 self.viewport_height
268 }
269
270 #[inline]
274 pub fn set_target(&mut self, target: GeoCoord) {
275 self.target = target;
276 self.sync_projection_state();
277 }
278
279 #[inline]
281 pub fn set_projection(&mut self, projection: CameraProjection) {
282 self.projection = projection;
283 self.sync_projection_state();
284 }
285
286 pub fn set_distance(&mut self, d: f64) {
289 debug_assert!(
290 d.is_finite() && d > 0.0,
291 "Camera::set_distance: invalid {d}"
292 );
293 if d.is_finite() && d > 0.0 {
294 self.distance = d;
295 }
296 }
297
298 pub fn set_pitch(&mut self, p: f64) {
301 debug_assert!(p.is_finite(), "Camera::set_pitch: non-finite {p}");
302 if p.is_finite() {
303 self.pitch = p.clamp(0.0, MAX_PITCH);
304 }
305 }
306
307 pub fn set_yaw(&mut self, y: f64) {
310 debug_assert!(y.is_finite(), "Camera::set_yaw: non-finite {y}");
311 if y.is_finite() {
312 self.yaw = normalize_yaw(y);
313 }
314 }
315
316 pub fn set_mode(&mut self, mode: CameraMode) {
329 if mode == self.mode {
330 return;
331 }
332 let half_tan = (self.fov_y / 2.0).tan();
333 match (self.mode, mode) {
334 (CameraMode::Perspective, CameraMode::Orthographic) => {
335 self.distance *= half_tan;
336 }
337 (CameraMode::Orthographic, CameraMode::Perspective) => {
338 if half_tan.abs() > 1e-12 {
339 self.distance /= half_tan;
340 }
341 }
342 _ => {}
343 }
344 self.mode = mode;
345 }
346
347 pub fn set_fov_y(&mut self, fov: f64) {
350 debug_assert!(
351 fov.is_finite() && fov > 0.0,
352 "Camera::set_fov_y: invalid {fov}"
353 );
354 if fov.is_finite() && fov > 0.0 {
355 self.fov_y = fov;
356 }
357 }
358
359 #[inline]
361 pub fn set_viewport(&mut self, width: u32, height: u32) {
362 self.viewport_width = width;
363 self.viewport_height = height;
364 }
365
366 pub fn eye_offset(&self) -> DVec3 {
381 let (sp, cp) = self.pitch.sin_cos();
382 let (sy, cy) = self.yaw.sin_cos();
383 let (east, north, up) = self.local_basis();
384 east * (-self.distance * sp * sy)
385 + north * (-self.distance * sp * cy)
386 + up * (self.distance * cp)
387 }
388
389 pub fn view_matrix(&self, target_world: DVec3) -> DMat4 {
426 let eye = target_world + self.eye_offset();
427 let up = self.view_up_from_eye(eye, target_world);
428
429 DMat4::look_at_rh(eye, target_world, up)
430 }
431
432 pub fn perspective_matrix(&self) -> DMat4 {
443 let aspect = self.viewport_width as f64 / self.viewport_height.max(1) as f64;
444 let near = self.distance * 0.001;
445 let pitch_far_scale = if self.pitch > 0.01 {
446 (1.0 / self.pitch.cos().abs().max(0.05)).min(100.0)
447 } else {
448 1.0
449 };
450 let far = self.distance * 10.0 * pitch_far_scale;
451 DMat4::perspective_rh(self.fov_y, aspect, near, far)
452 }
453
454 pub fn orthographic_matrix(&self) -> DMat4 {
460 let half_h = self.distance;
461 let aspect = self.viewport_width as f64 / self.viewport_height.max(1) as f64;
462 let half_w = half_h * aspect;
463 let near = -self.distance * 100.0;
464 let far = self.distance * 100.0;
465 DMat4::orthographic_rh(-half_w, half_w, -half_h, half_h, near, far)
466 }
467
468 pub fn projection_matrix(&self) -> DMat4 {
470 match self.mode {
471 CameraMode::Perspective => self.perspective_matrix(),
472 CameraMode::Orthographic => self.orthographic_matrix(),
473 }
474 }
475
476 pub fn target_world(&self) -> DVec3 {
480 self.projection.project(&self.target).position
481 }
482
483 pub fn view_projection_matrix(&self) -> DMat4 {
485 let target_world = self.target_world();
486 self.projection_matrix() * self.view_matrix(target_world)
487 }
488
489 pub fn absolute_view_projection_matrix(&self) -> DMat4 {
498 let target_world = self.target_world();
499 self.projection_matrix() * self.view_matrix(target_world)
500 }
501
502 pub fn covering_camera(&self, fractional_zoom: f64) -> Option<rustial_math::CoveringCamera> {
508 if self.projection != CameraProjection::WebMercator {
509 return None;
510 }
511 if self.mode != CameraMode::Perspective {
512 return None;
513 }
514
515 let world_size = rustial_math::WebMercator::world_size();
516 let target_world = self.target_world();
517 let eye = target_world + self.eye_offset();
518
519 let half = world_size * 0.5;
521 let cam_x = (eye.x + half) / world_size;
522 let cam_y = (half - eye.y) / world_size;
523 let center_x = (target_world.x + half) / world_size;
524 let center_y = (half - target_world.y) / world_size;
525
526 let cam_to_center_z = eye.z / world_size;
527
528 Some(rustial_math::CoveringCamera {
529 camera_x: cam_x,
530 camera_y: cam_y,
531 camera_to_center_z: cam_to_center_z.abs(),
532 center_x,
533 center_y,
534 pitch_rad: self.pitch,
535 fov_deg: self.fov_y.to_degrees(),
536 zoom: fractional_zoom,
537 display_tile_size: 256,
538 })
539 }
540
541 pub fn flat_tile_view(&self) -> Option<rustial_math::FlatTileView> {
546 if self.projection != CameraProjection::WebMercator {
547 return None;
548 }
549
550 match self.mode {
551 CameraMode::Perspective => Some(rustial_math::FlatTileView::new(
552 rustial_math::WorldCoord::new(
553 self.target_world().x,
554 self.target_world().y,
555 self.target_world().z,
556 ),
557 self.distance,
558 self.pitch,
559 self.yaw,
560 self.fov_y,
561 self.viewport_width,
562 self.viewport_height,
563 )),
564 CameraMode::Orthographic => None,
565 }
566 }
567
568 pub fn screen_to_ray(&self, px: f64, py: f64) -> (DVec3, DVec3) {
581 let w = self.viewport_width.max(1) as f64;
582 let h = self.viewport_height.max(1) as f64;
583
584 let target_world = self.target_world();
585 let view = self.view_matrix(target_world);
586 let proj = self.projection_matrix();
587 let vp_inv = (proj * view).inverse();
588
589 let ndc_x = (2.0 * px / w) - 1.0;
591 let ndc_y = 1.0 - (2.0 * py / h);
592
593 let near_ndc = DVec4::new(ndc_x, ndc_y, -1.0, 1.0);
594 let far_ndc = DVec4::new(ndc_x, ndc_y, 1.0, 1.0);
595
596 let near_world = vp_inv * near_ndc;
597 let far_world = vp_inv * far_ndc;
598
599 if near_world.w.abs() < 1e-12 || far_world.w.abs() < 1e-12 {
601 return (DVec3::ZERO, -DVec3::Z);
602 }
603
604 let near = DVec3::new(
605 near_world.x / near_world.w,
606 near_world.y / near_world.w,
607 near_world.z / near_world.w,
608 );
609 let far = DVec3::new(
610 far_world.x / far_world.w,
611 far_world.y / far_world.w,
612 far_world.z / far_world.w,
613 );
614
615 let dir = (far - near).normalize();
616 if dir.is_nan() {
617 return (DVec3::ZERO, -DVec3::Z);
618 }
619 (near, dir)
620 }
621
622 pub fn screen_to_geo(&self, px: f64, py: f64) -> Option<GeoCoord> {
628 if matches!(self.projection, CameraProjection::Globe) {
629 return self.screen_to_geo_on_globe(px, py);
630 }
631
632 let (origin, dir) = self.screen_to_ray(px, py);
633
634 if dir.z.abs() < 1e-12 {
636 return None; }
638 let t = -origin.z / dir.z;
639 if t < 0.0 {
640 return None; }
642
643 let hit = origin + dir * t;
644 let world = rustial_math::WorldCoord::new(hit.x, hit.y, 0.0);
645 Some(self.projection.unproject(&world))
646 }
647
648 pub fn geo_to_screen(&self, geo: &GeoCoord) -> Option<(f64, f64)> {
654 let w = self.viewport_width.max(1) as f64;
655 let h = self.viewport_height.max(1) as f64;
656
657 let world_pos = self.projection.project(geo);
658 let target_world = self.target_world();
659 let view = self.view_matrix(target_world);
660 let proj = self.projection_matrix();
661 let vp = proj * view;
662
663 let clip = vp
664 * DVec4::new(
665 world_pos.position.x,
666 world_pos.position.y,
667 world_pos.position.z,
668 1.0,
669 );
670
671 if clip.w <= 0.0 {
673 return None;
674 }
675
676 let ndc_x = clip.x / clip.w;
677 let ndc_y = clip.y / clip.w;
678
679 let px = (ndc_x + 1.0) * 0.5 * w;
681 let py = (1.0 - ndc_y) * 0.5 * h;
682
683 Some((px, py))
684 }
685
686 pub fn meters_per_pixel(&self) -> f64 {
694 let visible_height = match self.mode {
695 CameraMode::Perspective => 2.0 * self.distance * (self.fov_y / 2.0).tan(),
696 CameraMode::Orthographic => 2.0 * self.distance,
697 };
698 visible_height / self.viewport_height.max(1) as f64
699 }
700
701 pub fn near_meters_per_pixel(&self) -> f64 {
719 let center_mpp = self.meters_per_pixel();
720
721 if self.pitch.abs() < 0.01 {
722 return center_mpp;
723 }
724
725 match self.mode {
726 CameraMode::Orthographic => center_mpp,
727 CameraMode::Perspective => {
728 let h = self.distance * self.pitch.cos();
730 if h <= 0.0 {
731 return center_mpp;
732 }
733
734 let half_fov = self.fov_y / 2.0;
738 let near_angle = (self.pitch - half_fov).max(0.01);
739
740 let rad_per_px = self.fov_y / self.viewport_height.max(1) as f64;
744 let cos_near = near_angle.cos();
745 let near_mpp = h * rad_per_px / (cos_near * cos_near);
746
747 near_mpp.clamp(center_mpp * 0.125, center_mpp)
752 }
753 }
754 }
755
756 }
758
759#[derive(Debug, Clone)]
768pub struct CameraConstraints {
769 pub min_distance: f64,
771 pub max_distance: f64,
773 pub min_pitch: f64,
775 pub max_pitch: f64,
777}
778
779impl Default for CameraConstraints {
780 fn default() -> Self {
781 Self {
782 min_distance: 1.0,
783 max_distance: 40_000_000.0,
784 min_pitch: 0.0,
785 max_pitch: std::f64::consts::FRAC_PI_2 - 0.01,
786 }
787 }
788}
789
790pub struct CameraController;
800
801impl CameraController {
802 fn retarget_for_screen_anchor(camera: &mut Camera, desired: GeoCoord, actual: GeoCoord) {
803 if matches!(
804 camera.projection(),
805 CameraProjection::Globe | CameraProjection::VerticalPerspective { .. }
806 ) {
807 let mut target = *camera.target();
808 target.lat = (target.lat + (desired.lat - actual.lat)).clamp(-90.0, 90.0);
809 let lon_delta = desired.lon - actual.lon;
810 let mut lon = target.lon + lon_delta;
811 lon = ((lon + 180.0) % 360.0 + 360.0) % 360.0 - 180.0;
812 target.lon = lon;
813 camera.set_target(target);
814 return;
815 }
816
817 let desired = camera.projection().project(&desired);
818 let actual = camera.projection().project(&actual);
819 let current = camera.projection().project(camera.target());
820
821 let shift_x = actual.position.x - desired.position.x;
822 let shift_y = actual.position.y - desired.position.y;
823
824 let extent = camera.projection().max_extent();
825 let full = camera.projection().world_size();
826 let mut new_x = current.position.x - shift_x;
827 let new_y = (current.position.y - shift_y).clamp(-extent, extent);
828 new_x = ((new_x + extent) % full + full) % full - extent;
829
830 camera.set_target(camera.projection().unproject(&WorldCoord::new(
831 new_x,
832 new_y,
833 current.position.z,
834 )));
835 }
836
837 pub fn zoom(
841 camera: &mut Camera,
842 factor: f64,
843 cursor_x: Option<f64>,
844 cursor_y: Option<f64>,
845 constraints: &CameraConstraints,
846 ) {
847 if !factor.is_finite() || factor <= 0.0 {
848 return;
849 }
850
851 let anchor = match (cursor_x, cursor_y) {
852 (Some(x), Some(y)) => camera.screen_to_geo(x, y).map(|geo| (x, y, geo)),
853 _ => None,
854 };
855
856 camera.set_distance(
857 (camera.distance() / factor).clamp(constraints.min_distance, constraints.max_distance),
858 );
859
860 if let Some((x, y, desired)) = anchor {
861 if let Some(actual) = camera.screen_to_geo(x, y) {
862 Self::retarget_for_screen_anchor(camera, desired, actual);
863 }
864 }
865 }
866
867 pub fn rotate(
872 camera: &mut Camera,
873 delta_yaw: f64,
874 delta_pitch: f64,
875 constraints: &CameraConstraints,
876 ) {
877 camera.set_yaw(camera.yaw() + delta_yaw);
878 camera.set_pitch(
879 (camera.pitch() + delta_pitch).clamp(constraints.min_pitch, constraints.max_pitch),
880 );
881 }
882
883 pub fn pan(
885 camera: &mut Camera,
886 dx: f64,
887 dy: f64,
888 cursor_x: Option<f64>,
889 cursor_y: Option<f64>,
890 ) {
891 let px = cursor_x.unwrap_or(camera.viewport_width() as f64 * 0.5);
892 let py = cursor_y.unwrap_or(camera.viewport_height() as f64 * 0.5);
893
894 if matches!(
895 camera.projection(),
896 CameraProjection::Globe | CameraProjection::VerticalPerspective { .. }
897 ) {
898 if let (Some(geo_a), Some(geo_b)) = (
899 camera.screen_to_geo(px, py),
900 camera.screen_to_geo(px + dx, py + dy),
901 ) {
902 Self::retarget_for_screen_anchor(camera, geo_a, geo_b);
903 return;
904 }
905 }
906
907 if let (Some(geo_a), Some(geo_b)) = (
908 camera.screen_to_geo(px, py),
909 camera.screen_to_geo(px + dx, py + dy),
910 ) {
911 Self::retarget_for_screen_anchor(camera, geo_a, geo_b);
912 return;
913 }
914
915 let mpp = camera.meters_per_pixel();
917 let (sy, cy) = camera.yaw().sin_cos();
918
919 let world_dx = (dx * cy + dy * sy) * mpp;
920 let world_dy = (-dx * sy + dy * cy) * mpp;
921
922 let current = camera.projection.project(camera.target());
923 let mut new_x = current.position.x - world_dx;
924 let mut new_y = current.position.y + world_dy;
925
926 let extent = camera.projection.max_extent();
927 let full = camera.projection.world_size();
928 new_x = ((new_x + extent) % full + full) % full - extent;
929 new_y = new_y.clamp(-extent, extent);
930
931 camera.set_target(camera.projection.unproject(&WorldCoord::new(
932 new_x,
933 new_y,
934 current.position.z,
935 )));
936 }
937
938 pub fn handle_event(camera: &mut Camera, event: InputEvent, constraints: &CameraConstraints) {
945 match event {
946 InputEvent::Pan { dx, dy, x, y } => Self::pan(camera, dx, dy, x, y),
947 InputEvent::Zoom { factor, x, y } => Self::zoom(camera, factor, x, y, constraints),
948 InputEvent::Rotate {
949 delta_yaw,
950 delta_pitch,
951 } => Self::rotate(camera, delta_yaw, delta_pitch, constraints),
952 InputEvent::Resize { width, height } => {
953 camera.set_viewport(width, height);
954 }
955 InputEvent::Touch(_) => {
956 }
959 }
960 }
961}
962
963#[cfg(test)]
964mod tests {
965 use super::*;
966
967 #[test]
970 fn default_camera_top_down() {
971 let cam = Camera::default();
972 let offset = cam.eye_offset();
973 assert!(offset.x.abs() < 1e-6);
974 assert!(offset.y.abs() < 1e-6);
975 assert!((offset.z - cam.distance()).abs() < 1e-6);
976 }
977
978 #[test]
979 fn eye_offset_pitched_yaw_zero() {
980 let mut cam = Camera::default();
981 cam.set_pitch(std::f64::consts::FRAC_PI_4);
982 cam.set_distance(100.0);
983 let offset = cam.eye_offset();
984 assert!(offset.x.abs() < 1e-6, "x should be ~0, got {}", offset.x);
985 assert!(offset.y < -1.0, "y should be negative, got {}", offset.y);
986 assert!(offset.z > 1.0, "z should be positive, got {}", offset.z);
987 }
988
989 #[test]
990 fn eye_offset_pitched_yaw_90() {
991 let mut cam = Camera::default();
992 cam.set_pitch(std::f64::consts::FRAC_PI_4);
993 cam.set_yaw(std::f64::consts::FRAC_PI_2);
994 cam.set_distance(100.0);
995 let offset = cam.eye_offset();
996 assert!(offset.x < -1.0, "x should be negative for east-facing");
997 assert!(offset.y.abs() < 1e-6, "y should be ~0");
998 assert!(offset.z > 1.0, "z should be positive");
999 }
1000
1001 #[test]
1004 fn view_matrix_no_flip_through_pitch_range() {
1005 let mut cam = Camera::default();
1006 cam.set_distance(1000.0);
1007
1008 let target = DVec3::ZERO;
1009 let steps = 100;
1010 let max_pitch = std::f64::consts::FRAC_PI_2 - 0.02;
1011
1012 for i in 0..=steps {
1013 cam.set_pitch(max_pitch * (i as f64 / steps as f64));
1014 let view = cam.view_matrix(target);
1015 let eye = target + cam.eye_offset();
1016 assert!(
1017 eye.z > 0.0,
1018 "eye should be above ground at pitch={:.3}",
1019 cam.pitch()
1020 );
1021 for col in 0..4 {
1022 let c = view.col(col);
1023 assert!(
1024 c.x.is_finite() && c.y.is_finite() && c.z.is_finite() && c.w.is_finite(),
1025 "non-finite view matrix at pitch={:.3}",
1026 cam.pitch()
1027 );
1028 }
1029 }
1030 }
1031
1032 #[test]
1033 fn view_matrix_stable_through_yaw_range() {
1034 let mut cam = Camera::default();
1035 cam.set_distance(1000.0);
1036 cam.set_pitch(0.5);
1037
1038 let target = DVec3::ZERO;
1039 for i in 0..=36 {
1040 cam.set_yaw((i as f64 / 36.0) * std::f64::consts::TAU);
1041 let view = cam.view_matrix(target);
1042 for col in 0..4 {
1043 let c = view.col(col);
1044 assert!(
1045 c.x.is_finite() && c.y.is_finite() && c.z.is_finite() && c.w.is_finite(),
1046 "non-finite view matrix at yaw={:.3}",
1047 cam.yaw()
1048 );
1049 }
1050 }
1051 }
1052
1053 #[test]
1054 fn view_matrix_no_north_south_flip_at_yaw_pi() {
1055 let mut cam = Camera::default();
1056 cam.set_distance(1000.0);
1057 cam.set_yaw(std::f64::consts::PI);
1058
1059 let target = DVec3::ZERO;
1060 let steps = 50;
1061 let max_pitch = std::f64::consts::FRAC_PI_2 - 0.05;
1062 let mut prev_right_x: Option<f64> = None;
1063
1064 for i in 0..=steps {
1065 cam.set_pitch(max_pitch * (i as f64 / steps as f64));
1066 let view = cam.view_matrix(target);
1067 let right_x = view.col(0).x;
1068 if let Some(prev) = prev_right_x {
1069 assert!(
1070 right_x * prev > -1e-6,
1071 "screen-right flipped sign at pitch={:.3}: was {prev:.4}, now {right_x:.4}",
1072 cam.pitch()
1073 );
1074 }
1075 prev_right_x = Some(right_x);
1076 }
1077 }
1078
1079 #[test]
1082 fn zoom_clamp() {
1083 let mut cam = Camera::default();
1084 let constraints = CameraConstraints::default();
1085 CameraController::zoom(&mut cam, 1e20, None, None, &constraints);
1086 assert!(cam.distance() >= constraints.min_distance);
1087 CameraController::zoom(&mut cam, 1e-20, None, None, &constraints);
1088 assert!(cam.distance() <= constraints.max_distance);
1089 }
1090
1091 #[test]
1092 fn zoom_nan_ignored() {
1093 let mut cam = Camera::default();
1094 let original = cam.distance();
1095 let constraints = CameraConstraints::default();
1096 CameraController::zoom(&mut cam, f64::NAN, None, None, &constraints);
1097 assert_eq!(cam.distance(), original);
1098 }
1099
1100 #[test]
1101 fn zoom_zero_ignored() {
1102 let mut cam = Camera::default();
1103 let original = cam.distance();
1104 let constraints = CameraConstraints::default();
1105 CameraController::zoom(&mut cam, 0.0, None, None, &constraints);
1106 assert_eq!(cam.distance(), original);
1107 }
1108
1109 #[test]
1110 fn zoom_negative_ignored() {
1111 let mut cam = Camera::default();
1112 let original = cam.distance();
1113 let constraints = CameraConstraints::default();
1114 CameraController::zoom(&mut cam, -2.0, None, None, &constraints);
1115 assert_eq!(cam.distance(), original);
1116 }
1117
1118 #[test]
1119 fn zoom_infinity_ignored() {
1120 let mut cam = Camera::default();
1121 let original = cam.distance();
1122 let constraints = CameraConstraints::default();
1123 CameraController::zoom(&mut cam, f64::INFINITY, None, None, &constraints);
1124 assert_eq!(cam.distance(), original);
1125 }
1126
1127 #[test]
1128 fn zoom_around_center_keeps_target_stable() {
1129 let mut cam = Camera::default();
1130 cam.set_target(GeoCoord::from_lat_lon(51.1, 17.0));
1131 cam.set_distance(100_000.0);
1132 cam.set_viewport(800, 600);
1133 let before = *cam.target();
1134 let constraints = CameraConstraints::default();
1135
1136 CameraController::zoom(&mut cam, 1.1, Some(400.0), Some(300.0), &constraints);
1137
1138 let after = *cam.target();
1139 assert!((after.lat - before.lat).abs() < 1e-6);
1140 assert!((after.lon - before.lon).abs() < 1e-6);
1141 }
1142
1143 #[test]
1144 fn zoom_around_cursor_preserves_anchor_location() {
1145 let mut cam = Camera::default();
1146 cam.set_target(GeoCoord::from_lat_lon(51.1, 17.0));
1147 cam.set_distance(100_000.0);
1148 cam.set_viewport(800, 600);
1149 let constraints = CameraConstraints::default();
1150 let desired = cam.screen_to_geo(650.0, 420.0).expect("anchor before zoom");
1151
1152 CameraController::zoom(&mut cam, 1.1, Some(650.0), Some(420.0), &constraints);
1153
1154 let actual = cam.screen_to_geo(650.0, 420.0).expect("anchor after zoom");
1155 assert!((actual.lat - desired.lat).abs() < 1e-4);
1156 assert!((actual.lon - desired.lon).abs() < 1e-4);
1157 assert!((cam.target().lat - 51.1).abs() > 1e-5 || (cam.target().lon - 17.0).abs() > 1e-5);
1158 }
1159
1160 #[test]
1163 fn perspective_matrix_not_zero() {
1164 let cam = Camera::default();
1165 let m = cam.perspective_matrix();
1166 assert!(m.col(0).x.abs() > 0.0);
1167 }
1168
1169 #[test]
1170 fn orthographic_matrix_not_zero() {
1171 let mut cam = Camera::default();
1172 cam.set_mode(CameraMode::Orthographic);
1173 let m = cam.orthographic_matrix();
1174 assert!(m.col(0).x.abs() > 0.0);
1175 }
1176
1177 #[test]
1178 fn projection_matrix_matches_mode() {
1179 let mut cam = Camera::default();
1180 cam.set_mode(CameraMode::Perspective);
1181 let p = cam.projection_matrix();
1182 assert_eq!(p, cam.perspective_matrix());
1183
1184 cam.set_mode(CameraMode::Orthographic);
1185 let o = cam.projection_matrix();
1186 assert_eq!(o, cam.orthographic_matrix());
1187 }
1188
1189 #[test]
1190 fn far_plane_grows_with_pitch() {
1191 let mut cam = Camera::default();
1192 cam.set_distance(10_000.0);
1193 let m0 = cam.perspective_matrix();
1194
1195 cam.set_pitch(1.2);
1196 let m1 = cam.perspective_matrix();
1197
1198 let depth0 = m0.col(2).z;
1199 let depth1 = m1.col(2).z;
1200 assert!(
1201 (depth1 - depth0).abs() > 1e-6,
1202 "far plane should differ with pitch"
1203 );
1204 }
1205
1206 #[test]
1207 fn perspective_matrix_finite_at_max_pitch() {
1208 let mut cam = Camera::default();
1209 cam.set_pitch(std::f64::consts::FRAC_PI_2 - 0.01);
1210 cam.set_distance(10_000.0);
1211 let m = cam.perspective_matrix();
1212 for col in 0..4 {
1213 let c = m.col(col);
1214 assert!(
1215 c.x.is_finite() && c.y.is_finite() && c.z.is_finite() && c.w.is_finite(),
1216 "perspective matrix should be finite at max pitch"
1217 );
1218 }
1219 }
1220
1221 #[test]
1224 fn screen_to_geo_center_returns_target() {
1225 let mut cam = Camera::default();
1226 cam.set_target(GeoCoord::from_lat_lon(51.1, 17.0));
1227 cam.set_distance(100_000.0);
1228 cam.set_viewport(800, 600);
1229 let geo = cam.screen_to_geo(400.0, 300.0);
1230 assert!(geo.is_some(), "center of screen should hit ground");
1231 let geo = geo.expect("center hit");
1232 assert!(
1233 (geo.lat - 51.1).abs() < 0.1,
1234 "lat should be near 51.1, got {}",
1235 geo.lat
1236 );
1237 assert!(
1238 (geo.lon - 17.0).abs() < 0.1,
1239 "lon should be near 17.0, got {}",
1240 geo.lon
1241 );
1242 }
1243
1244 #[test]
1245 fn screen_to_geo_off_center_differs() {
1246 let mut cam = Camera::default();
1247 cam.set_distance(100_000.0);
1248 cam.set_viewport(800, 600);
1249 let center = cam.screen_to_geo(400.0, 300.0).expect("center");
1250 let corner = cam.screen_to_geo(0.0, 0.0).expect("corner");
1251 let dist = ((center.lat - corner.lat).powi(2) + (center.lon - corner.lon).powi(2)).sqrt();
1252 assert!(dist > 0.01, "corner and center should differ");
1253 }
1254
1255 #[test]
1256 fn screen_to_ray_direction_is_normalized() {
1257 let cam = Camera::default();
1258 let (_, dir) = cam.screen_to_ray(400.0, 300.0);
1259 assert!(
1260 (dir.length() - 1.0).abs() < 1e-6,
1261 "direction should be unit length"
1262 );
1263 }
1264
1265 #[test]
1266 fn screen_to_ray_degenerate_viewport() {
1267 let mut cam = Camera::default();
1268 cam.set_viewport(0, 0);
1269 let (origin, dir) = cam.screen_to_ray(0.0, 0.0);
1270 assert!(origin.x.is_finite());
1271 assert!(dir.z.is_finite());
1272 }
1273
1274 #[test]
1275 fn screen_to_geo_horizon_returns_none() {
1276 let mut cam = Camera::default();
1277 cam.set_pitch(std::f64::consts::FRAC_PI_2 - 0.02);
1278 cam.set_distance(10_000.0);
1279 cam.set_viewport(800, 600);
1280 let result = cam.screen_to_geo(400.0, 0.0);
1281 if let Some(geo) = result {
1282 assert!(geo.lat.is_finite());
1283 assert!(geo.lon.is_finite());
1284 }
1285 }
1286
1287 #[test]
1290 fn meters_per_pixel_positive() {
1291 let cam = Camera::default();
1292 assert!(cam.meters_per_pixel() > 0.0);
1293 }
1294
1295 #[test]
1296 fn meters_per_pixel_decreases_with_zoom() {
1297 let mut cam = Camera::default();
1298 let mpp_far = cam.meters_per_pixel();
1299 cam.set_distance(1_000.0);
1300 let mpp_close = cam.meters_per_pixel();
1301 assert!(mpp_close < mpp_far);
1302 }
1303
1304 #[test]
1305 fn meters_per_pixel_ortho_vs_perspective() {
1306 let mut cam = Camera::default();
1307 cam.set_mode(CameraMode::Perspective);
1308 let mpp_persp = cam.meters_per_pixel();
1309 cam.set_mode(CameraMode::Orthographic);
1310 let mpp_ortho = cam.meters_per_pixel();
1311 assert!(mpp_persp > 0.0 && mpp_persp.is_finite());
1312 assert!(mpp_ortho > 0.0 && mpp_ortho.is_finite());
1313 }
1314
1315 #[test]
1316 fn set_mode_preserves_meters_per_pixel() {
1317 let mut cam = Camera::default();
1318 cam.set_distance(100_000.0);
1319 cam.set_viewport(1280, 720);
1320
1321 cam.set_mode(CameraMode::Perspective);
1322 let mpp_before = cam.meters_per_pixel();
1323
1324 cam.set_mode(CameraMode::Orthographic);
1325 let mpp_after = cam.meters_per_pixel();
1326
1327 assert!(
1328 (mpp_before - mpp_after).abs() / mpp_before < 1e-10,
1329 "meters_per_pixel should be preserved: perspective={mpp_before}, orthographic={mpp_after}"
1330 );
1331
1332 cam.set_mode(CameraMode::Perspective);
1334 let mpp_roundtrip = cam.meters_per_pixel();
1335 assert!(
1336 (mpp_before - mpp_roundtrip).abs() / mpp_before < 1e-10,
1337 "meters_per_pixel should survive round-trip: original={mpp_before}, roundtrip={mpp_roundtrip}"
1338 );
1339 }
1340
1341 #[test]
1342 fn target_world_uses_selected_projection() {
1343 let mut cam = Camera::default();
1344 cam.set_target(GeoCoord::from_lat_lon(45.0, 10.0));
1345
1346 let merc = cam.target_world();
1347 cam.set_projection(CameraProjection::Equirectangular);
1348 let eq = cam.target_world();
1349
1350 assert!((merc.x - eq.x).abs() < 1e-6);
1351 assert!((merc.y - eq.y).abs() > 1_000.0);
1352 }
1353
1354 #[test]
1355 fn screen_to_geo_center_respects_equirectangular_projection() {
1356 let mut cam = Camera::default();
1357 cam.set_projection(CameraProjection::Equirectangular);
1358 cam.set_target(GeoCoord::from_lat_lon(30.0, 20.0));
1359 cam.set_distance(100_000.0);
1360 cam.set_viewport(800, 600);
1361
1362 let geo = cam.screen_to_geo(400.0, 300.0).expect("center hit");
1363 assert!((geo.lat - 30.0).abs() < 0.1);
1364 assert!((geo.lon - 20.0).abs() < 0.1);
1365 }
1366
1367 #[test]
1368 fn screen_to_geo_center_respects_globe_projection() {
1369 let mut cam = Camera::default();
1370 cam.set_projection(CameraProjection::Globe);
1371 cam.set_target(GeoCoord::from_lat_lon(30.0, 20.0));
1372 cam.set_distance(3_000_000.0);
1373 cam.set_viewport(800, 600);
1374
1375 let geo = cam.screen_to_geo(400.0, 300.0).expect("center hit");
1376 assert!((geo.lat - 30.0).abs() < 0.1);
1377 assert!((geo.lon - 20.0).abs() < 0.1);
1378 }
1379
1380 #[test]
1381 fn screen_to_geo_center_respects_vertical_perspective_projection() {
1382 let mut cam = Camera::default();
1383 cam.set_target(GeoCoord::from_lat_lon(30.0, 20.0));
1384 cam.set_distance(3_000_000.0);
1385 cam.set_projection(CameraProjection::vertical_perspective(
1386 *cam.target(),
1387 cam.distance(),
1388 ));
1389 cam.set_viewport(800, 600);
1390
1391 let geo = cam.screen_to_geo(400.0, 300.0).expect("center hit");
1392 assert!((geo.lat - 30.0).abs() < 0.1);
1393 assert!((geo.lon - 20.0).abs() < 0.1);
1394 }
1395
1396 #[test]
1397 fn pan_moves_target_under_globe_projection() {
1398 let mut cam = Camera::default();
1399 cam.set_projection(CameraProjection::Globe);
1400 cam.set_target(GeoCoord::from_lat_lon(10.0, 10.0));
1401 cam.set_distance(3_000_000.0);
1402 cam.set_viewport(800, 600);
1403
1404 let before = *cam.target();
1405 CameraController::pan(&mut cam, 100.0, 0.0, None, None);
1406 let after = *cam.target();
1407
1408 assert!((after.lon - before.lon).abs() > 0.0 || (after.lat - before.lat).abs() > 0.0);
1409 }
1410}