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!(self.projection, CameraProjection::VerticalPerspective { .. }) {
144 self.projection = CameraProjection::vertical_perspective(self.target, self.distance);
145 }
146 }
147
148 fn local_basis(&self) -> (DVec3, DVec3, DVec3) {
149 match self.projection {
150 CameraProjection::Globe => {
151 let lat = self.target.lat.to_radians();
152 let lon = self.target.lon.to_radians();
153 let (sin_lat, cos_lat) = lat.sin_cos();
154 let (sin_lon, cos_lon) = lon.sin_cos();
155
156 let east = DVec3::new(-sin_lon, cos_lon, 0.0);
157 let north = DVec3::new(-sin_lat * cos_lon, -sin_lat * sin_lon, cos_lat);
158 let up = DVec3::new(cos_lat * cos_lon, cos_lat * sin_lon, sin_lat);
159 (east, north, up)
160 }
161 _ => (DVec3::X, DVec3::Y, DVec3::Z),
162 }
163 }
164
165 fn view_up_from_eye(&self, eye: DVec3, target_world: DVec3) -> DVec3 {
166 const BLEND_RAD: f64 = 0.15;
167 let (sy, cy) = self.yaw.sin_cos();
168 let (east, north, _) = self.local_basis();
169
170 let yaw_up = east * sy + north * cy;
171 let right = east * cy - north * sy;
172 let look = (target_world - eye).normalize_or_zero();
173 let pitched_up = right.cross(look).normalize_or_zero();
174
175 let t = (self.pitch / BLEND_RAD).clamp(0.0, 1.0);
176 let up = (pitched_up * t + yaw_up * (1.0 - t)).normalize_or_zero();
177 if up.length_squared() < 0.5 { DVec3::Z } else { up }
178 }
179
180 fn screen_to_geo_on_globe(&self, px: f64, py: f64) -> Option<GeoCoord> {
181 let (origin, dir) = self.screen_to_ray(px, py);
182 let radius = Ellipsoid::WGS84.a;
183 let a = dir.dot(dir);
184 let b = 2.0 * origin.dot(dir);
185 let c = origin.dot(origin) - radius * radius;
186 let disc = b * b - 4.0 * a * c;
187 if disc < 0.0 {
188 return None;
189 }
190 let sqrt_disc = disc.sqrt();
191 let t0 = (-b - sqrt_disc) / (2.0 * a);
192 let t1 = (-b + sqrt_disc) / (2.0 * a);
193 let t = [t0, t1]
194 .into_iter()
195 .filter(|t| *t >= 0.0)
196 .min_by(|a, b| a.total_cmp(b))?;
197 let hit = origin + dir * t;
198 Some(Globe::unproject(&WorldCoord::new(hit.x, hit.y, hit.z)))
199 }
200
201 pub fn view_up_vector(&self) -> DVec3 {
203 let eye = self.eye_offset();
204 self.view_up_from_eye(eye, DVec3::ZERO)
205 }
206
207 #[inline]
211 pub fn target(&self) -> &GeoCoord { &self.target }
212
213 #[inline]
215 pub fn distance(&self) -> f64 { self.distance }
216
217 #[inline]
219 pub fn projection(&self) -> CameraProjection { self.projection }
220
221 #[inline]
223 pub fn pitch(&self) -> f64 { self.pitch }
224
225 #[inline]
227 pub fn yaw(&self) -> f64 { self.yaw }
228
229 #[inline]
231 pub fn mode(&self) -> CameraMode { self.mode }
232
233 #[inline]
235 pub fn fov_y(&self) -> f64 { self.fov_y }
236
237 #[inline]
239 pub fn viewport_width(&self) -> u32 { self.viewport_width }
240
241 #[inline]
243 pub fn viewport_height(&self) -> u32 { self.viewport_height }
244
245 #[inline]
249 pub fn set_target(&mut self, target: GeoCoord) {
250 self.target = target;
251 self.sync_projection_state();
252 }
253
254 #[inline]
256 pub fn set_projection(&mut self, projection: CameraProjection) {
257 self.projection = projection;
258 self.sync_projection_state();
259 }
260
261 pub fn set_distance(&mut self, d: f64) {
264 debug_assert!(d.is_finite() && d > 0.0, "Camera::set_distance: invalid {d}");
265 if d.is_finite() && d > 0.0 {
266 self.distance = d;
267 }
268 }
269
270 pub fn set_pitch(&mut self, p: f64) {
273 debug_assert!(p.is_finite(), "Camera::set_pitch: non-finite {p}");
274 if p.is_finite() {
275 self.pitch = p.clamp(0.0, MAX_PITCH);
276 }
277 }
278
279 pub fn set_yaw(&mut self, y: f64) {
282 debug_assert!(y.is_finite(), "Camera::set_yaw: non-finite {y}");
283 if y.is_finite() {
284 self.yaw = normalize_yaw(y);
285 }
286 }
287
288 pub fn set_mode(&mut self, mode: CameraMode) {
301 if mode == self.mode {
302 return;
303 }
304 let half_tan = (self.fov_y / 2.0).tan();
305 match (self.mode, mode) {
306 (CameraMode::Perspective, CameraMode::Orthographic) => {
307 self.distance *= half_tan;
308 }
309 (CameraMode::Orthographic, CameraMode::Perspective) => {
310 if half_tan.abs() > 1e-12 {
311 self.distance /= half_tan;
312 }
313 }
314 _ => {}
315 }
316 self.mode = mode;
317 }
318
319 pub fn set_fov_y(&mut self, fov: f64) {
322 debug_assert!(fov.is_finite() && fov > 0.0, "Camera::set_fov_y: invalid {fov}");
323 if fov.is_finite() && fov > 0.0 {
324 self.fov_y = fov;
325 }
326 }
327
328 #[inline]
330 pub fn set_viewport(&mut self, width: u32, height: u32) {
331 self.viewport_width = width;
332 self.viewport_height = height;
333 }
334
335 pub fn eye_offset(&self) -> DVec3 {
350 let (sp, cp) = self.pitch.sin_cos();
351 let (sy, cy) = self.yaw.sin_cos();
352 let (east, north, up) = self.local_basis();
353 east * (-self.distance * sp * sy)
354 + north * (-self.distance * sp * cy)
355 + up * (self.distance * cp)
356 }
357
358 pub fn view_matrix(&self, target_world: DVec3) -> DMat4 {
395 let eye = target_world + self.eye_offset();
396 let up = self.view_up_from_eye(eye, target_world);
397
398 DMat4::look_at_rh(eye, target_world, up)
399 }
400
401 pub fn perspective_matrix(&self) -> DMat4 {
412 let aspect = self.viewport_width as f64 / self.viewport_height.max(1) as f64;
413 let near = self.distance * 0.001;
414 let pitch_far_scale = if self.pitch > 0.01 {
415 (1.0 / self.pitch.cos().abs().max(0.05)).min(100.0)
416 } else {
417 1.0
418 };
419 let far = self.distance * 10.0 * pitch_far_scale;
420 DMat4::perspective_rh(self.fov_y, aspect, near, far)
421 }
422
423 pub fn orthographic_matrix(&self) -> DMat4 {
429 let half_h = self.distance;
430 let aspect = self.viewport_width as f64 / self.viewport_height.max(1) as f64;
431 let half_w = half_h * aspect;
432 let near = -self.distance * 100.0;
433 let far = self.distance * 100.0;
434 DMat4::orthographic_rh(-half_w, half_w, -half_h, half_h, near, far)
435 }
436
437 pub fn projection_matrix(&self) -> DMat4 {
439 match self.mode {
440 CameraMode::Perspective => self.perspective_matrix(),
441 CameraMode::Orthographic => self.orthographic_matrix(),
442 }
443 }
444
445 pub fn target_world(&self) -> DVec3 {
449 self.projection.project(&self.target).position
450 }
451
452 pub fn view_projection_matrix(&self) -> DMat4 {
454 let target_world = self.target_world();
455 self.projection_matrix() * self.view_matrix(target_world)
456 }
457
458 pub fn absolute_view_projection_matrix(&self) -> DMat4 {
467 let target_world = self.target_world();
468 self.projection_matrix() * self.view_matrix(target_world)
469 }
470
471 pub fn covering_camera(&self, fractional_zoom: f64) -> Option<rustial_math::CoveringCamera> {
477 if self.projection != CameraProjection::WebMercator {
478 return None;
479 }
480 if self.mode != CameraMode::Perspective {
481 return None;
482 }
483
484 let world_size = rustial_math::WebMercator::world_size();
485 let target_world = self.target_world();
486 let eye = target_world + self.eye_offset();
487
488 let half = world_size * 0.5;
490 let cam_x = (eye.x + half) / world_size;
491 let cam_y = (half - eye.y) / world_size;
492 let center_x = (target_world.x + half) / world_size;
493 let center_y = (half - target_world.y) / world_size;
494
495 let cam_to_center_z = eye.z / world_size;
496
497 Some(rustial_math::CoveringCamera {
498 camera_x: cam_x,
499 camera_y: cam_y,
500 camera_to_center_z: cam_to_center_z.abs(),
501 center_x,
502 center_y,
503 pitch_rad: self.pitch,
504 fov_deg: self.fov_y.to_degrees(),
505 zoom: fractional_zoom,
506 display_tile_size: 256,
507 })
508 }
509
510 pub fn flat_tile_view(&self) -> Option<rustial_math::FlatTileView> {
515 if self.projection != CameraProjection::WebMercator {
516 return None;
517 }
518
519 match self.mode {
520 CameraMode::Perspective => Some(rustial_math::FlatTileView::new(
521 rustial_math::WorldCoord::new(
522 self.target_world().x,
523 self.target_world().y,
524 self.target_world().z,
525 ),
526 self.distance,
527 self.pitch,
528 self.yaw,
529 self.fov_y,
530 self.viewport_width,
531 self.viewport_height,
532 )),
533 CameraMode::Orthographic => None,
534 }
535 }
536
537 pub fn screen_to_ray(&self, px: f64, py: f64) -> (DVec3, DVec3) {
550 let w = self.viewport_width.max(1) as f64;
551 let h = self.viewport_height.max(1) as f64;
552
553 let target_world = self.target_world();
554 let view = self.view_matrix(target_world);
555 let proj = self.projection_matrix();
556 let vp_inv = (proj * view).inverse();
557
558 let ndc_x = (2.0 * px / w) - 1.0;
560 let ndc_y = 1.0 - (2.0 * py / h);
561
562 let near_ndc = DVec4::new(ndc_x, ndc_y, -1.0, 1.0);
563 let far_ndc = DVec4::new(ndc_x, ndc_y, 1.0, 1.0);
564
565 let near_world = vp_inv * near_ndc;
566 let far_world = vp_inv * far_ndc;
567
568 if near_world.w.abs() < 1e-12 || far_world.w.abs() < 1e-12 {
570 return (DVec3::ZERO, -DVec3::Z);
571 }
572
573 let near = DVec3::new(
574 near_world.x / near_world.w,
575 near_world.y / near_world.w,
576 near_world.z / near_world.w,
577 );
578 let far = DVec3::new(
579 far_world.x / far_world.w,
580 far_world.y / far_world.w,
581 far_world.z / far_world.w,
582 );
583
584 let dir = (far - near).normalize();
585 if dir.is_nan() {
586 return (DVec3::ZERO, -DVec3::Z);
587 }
588 (near, dir)
589 }
590
591 pub fn screen_to_geo(&self, px: f64, py: f64) -> Option<GeoCoord> {
597 if matches!(self.projection, CameraProjection::Globe) {
598 return self.screen_to_geo_on_globe(px, py);
599 }
600
601 let (origin, dir) = self.screen_to_ray(px, py);
602
603 if dir.z.abs() < 1e-12 {
605 return None; }
607 let t = -origin.z / dir.z;
608 if t < 0.0 {
609 return None; }
611
612 let hit = origin + dir * t;
613 let world = rustial_math::WorldCoord::new(hit.x, hit.y, 0.0);
614 Some(self.projection.unproject(&world))
615 }
616
617 pub fn geo_to_screen(&self, geo: &GeoCoord) -> Option<(f64, f64)> {
623 let w = self.viewport_width.max(1) as f64;
624 let h = self.viewport_height.max(1) as f64;
625
626 let world_pos = self.projection.project(geo);
627 let target_world = self.target_world();
628 let view = self.view_matrix(target_world);
629 let proj = self.projection_matrix();
630 let vp = proj * view;
631
632 let clip = vp * DVec4::new(world_pos.position.x, world_pos.position.y, world_pos.position.z, 1.0);
633
634 if clip.w <= 0.0 {
636 return None;
637 }
638
639 let ndc_x = clip.x / clip.w;
640 let ndc_y = clip.y / clip.w;
641
642 let px = (ndc_x + 1.0) * 0.5 * w;
644 let py = (1.0 - ndc_y) * 0.5 * h;
645
646 Some((px, py))
647 }
648
649 pub fn meters_per_pixel(&self) -> f64 {
657 let visible_height = match self.mode {
658 CameraMode::Perspective => 2.0 * self.distance * (self.fov_y / 2.0).tan(),
659 CameraMode::Orthographic => 2.0 * self.distance,
660 };
661 visible_height / self.viewport_height.max(1) as f64
662 }
663
664 pub fn near_meters_per_pixel(&self) -> f64 {
682 let center_mpp = self.meters_per_pixel();
683
684 if self.pitch.abs() < 0.01 {
685 return center_mpp;
686 }
687
688 match self.mode {
689 CameraMode::Orthographic => center_mpp,
690 CameraMode::Perspective => {
691 let h = self.distance * self.pitch.cos();
693 if h <= 0.0 {
694 return center_mpp;
695 }
696
697 let half_fov = self.fov_y / 2.0;
701 let near_angle = (self.pitch - half_fov).max(0.01);
702
703 let rad_per_px = self.fov_y / self.viewport_height.max(1) as f64;
707 let cos_near = near_angle.cos();
708 let near_mpp = h * rad_per_px / (cos_near * cos_near);
709
710 near_mpp.clamp(center_mpp * 0.125, center_mpp)
715 }
716 }
717 }
718
719 }
722
723#[derive(Debug, Clone)]
732pub struct CameraConstraints {
733 pub min_distance: f64,
735 pub max_distance: f64,
737 pub min_pitch: f64,
739 pub max_pitch: f64,
741}
742
743impl Default for CameraConstraints {
744 fn default() -> Self {
745 Self {
746 min_distance: 1.0,
747 max_distance: 40_000_000.0,
748 min_pitch: 0.0,
749 max_pitch: std::f64::consts::FRAC_PI_2 - 0.01,
750 }
751 }
752}
753
754pub struct CameraController;
764
765impl CameraController {
766 fn retarget_for_screen_anchor(camera: &mut Camera, desired: GeoCoord, actual: GeoCoord) {
767 if matches!(camera.projection(), CameraProjection::Globe | CameraProjection::VerticalPerspective { .. }) {
768 let mut target = *camera.target();
769 target.lat = (target.lat + (desired.lat - actual.lat)).clamp(-90.0, 90.0);
770 let lon_delta = desired.lon - actual.lon;
771 let mut lon = target.lon + lon_delta;
772 lon = ((lon + 180.0) % 360.0 + 360.0) % 360.0 - 180.0;
773 target.lon = lon;
774 camera.set_target(target);
775 return;
776 }
777
778 let desired = camera.projection().project(&desired);
779 let actual = camera.projection().project(&actual);
780 let current = camera.projection().project(camera.target());
781
782 let shift_x = actual.position.x - desired.position.x;
783 let shift_y = actual.position.y - desired.position.y;
784
785 let extent = camera.projection().max_extent();
786 let full = camera.projection().world_size();
787 let mut new_x = current.position.x - shift_x;
788 let new_y = (current.position.y - shift_y).clamp(-extent, extent);
789 new_x = ((new_x + extent) % full + full) % full - extent;
790
791 camera.set_target(camera.projection().unproject(&WorldCoord::new(
792 new_x,
793 new_y,
794 current.position.z,
795 )));
796 }
797
798 pub fn zoom(
802 camera: &mut Camera,
803 factor: f64,
804 cursor_x: Option<f64>,
805 cursor_y: Option<f64>,
806 constraints: &CameraConstraints,
807 ) {
808 if !factor.is_finite() || factor <= 0.0 {
809 return;
810 }
811
812 let anchor = match (cursor_x, cursor_y) {
813 (Some(x), Some(y)) => camera.screen_to_geo(x, y).map(|geo| (x, y, geo)),
814 _ => None,
815 };
816
817 camera.set_distance(
818 (camera.distance() / factor).clamp(constraints.min_distance, constraints.max_distance),
819 );
820
821 if let Some((x, y, desired)) = anchor {
822 if let Some(actual) = camera.screen_to_geo(x, y) {
823 Self::retarget_for_screen_anchor(camera, desired, actual);
824 }
825 }
826 }
827
828 pub fn rotate(
833 camera: &mut Camera,
834 delta_yaw: f64,
835 delta_pitch: f64,
836 constraints: &CameraConstraints,
837 ) {
838 camera.set_yaw(camera.yaw() + delta_yaw);
839 camera.set_pitch(
840 (camera.pitch() + delta_pitch).clamp(constraints.min_pitch, constraints.max_pitch),
841 );
842 }
843
844 pub fn pan(camera: &mut Camera, dx: f64, dy: f64, cursor_x: Option<f64>, cursor_y: Option<f64>) {
846 let px = cursor_x.unwrap_or(camera.viewport_width() as f64 * 0.5);
847 let py = cursor_y.unwrap_or(camera.viewport_height() as f64 * 0.5);
848
849 if matches!(camera.projection(), CameraProjection::Globe | CameraProjection::VerticalPerspective { .. }) {
850 if let (Some(geo_a), Some(geo_b)) = (
851 camera.screen_to_geo(px, py),
852 camera.screen_to_geo(px + dx, py + dy),
853 ) {
854 Self::retarget_for_screen_anchor(camera, geo_a, geo_b);
855 return;
856 }
857 }
858
859 if let (Some(geo_a), Some(geo_b)) = (
860 camera.screen_to_geo(px, py),
861 camera.screen_to_geo(px + dx, py + dy),
862 ) {
863 Self::retarget_for_screen_anchor(camera, geo_a, geo_b);
864 return;
865 }
866
867 let mpp = camera.meters_per_pixel();
869 let (sy, cy) = camera.yaw().sin_cos();
870
871 let world_dx = (dx * cy + dy * sy) * mpp;
872 let world_dy = (-dx * sy + dy * cy) * mpp;
873
874 let current = camera.projection.project(camera.target());
875 let mut new_x = current.position.x - world_dx;
876 let mut new_y = current.position.y + world_dy;
877
878 let extent = camera.projection.max_extent();
879 let full = camera.projection.world_size();
880 new_x = ((new_x + extent) % full + full) % full - extent;
881 new_y = new_y.clamp(-extent, extent);
882
883 camera.set_target(camera.projection.unproject(&WorldCoord::new(
884 new_x,
885 new_y,
886 current.position.z,
887 )));
888 }
889
890 pub fn handle_event(camera: &mut Camera, event: InputEvent, constraints: &CameraConstraints) {
897 match event {
898 InputEvent::Pan { dx, dy, x, y } => Self::pan(camera, dx, dy, x, y),
899 InputEvent::Zoom { factor, x, y } => Self::zoom(camera, factor, x, y, constraints),
900 InputEvent::Rotate {
901 delta_yaw,
902 delta_pitch,
903 } => Self::rotate(camera, delta_yaw, delta_pitch, constraints),
904 InputEvent::Resize { width, height } => {
905 camera.set_viewport(width, height);
906 }
907 InputEvent::Touch(_) => {
908 }
911 }
912 }
913}
914
915#[cfg(test)]
916mod tests {
917 use super::*;
918
919 #[test]
922 fn default_camera_top_down() {
923 let cam = Camera::default();
924 let offset = cam.eye_offset();
925 assert!(offset.x.abs() < 1e-6);
926 assert!(offset.y.abs() < 1e-6);
927 assert!((offset.z - cam.distance()).abs() < 1e-6);
928 }
929
930 #[test]
931 fn eye_offset_pitched_yaw_zero() {
932 let mut cam = Camera::default();
933 cam.set_pitch(std::f64::consts::FRAC_PI_4);
934 cam.set_distance(100.0);
935 let offset = cam.eye_offset();
936 assert!(offset.x.abs() < 1e-6, "x should be ~0, got {}", offset.x);
937 assert!(offset.y < -1.0, "y should be negative, got {}", offset.y);
938 assert!(offset.z > 1.0, "z should be positive, got {}", offset.z);
939 }
940
941 #[test]
942 fn eye_offset_pitched_yaw_90() {
943 let mut cam = Camera::default();
944 cam.set_pitch(std::f64::consts::FRAC_PI_4);
945 cam.set_yaw(std::f64::consts::FRAC_PI_2);
946 cam.set_distance(100.0);
947 let offset = cam.eye_offset();
948 assert!(offset.x < -1.0, "x should be negative for east-facing");
949 assert!(offset.y.abs() < 1e-6, "y should be ~0");
950 assert!(offset.z > 1.0, "z should be positive");
951 }
952
953 #[test]
956 fn view_matrix_no_flip_through_pitch_range() {
957 let mut cam = Camera::default();
958 cam.set_distance(1000.0);
959
960 let target = DVec3::ZERO;
961 let steps = 100;
962 let max_pitch = std::f64::consts::FRAC_PI_2 - 0.02;
963
964 for i in 0..=steps {
965 cam.set_pitch(max_pitch * (i as f64 / steps as f64));
966 let view = cam.view_matrix(target);
967 let eye = target + cam.eye_offset();
968 assert!(eye.z > 0.0, "eye should be above ground at pitch={:.3}", cam.pitch());
969 for col in 0..4 {
970 let c = view.col(col);
971 assert!(
972 c.x.is_finite() && c.y.is_finite() && c.z.is_finite() && c.w.is_finite(),
973 "non-finite view matrix at pitch={:.3}", cam.pitch()
974 );
975 }
976 }
977 }
978
979 #[test]
980 fn view_matrix_stable_through_yaw_range() {
981 let mut cam = Camera::default();
982 cam.set_distance(1000.0);
983 cam.set_pitch(0.5);
984
985 let target = DVec3::ZERO;
986 for i in 0..=36 {
987 cam.set_yaw((i as f64 / 36.0) * std::f64::consts::TAU);
988 let view = cam.view_matrix(target);
989 for col in 0..4 {
990 let c = view.col(col);
991 assert!(
992 c.x.is_finite() && c.y.is_finite() && c.z.is_finite() && c.w.is_finite(),
993 "non-finite view matrix at yaw={:.3}", cam.yaw()
994 );
995 }
996 }
997 }
998
999 #[test]
1000 fn view_matrix_no_north_south_flip_at_yaw_pi() {
1001 let mut cam = Camera::default();
1002 cam.set_distance(1000.0);
1003 cam.set_yaw(std::f64::consts::PI);
1004
1005 let target = DVec3::ZERO;
1006 let steps = 50;
1007 let max_pitch = std::f64::consts::FRAC_PI_2 - 0.05;
1008 let mut prev_right_x: Option<f64> = None;
1009
1010 for i in 0..=steps {
1011 cam.set_pitch(max_pitch * (i as f64 / steps as f64));
1012 let view = cam.view_matrix(target);
1013 let right_x = view.col(0).x;
1014 if let Some(prev) = prev_right_x {
1015 assert!(
1016 right_x * prev > -1e-6,
1017 "screen-right flipped sign at pitch={:.3}: was {prev:.4}, now {right_x:.4}",
1018 cam.pitch()
1019 );
1020 }
1021 prev_right_x = Some(right_x);
1022 }
1023 }
1024
1025 #[test]
1028 fn zoom_clamp() {
1029 let mut cam = Camera::default();
1030 let constraints = CameraConstraints::default();
1031 CameraController::zoom(&mut cam, 1e20, None, None, &constraints);
1032 assert!(cam.distance() >= constraints.min_distance);
1033 CameraController::zoom(&mut cam, 1e-20, None, None, &constraints);
1034 assert!(cam.distance() <= constraints.max_distance);
1035 }
1036
1037 #[test]
1038 fn zoom_nan_ignored() {
1039 let mut cam = Camera::default();
1040 let original = cam.distance();
1041 let constraints = CameraConstraints::default();
1042 CameraController::zoom(&mut cam, f64::NAN, None, None, &constraints);
1043 assert_eq!(cam.distance(), original);
1044 }
1045
1046 #[test]
1047 fn zoom_zero_ignored() {
1048 let mut cam = Camera::default();
1049 let original = cam.distance();
1050 let constraints = CameraConstraints::default();
1051 CameraController::zoom(&mut cam, 0.0, None, None, &constraints);
1052 assert_eq!(cam.distance(), original);
1053 }
1054
1055 #[test]
1056 fn zoom_negative_ignored() {
1057 let mut cam = Camera::default();
1058 let original = cam.distance();
1059 let constraints = CameraConstraints::default();
1060 CameraController::zoom(&mut cam, -2.0, None, None, &constraints);
1061 assert_eq!(cam.distance(), original);
1062 }
1063
1064 #[test]
1065 fn zoom_infinity_ignored() {
1066 let mut cam = Camera::default();
1067 let original = cam.distance();
1068 let constraints = CameraConstraints::default();
1069 CameraController::zoom(&mut cam, f64::INFINITY, None, None, &constraints);
1070 assert_eq!(cam.distance(), original);
1071 }
1072
1073 #[test]
1074 fn zoom_around_center_keeps_target_stable() {
1075 let mut cam = Camera::default();
1076 cam.set_target(GeoCoord::from_lat_lon(51.1, 17.0));
1077 cam.set_distance(100_000.0);
1078 cam.set_viewport(800, 600);
1079 let before = *cam.target();
1080 let constraints = CameraConstraints::default();
1081
1082 CameraController::zoom(&mut cam, 1.1, Some(400.0), Some(300.0), &constraints);
1083
1084 let after = *cam.target();
1085 assert!((after.lat - before.lat).abs() < 1e-6);
1086 assert!((after.lon - before.lon).abs() < 1e-6);
1087 }
1088
1089 #[test]
1090 fn zoom_around_cursor_preserves_anchor_location() {
1091 let mut cam = Camera::default();
1092 cam.set_target(GeoCoord::from_lat_lon(51.1, 17.0));
1093 cam.set_distance(100_000.0);
1094 cam.set_viewport(800, 600);
1095 let constraints = CameraConstraints::default();
1096 let desired = cam.screen_to_geo(650.0, 420.0).expect("anchor before zoom");
1097
1098 CameraController::zoom(&mut cam, 1.1, Some(650.0), Some(420.0), &constraints);
1099
1100 let actual = cam.screen_to_geo(650.0, 420.0).expect("anchor after zoom");
1101 assert!((actual.lat - desired.lat).abs() < 1e-4);
1102 assert!((actual.lon - desired.lon).abs() < 1e-4);
1103 assert!((cam.target().lat - 51.1).abs() > 1e-5 || (cam.target().lon - 17.0).abs() > 1e-5);
1104 }
1105
1106 #[test]
1109 fn perspective_matrix_not_zero() {
1110 let cam = Camera::default();
1111 let m = cam.perspective_matrix();
1112 assert!(m.col(0).x.abs() > 0.0);
1113 }
1114
1115 #[test]
1116 fn orthographic_matrix_not_zero() {
1117 let mut cam = Camera::default();
1118 cam.set_mode(CameraMode::Orthographic);
1119 let m = cam.orthographic_matrix();
1120 assert!(m.col(0).x.abs() > 0.0);
1121 }
1122
1123 #[test]
1124 fn projection_matrix_matches_mode() {
1125 let mut cam = Camera::default();
1126 cam.set_mode(CameraMode::Perspective);
1127 let p = cam.projection_matrix();
1128 assert_eq!(p, cam.perspective_matrix());
1129
1130 cam.set_mode(CameraMode::Orthographic);
1131 let o = cam.projection_matrix();
1132 assert_eq!(o, cam.orthographic_matrix());
1133 }
1134
1135 #[test]
1136 fn far_plane_grows_with_pitch() {
1137 let mut cam = Camera::default();
1138 cam.set_distance(10_000.0);
1139 let m0 = cam.perspective_matrix();
1140
1141 cam.set_pitch(1.2);
1142 let m1 = cam.perspective_matrix();
1143
1144 let depth0 = m0.col(2).z;
1145 let depth1 = m1.col(2).z;
1146 assert!((depth1 - depth0).abs() > 1e-6, "far plane should differ with pitch");
1147 }
1148
1149 #[test]
1150 fn perspective_matrix_finite_at_max_pitch() {
1151 let mut cam = Camera::default();
1152 cam.set_pitch(std::f64::consts::FRAC_PI_2 - 0.01);
1153 cam.set_distance(10_000.0);
1154 let m = cam.perspective_matrix();
1155 for col in 0..4 {
1156 let c = m.col(col);
1157 assert!(
1158 c.x.is_finite() && c.y.is_finite() && c.z.is_finite() && c.w.is_finite(),
1159 "perspective matrix should be finite at max pitch"
1160 );
1161 }
1162 }
1163
1164 #[test]
1167 fn screen_to_geo_center_returns_target() {
1168 let mut cam = Camera::default();
1169 cam.set_target(GeoCoord::from_lat_lon(51.1, 17.0));
1170 cam.set_distance(100_000.0);
1171 cam.set_viewport(800, 600);
1172 let geo = cam.screen_to_geo(400.0, 300.0);
1173 assert!(geo.is_some(), "center of screen should hit ground");
1174 let geo = geo.expect("center hit");
1175 assert!((geo.lat - 51.1).abs() < 0.1, "lat should be near 51.1, got {}", geo.lat);
1176 assert!((geo.lon - 17.0).abs() < 0.1, "lon should be near 17.0, got {}", geo.lon);
1177 }
1178
1179 #[test]
1180 fn screen_to_geo_off_center_differs() {
1181 let mut cam = Camera::default();
1182 cam.set_distance(100_000.0);
1183 cam.set_viewport(800, 600);
1184 let center = cam.screen_to_geo(400.0, 300.0).expect("center");
1185 let corner = cam.screen_to_geo(0.0, 0.0).expect("corner");
1186 let dist = ((center.lat - corner.lat).powi(2) + (center.lon - corner.lon).powi(2)).sqrt();
1187 assert!(dist > 0.01, "corner and center should differ");
1188 }
1189
1190 #[test]
1191 fn screen_to_ray_direction_is_normalized() {
1192 let cam = Camera::default();
1193 let (_, dir) = cam.screen_to_ray(400.0, 300.0);
1194 assert!((dir.length() - 1.0).abs() < 1e-6, "direction should be unit length");
1195 }
1196
1197 #[test]
1198 fn screen_to_ray_degenerate_viewport() {
1199 let mut cam = Camera::default();
1200 cam.set_viewport(0, 0);
1201 let (origin, dir) = cam.screen_to_ray(0.0, 0.0);
1202 assert!(origin.x.is_finite());
1203 assert!(dir.z.is_finite());
1204 }
1205
1206 #[test]
1207 fn screen_to_geo_horizon_returns_none() {
1208 let mut cam = Camera::default();
1209 cam.set_pitch(std::f64::consts::FRAC_PI_2 - 0.02);
1210 cam.set_distance(10_000.0);
1211 cam.set_viewport(800, 600);
1212 let result = cam.screen_to_geo(400.0, 0.0);
1213 if let Some(geo) = result {
1214 assert!(geo.lat.is_finite());
1215 assert!(geo.lon.is_finite());
1216 }
1217 }
1218
1219 #[test]
1222 fn meters_per_pixel_positive() {
1223 let cam = Camera::default();
1224 assert!(cam.meters_per_pixel() > 0.0);
1225 }
1226
1227 #[test]
1228 fn meters_per_pixel_decreases_with_zoom() {
1229 let mut cam = Camera::default();
1230 let mpp_far = cam.meters_per_pixel();
1231 cam.set_distance(1_000.0);
1232 let mpp_close = cam.meters_per_pixel();
1233 assert!(mpp_close < mpp_far);
1234 }
1235
1236 #[test]
1237 fn meters_per_pixel_ortho_vs_perspective() {
1238 let mut cam = Camera::default();
1239 cam.set_mode(CameraMode::Perspective);
1240 let mpp_persp = cam.meters_per_pixel();
1241 cam.set_mode(CameraMode::Orthographic);
1242 let mpp_ortho = cam.meters_per_pixel();
1243 assert!(mpp_persp > 0.0 && mpp_persp.is_finite());
1244 assert!(mpp_ortho > 0.0 && mpp_ortho.is_finite());
1245 }
1246
1247 #[test]
1248 fn set_mode_preserves_meters_per_pixel() {
1249 let mut cam = Camera::default();
1250 cam.set_distance(100_000.0);
1251 cam.set_viewport(1280, 720);
1252
1253 cam.set_mode(CameraMode::Perspective);
1254 let mpp_before = cam.meters_per_pixel();
1255
1256 cam.set_mode(CameraMode::Orthographic);
1257 let mpp_after = cam.meters_per_pixel();
1258
1259 assert!(
1260 (mpp_before - mpp_after).abs() / mpp_before < 1e-10,
1261 "meters_per_pixel should be preserved: perspective={mpp_before}, orthographic={mpp_after}"
1262 );
1263
1264 cam.set_mode(CameraMode::Perspective);
1266 let mpp_roundtrip = cam.meters_per_pixel();
1267 assert!(
1268 (mpp_before - mpp_roundtrip).abs() / mpp_before < 1e-10,
1269 "meters_per_pixel should survive round-trip: original={mpp_before}, roundtrip={mpp_roundtrip}"
1270 );
1271 }
1272
1273 #[test]
1274 fn target_world_uses_selected_projection() {
1275 let mut cam = Camera::default();
1276 cam.set_target(GeoCoord::from_lat_lon(45.0, 10.0));
1277
1278 let merc = cam.target_world();
1279 cam.set_projection(CameraProjection::Equirectangular);
1280 let eq = cam.target_world();
1281
1282 assert!((merc.x - eq.x).abs() < 1e-6);
1283 assert!((merc.y - eq.y).abs() > 1_000.0);
1284 }
1285
1286 #[test]
1287 fn screen_to_geo_center_respects_equirectangular_projection() {
1288 let mut cam = Camera::default();
1289 cam.set_projection(CameraProjection::Equirectangular);
1290 cam.set_target(GeoCoord::from_lat_lon(30.0, 20.0));
1291 cam.set_distance(100_000.0);
1292 cam.set_viewport(800, 600);
1293
1294 let geo = cam.screen_to_geo(400.0, 300.0).expect("center hit");
1295 assert!((geo.lat - 30.0).abs() < 0.1);
1296 assert!((geo.lon - 20.0).abs() < 0.1);
1297 }
1298
1299 #[test]
1300 fn screen_to_geo_center_respects_globe_projection() {
1301 let mut cam = Camera::default();
1302 cam.set_projection(CameraProjection::Globe);
1303 cam.set_target(GeoCoord::from_lat_lon(30.0, 20.0));
1304 cam.set_distance(3_000_000.0);
1305 cam.set_viewport(800, 600);
1306
1307 let geo = cam.screen_to_geo(400.0, 300.0).expect("center hit");
1308 assert!((geo.lat - 30.0).abs() < 0.1);
1309 assert!((geo.lon - 20.0).abs() < 0.1);
1310 }
1311
1312 #[test]
1313 fn screen_to_geo_center_respects_vertical_perspective_projection() {
1314 let mut cam = Camera::default();
1315 cam.set_target(GeoCoord::from_lat_lon(30.0, 20.0));
1316 cam.set_distance(3_000_000.0);
1317 cam.set_projection(CameraProjection::vertical_perspective(*cam.target(), cam.distance()));
1318 cam.set_viewport(800, 600);
1319
1320 let geo = cam.screen_to_geo(400.0, 300.0).expect("center hit");
1321 assert!((geo.lat - 30.0).abs() < 0.1);
1322 assert!((geo.lon - 20.0).abs() < 0.1);
1323 }
1324
1325 #[test]
1326 fn pan_moves_target_under_globe_projection() {
1327 let mut cam = Camera::default();
1328 cam.set_projection(CameraProjection::Globe);
1329 cam.set_target(GeoCoord::from_lat_lon(10.0, 10.0));
1330 cam.set_distance(3_000_000.0);
1331 cam.set_viewport(800, 600);
1332
1333 let before = *cam.target();
1334 CameraController::pan(&mut cam, 100.0, 0.0, None, None);
1335 let after = *cam.target();
1336
1337 assert!((after.lon - before.lon).abs() > 0.0 || (after.lat - before.lat).abs() > 0.0);
1338 }
1339 }