Skip to main content

rustial_engine/
camera.rs

1//! Camera state, projection, and input controller for the 2.5D map view.
2//!
3//! # Coordinate conventions
4//!
5//! The engine uses a **camera-relative** rendering model to avoid f32
6//! jitter at large Web Mercator coordinates.  All positions passed to
7//! the GPU are expressed relative to `camera.target_world()`.
8//!
9//! ```text
10//!        +Z  (up / altitude)
11//!         |
12//!         |   +Y  (north, Web Mercator)
13//!         |  /
14//!         | /
15//!         O -----> +X  (east, Web Mercator)
16//!
17//!   Map tiles lie on the Z = 0 plane.
18//! ```
19//!
20//! ## Spherical eye offset
21//!
22//! The camera orbits the target point using spherical coordinates:
23//!
24//! | Parameter | Range | Meaning |
25//! |-----------|-------|---------|
26//! | `pitch`   | `0` to `PI/2` | `0` = top-down (eye on +Z), `PI/2` = horizon |
27//! | `yaw`     | any | Clockwise bearing from north (+Y) when viewed from above |
28//! | `distance`| > 0 | Radius of the orbit sphere (meters) |
29//!
30//! # Architecture
31//!
32//! | Type | Role |
33//! |------|------|
34//! | [`Camera`] | Immutable-style state struct (target, orbit params, projection). |
35//! | [`CameraConstraints`] | Clamps applied every frame (distance, pitch limits). |
36//! | [`CameraController`] | Stateless helper that maps [`InputEvent`]s to camera mutations. |
37//! | [`CameraMode`] | Perspective vs. orthographic projection selection. |
38//!
39//! The [`CameraAnimator`](crate::CameraAnimator) (in its own module) wraps
40//! smooth transitions and momentum on top of these primitives.
41
42use crate::camera_projection::CameraProjection;
43use crate::input::InputEvent;
44use glam::{DMat4, DVec3, DVec4};
45use rustial_math::{Ellipsoid, GeoCoord, Globe, WorldCoord};
46
47// ---------------------------------------------------------------------------
48// CameraMode
49// ---------------------------------------------------------------------------
50
51/// Projection mode for the map camera.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
53pub enum CameraMode {
54    /// Orthographic projection (2D-like, no perspective foreshortening).
55    Orthographic,
56    /// Perspective projection (3D depth).
57    #[default]
58    Perspective,
59}
60
61// ---------------------------------------------------------------------------
62// Camera
63// ---------------------------------------------------------------------------
64
65/// The map camera state.
66///
67/// Stores the geographic target, orbital parameters (pitch / yaw / distance),
68/// projection mode, and viewport dimensions.  All derived matrices are
69/// computed on demand -- the struct itself is plain data.
70///
71/// # Invariants (enforced by setters)
72///
73/// | Field | Guarantee |
74/// |-------|-----------|
75/// | `pitch` | Clamped to `[0, PI/2 - ?]` on every write |
76/// | `yaw` | Normalized to `[-?, ?]` on every write |
77/// | `distance` | Always positive and finite |
78/// | `fov_y` | Always positive and finite |
79///
80/// # Thread safety
81///
82/// `Camera` is `Send + Sync` (all fields are `Copy` or trivially safe).
83/// It is typically owned by [`MapState`](crate::MapState) and mutated from
84/// a single thread per frame.
85#[derive(Debug, Clone)]
86pub struct Camera {
87    /// Geographic center the camera is looking at.
88    target: GeoCoord,
89    /// Geographic projection used by camera/world coordinate helpers.
90    projection: CameraProjection,
91    /// Distance from the target point, in meters.
92    distance: f64,
93    /// Pitch angle in radians (0 = top-down, PI/2 = horizon).
94    pitch: f64,
95    /// Yaw / bearing angle in radians (0 = north-up, clockwise positive).
96    /// Always normalized to [-?, ?].
97    yaw: f64,
98    /// Projection mode.
99    mode: CameraMode,
100    /// Vertical field of view in radians (perspective mode only).
101    fov_y: f64,
102    /// Viewport width in pixels.
103    viewport_width: u32,
104    /// Viewport height in pixels.
105    viewport_height: u32,
106}
107
108/// Hard upper limit for pitch -- just below the singularity at ?/2.
109const MAX_PITCH: f64 = std::f64::consts::FRAC_PI_2 - 0.001;
110
111/// Normalize an angle to `[-?, ?]`.
112#[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    /// Camera-relative up vector matching [`view_matrix`](Self::view_matrix).
209    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    // -- Getters -----------------------------------------------------------
215
216    /// Geographic center the camera is looking at.
217    #[inline]
218    pub fn target(&self) -> &GeoCoord {
219        &self.target
220    }
221
222    /// Distance from the target point, in meters.
223    #[inline]
224    pub fn distance(&self) -> f64 {
225        self.distance
226    }
227
228    /// Geographic projection used by camera/world coordinate helpers.
229    #[inline]
230    pub fn projection(&self) -> CameraProjection {
231        self.projection
232    }
233
234    /// Pitch angle in radians (0 = top-down, ??/2 = horizon).
235    #[inline]
236    pub fn pitch(&self) -> f64 {
237        self.pitch
238    }
239
240    /// Yaw / bearing angle in radians, normalized to `[-?, ?]`.
241    #[inline]
242    pub fn yaw(&self) -> f64 {
243        self.yaw
244    }
245
246    /// Projection mode.
247    #[inline]
248    pub fn mode(&self) -> CameraMode {
249        self.mode
250    }
251
252    /// Vertical field of view in radians (perspective mode only).
253    #[inline]
254    pub fn fov_y(&self) -> f64 {
255        self.fov_y
256    }
257
258    /// Viewport width in pixels.
259    #[inline]
260    pub fn viewport_width(&self) -> u32 {
261        self.viewport_width
262    }
263
264    /// Viewport height in pixels.
265    #[inline]
266    pub fn viewport_height(&self) -> u32 {
267        self.viewport_height
268    }
269
270    // -- Setters (validated) ----------------------------------------------
271
272    /// Set the camera target coordinate.
273    #[inline]
274    pub fn set_target(&mut self, target: GeoCoord) {
275        self.target = target;
276        self.sync_projection_state();
277    }
278
279    /// Set the camera's geographic projection.
280    #[inline]
281    pub fn set_projection(&mut self, projection: CameraProjection) {
282        self.projection = projection;
283        self.sync_projection_state();
284    }
285
286    /// Set camera distance in meters.  Non-finite or non-positive values
287    /// are rejected in debug builds and ignored in release builds.
288    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    /// Set pitch in radians.  Hard-clamped to `[0, MAX_PITCH]`.
299    /// Non-finite values are rejected.
300    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    /// Set yaw / bearing in radians.  Normalized to `[-?, ?]`.
308    /// Non-finite values are rejected.
309    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    /// Set the projection mode.
317    ///
318    /// Adjusts `distance` so that
319    /// [`meters_per_pixel`](Self::meters_per_pixel) (and therefore the
320    /// displayed zoom level) stays constant across the transition.
321    ///
322    /// The visible ground height formulas are:
323    ///
324    /// - **Perspective**: `2 * distance * tan(fov_y / 2)`
325    /// - **Orthographic**: `2 * distance`
326    ///
327    /// Equating the two gives the conversion factor `tan(fov_y / 2)`.
328    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    /// Set vertical field-of-view in radians.  Non-finite or non-positive
348    /// values are rejected.
349    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    /// Set the viewport dimensions in pixels.
360    #[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    // -- Orbital geometry -------------------------------------------------
367
368    /// Eye position relative to the target (camera-relative origin).
369    ///
370    /// Computed from spherical coordinates with the conventions documented
371    /// in the [module-level docs](self).
372    ///
373    /// ```text
374    /// eye.x = d * sin(pitch) * sin(yaw)   -- east component
375    /// eye.y = d * sin(pitch) * cos(yaw)   -- north component
376    /// eye.z = d * cos(pitch)              -- altitude
377    /// ```
378    ///
379    /// At `yaw = 0` the camera sits on the +Y (north) side of the target.
380    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    // -- Matrix builders --------------------------------------------------
390
391    /// Build a view matrix (world -> camera clip space, right-handed).
392    ///
393    /// # Up-vector derivation
394    ///
395    /// The up-hint is computed from the orbital geometry in two regimes:
396    ///
397    /// **Pitched** (`pitch > threshold`): the camera's "screen-right"
398    /// vector is the orbit-sphere tangent in the yaw direction:
399    ///
400    /// ```text
401    /// right = (cos(yaw), -sin(yaw), 0)
402    /// ```
403    ///
404    /// This is always horizontal, always perpendicular to the look
405    /// direction, and independent of pitch.  The up-hint is then
406    /// `right x look` (normalised), which is guaranteed to point
407    /// "above the horizon" from the camera's perspective and is never
408    /// degenerate.
409    ///
410    /// **Top-down** (`pitch <= threshold`): the look direction is nearly
411    /// `-Z`, and the horizontal right vector is degenerate (the orbit
412    /// tangent's magnitude approaches zero).  Instead the up-hint is set
413    /// to `(sin(yaw), cos(yaw), 0)` so the yaw bearing controls which
414    /// map direction appears at the top of the screen.
415    ///
416    /// The two regimes are smoothly blended over `0..threshold` using
417    /// `t = pitch / threshold` so there is no visible discontinuity.
418    ///
419    /// Key properties:
420    ///
421    /// - At any yaw and any pitch, the up-hint is never parallel to the
422    ///   look direction (no gimbal-lock or north/south flip).
423    /// - At `pitch = 0`, screen-up follows the yaw bearing.
424    /// - At high pitch, screen-up is always world-Z (natural horizon).
425    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    /// Build a perspective projection matrix (right-handed, depth [0, 1]).
433    ///
434    /// # Depth range
435    ///
436    /// - **Near plane**: `distance * 0.001` -- close enough for objects at
437    ///   the camera target, far enough to preserve depth precision.
438    /// - **Far plane**: `distance * 10 * pitch_factor` -- when pitched
439    ///   toward the horizon the visible ground extends well beyond the
440    ///   orbit distance.  `pitch_factor = min(1/cos(pitch), 100)` scales
441    ///   the far plane to avoid clipping.
442    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    /// Build an orthographic projection matrix (right-handed).
455    ///
456    /// Half-height equals `distance`, so zooming works identically to
457    /// perspective mode (increase distance = see more ground).  The near /
458    /// far range is `+/-distance * 100` to accommodate terrain elevation.
459    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    /// Build the projection matrix based on the current [`mode`](Self::mode).
469    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    // -- Coordinate helpers -----------------------------------------------
477
478    /// Target position in projected world space (meters).
479    pub fn target_world(&self) -> DVec3 {
480        self.projection.project(&self.target).position
481    }
482
483    /// Combined view-projection matrix (camera-relative origin).
484    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    /// Combined view-projection matrix in **absolute** world space.
490    ///
491    /// Unlike [`view_projection_matrix`](Self::view_projection_matrix),
492    /// which places the target at the origin (camera-relative), this
493    /// computes the VP with world coordinates left as-is.  The resulting
494    /// frustum planes therefore live in the same coordinate space as
495    /// [`tile_bounds_world`](rustial_math::tile_bounds_world) and can be
496    /// used directly for frustum-based tile culling.
497    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    /// Export the current camera as a [`CoveringCamera`](rustial_math::CoveringCamera)
503    /// suitable for the MapLibre-equivalent covering-tiles traversal with
504    /// per-tile variable zoom.
505    ///
506    /// Returns `None` for orthographic mode or non-Mercator projections.
507    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        // Convert from Mercator meters to normalised [0..1] coords.
520        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    /// Export the current perspective camera as flat-tile selection parameters.
542    ///
543    /// Returns `None` for orthographic mode because footprint-aware flat-tile
544    /// filtering is only needed for pitched perspective views.
545    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    // -- Picking / unprojection -------------------------------------------
569
570    /// Unproject a screen-space pixel coordinate to a world-space ray.
571    ///
572    /// Returns `(origin, direction)` in **absolute** world space
573    /// (Web Mercator metres).  The direction is normalised.
574    ///
575    /// `px`, `py` are in logical pixels with `(0, 0)` at the top-left
576    /// corner of the viewport.
577    ///
578    /// Returns `(DVec3::ZERO, -DVec3::Z)` for degenerate viewports
579    /// (width or height of zero) to avoid NaN propagation.
580    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        // Convert pixel to NDC: x in [-1, 1], y in [-1, 1] (top = +1).
590        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        // Guard against degenerate inverse (w ~= 0).
600        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    /// Intersect the unprojected ray with the ground plane (Z = 0) and
623    /// return the geographic coordinate at the hit point.
624    ///
625    /// Returns `None` if the ray is parallel to the ground or points
626    /// away from it (sky).
627    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        // Ray-plane intersection: t = -origin.z / dir.z
635        if dir.z.abs() < 1e-12 {
636            return None; // Parallel to ground.
637        }
638        let t = -origin.z / dir.z;
639        if t < 0.0 {
640            return None; // Behind the camera.
641        }
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    /// Project a geographic coordinate to a screen-space pixel position.
649    ///
650    /// Returns `(px, py)` in logical pixels with `(0, 0)` at the
651    /// top-left corner of the viewport, or `None` if the point is
652    /// behind the camera.
653    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        // Behind the camera.
672        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        // NDC to pixel: x in [0, w], y in [0, h] (top-left origin).
680        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    // -- Resolution helpers -----------------------------------------------
687
688    /// Approximate meters-per-pixel at the current zoom level (screen center).
689    ///
690    /// For perspective mode this is the ground-plane resolution at the
691    /// target point (not at the edges, which varies with pitch).  For
692    /// orthographic mode the resolution is uniform across the viewport.
693    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    /// Approximate meters-per-pixel at the **near ground** (bottom of
702    /// the screen).
703    ///
704    /// When the camera is pitched toward the horizon, the ground
705    /// closest to the viewer (bottom of the viewport) has a much finer
706    /// resolution than the target point.  Using this value for zoom
707    /// selection ensures tiles near the camera are sharp.
708    ///
709    /// The result is clamped so the zoom level increases by at most
710    /// three steps (factor of 8) relative to
711    /// [`meters_per_pixel`](Self::meters_per_pixel).  When the covering-
712    /// tiles variable-zoom path is active, the per-tile zoom heuristic
713    /// automatically assigns lower zoom levels to distant tiles, so this
714    /// generous ceiling does not cause excessive tile counts.
715    ///
716    /// Returns the same value as `meters_per_pixel` when `pitch` is
717    /// below ~30 degrees or the camera is orthographic.
718    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                // Camera height above the ground plane.
729                let h = self.distance * self.pitch.cos();
730                if h <= 0.0 {
731                    return center_mpp;
732                }
733
734                // The bottom-of-screen ray's angle from vertical.
735                // pitch = angle from vertical to look direction.
736                // Bottom-of-screen = pitch - fov_y/2  (closer to vertical = nearer ground).
737                let half_fov = self.fov_y / 2.0;
738                let near_angle = (self.pitch - half_fov).max(0.01);
739
740                // Ground-plane resolution per radian at angle theta from vertical:
741                //   dr/rad = h / cos^2(theta)
742                // One pixel subtends fov_y / viewport_height radians.
743                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                // Clamp: at most three extra zoom levels (factor of 8).
748                // The covering-tiles variable-zoom heuristic keeps distant
749                // tiles at lower zoom, so this generous ceiling does not
750                // cause excessive tile counts.
751                near_mpp.clamp(center_mpp * 0.125, center_mpp)
752            }
753        }
754    }
755
756    // -- Test helper (not public API) -------------------------------------
757}
758
759// ---------------------------------------------------------------------------
760// CameraConstraints
761// ---------------------------------------------------------------------------
762
763/// Per-frame clamps applied to the camera by [`CameraController`].
764///
765/// Prevents the user from zooming too close (sub-meter), too far
766/// (beyond Earth's radius), or pitching past the horizon.
767#[derive(Debug, Clone)]
768pub struct CameraConstraints {
769    /// Minimum camera distance in meters.
770    pub min_distance: f64,
771    /// Maximum camera distance in meters.
772    pub max_distance: f64,
773    /// Minimum pitch in radians (typically 0 = top-down).
774    pub min_pitch: f64,
775    /// Maximum pitch in radians (typically just below PI/2).
776    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
790// ---------------------------------------------------------------------------
791// CameraController
792// ---------------------------------------------------------------------------
793
794/// Stateless helper that maps [`InputEvent`]s to camera mutations.
795///
796/// All methods are associated functions (no `self`) because the
797/// controller carries no state -- it is a pure function namespace.
798/// The actual state lives in [`Camera`] and [`CameraConstraints`].
799pub 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    /// Zoom by a multiplicative factor (>1 zooms in, <1 zooms out).
838    ///
839    /// Non-finite, zero, and negative factors are silently ignored.
840    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    /// Rotate the camera by delta yaw and delta pitch (radians).
868    ///
869    /// Pitch is clamped to [`CameraConstraints`]; yaw wraps freely
870    /// (normalized to `[-?, ?]` by the setter).
871    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    /// Pan the camera by a screen-space pixel delta.
884    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        // Fallback: center-based approximation.
916        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    /// Dispatch an [`InputEvent`] to the appropriate handler.
939    ///
940    /// [`Touch`](InputEvent::Touch) events are ignored here — they
941    /// should be routed through the
942    /// [`GestureRecognizer`](crate::gesture::GestureRecognizer) first,
943    /// which produces derived Pan/Zoom/Rotate events.
944    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                // Raw touch events are handled by GestureRecognizer in
957                // MapState::handle_input, not here.
958            }
959        }
960    }
961}
962
963#[cfg(test)]
964mod tests {
965    use super::*;
966
967    // -- Eye offset -------------------------------------------------------
968
969    #[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    // -- View matrix stability --------------------------------------------
1002
1003    #[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    // -- Zoom / constraints -----------------------------------------------
1080
1081    #[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    // -- Projection matrices ----------------------------------------------
1161
1162    #[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    // -- Screen-to-geo / screen-to-ray ------------------------------------
1222
1223    #[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    // -- Meters per pixel -------------------------------------------------
1288
1289    #[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        // Round-trip back to perspective should also preserve.
1333        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}