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!(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    /// Camera-relative up vector matching [`view_matrix`](Self::view_matrix).
202    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    // -- Getters -----------------------------------------------------------
208
209    /// Geographic center the camera is looking at.
210    #[inline]
211    pub fn target(&self) -> &GeoCoord { &self.target }
212
213    /// Distance from the target point, in meters.
214    #[inline]
215    pub fn distance(&self) -> f64 { self.distance }
216
217    /// Geographic projection used by camera/world coordinate helpers.
218    #[inline]
219    pub fn projection(&self) -> CameraProjection { self.projection }
220
221    /// Pitch angle in radians (0 = top-down, ??/2 = horizon).
222    #[inline]
223    pub fn pitch(&self) -> f64 { self.pitch }
224
225    /// Yaw / bearing angle in radians, normalized to `[-?, ?]`.
226    #[inline]
227    pub fn yaw(&self) -> f64 { self.yaw }
228
229    /// Projection mode.
230    #[inline]
231    pub fn mode(&self) -> CameraMode { self.mode }
232
233    /// Vertical field of view in radians (perspective mode only).
234    #[inline]
235    pub fn fov_y(&self) -> f64 { self.fov_y }
236
237    /// Viewport width in pixels.
238    #[inline]
239    pub fn viewport_width(&self) -> u32 { self.viewport_width }
240
241    /// Viewport height in pixels.
242    #[inline]
243    pub fn viewport_height(&self) -> u32 { self.viewport_height }
244
245    // -- Setters (validated) ----------------------------------------------
246
247    /// Set the camera target coordinate.
248    #[inline]
249    pub fn set_target(&mut self, target: GeoCoord) {
250        self.target = target;
251        self.sync_projection_state();
252    }
253
254    /// Set the camera's geographic projection.
255    #[inline]
256    pub fn set_projection(&mut self, projection: CameraProjection) {
257        self.projection = projection;
258        self.sync_projection_state();
259    }
260
261    /// Set camera distance in meters.  Non-finite or non-positive values
262    /// are rejected in debug builds and ignored in release builds.
263    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    /// Set pitch in radians.  Hard-clamped to `[0, MAX_PITCH]`.
271    /// Non-finite values are rejected.
272    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    /// Set yaw / bearing in radians.  Normalized to `[-?, ?]`.
280    /// Non-finite values are rejected.
281    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    /// Set the projection mode.
289    ///
290    /// Adjusts `distance` so that
291    /// [`meters_per_pixel`](Self::meters_per_pixel) (and therefore the
292    /// displayed zoom level) stays constant across the transition.
293    ///
294    /// The visible ground height formulas are:
295    ///
296    /// - **Perspective**: `2 * distance * tan(fov_y / 2)`
297    /// - **Orthographic**: `2 * distance`
298    ///
299    /// Equating the two gives the conversion factor `tan(fov_y / 2)`.
300    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    /// Set vertical field-of-view in radians.  Non-finite or non-positive
320    /// values are rejected.
321    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    /// Set the viewport dimensions in pixels.
329    #[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    // -- Orbital geometry -------------------------------------------------
336
337    /// Eye position relative to the target (camera-relative origin).
338    ///
339    /// Computed from spherical coordinates with the conventions documented
340    /// in the [module-level docs](self).
341    ///
342    /// ```text
343    /// eye.x = d * sin(pitch) * sin(yaw)   -- east component
344    /// eye.y = d * sin(pitch) * cos(yaw)   -- north component
345    /// eye.z = d * cos(pitch)              -- altitude
346    /// ```
347    ///
348    /// At `yaw = 0` the camera sits on the +Y (north) side of the target.
349    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    // -- Matrix builders --------------------------------------------------
359
360    /// Build a view matrix (world -> camera clip space, right-handed).
361    ///
362    /// # Up-vector derivation
363    ///
364    /// The up-hint is computed from the orbital geometry in two regimes:
365    ///
366    /// **Pitched** (`pitch > threshold`): the camera's "screen-right"
367    /// vector is the orbit-sphere tangent in the yaw direction:
368    ///
369    /// ```text
370    /// right = (cos(yaw), -sin(yaw), 0)
371    /// ```
372    ///
373    /// This is always horizontal, always perpendicular to the look
374    /// direction, and independent of pitch.  The up-hint is then
375    /// `right x look` (normalised), which is guaranteed to point
376    /// "above the horizon" from the camera's perspective and is never
377    /// degenerate.
378    ///
379    /// **Top-down** (`pitch <= threshold`): the look direction is nearly
380    /// `-Z`, and the horizontal right vector is degenerate (the orbit
381    /// tangent's magnitude approaches zero).  Instead the up-hint is set
382    /// to `(sin(yaw), cos(yaw), 0)` so the yaw bearing controls which
383    /// map direction appears at the top of the screen.
384    ///
385    /// The two regimes are smoothly blended over `0..threshold` using
386    /// `t = pitch / threshold` so there is no visible discontinuity.
387    ///
388    /// Key properties:
389    ///
390    /// - At any yaw and any pitch, the up-hint is never parallel to the
391    ///   look direction (no gimbal-lock or north/south flip).
392    /// - At `pitch = 0`, screen-up follows the yaw bearing.
393    /// - At high pitch, screen-up is always world-Z (natural horizon).
394    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    /// Build a perspective projection matrix (right-handed, depth [0, 1]).
402    ///
403    /// # Depth range
404    ///
405    /// - **Near plane**: `distance * 0.001` -- close enough for objects at
406    ///   the camera target, far enough to preserve depth precision.
407    /// - **Far plane**: `distance * 10 * pitch_factor` -- when pitched
408    ///   toward the horizon the visible ground extends well beyond the
409    ///   orbit distance.  `pitch_factor = min(1/cos(pitch), 100)` scales
410    ///   the far plane to avoid clipping.
411    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    /// Build an orthographic projection matrix (right-handed).
424    ///
425    /// Half-height equals `distance`, so zooming works identically to
426    /// perspective mode (increase distance = see more ground).  The near /
427    /// far range is `+/-distance * 100` to accommodate terrain elevation.
428    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    /// Build the projection matrix based on the current [`mode`](Self::mode).
438    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    // -- Coordinate helpers -----------------------------------------------
446
447    /// Target position in projected world space (meters).
448    pub fn target_world(&self) -> DVec3 {
449        self.projection.project(&self.target).position
450    }
451
452    /// Combined view-projection matrix (camera-relative origin).
453    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    /// Combined view-projection matrix in **absolute** world space.
459    ///
460    /// Unlike [`view_projection_matrix`](Self::view_projection_matrix),
461    /// which places the target at the origin (camera-relative), this
462    /// computes the VP with world coordinates left as-is.  The resulting
463    /// frustum planes therefore live in the same coordinate space as
464    /// [`tile_bounds_world`](rustial_math::tile_bounds_world) and can be
465    /// used directly for frustum-based tile culling.
466    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    /// Export the current camera as a [`CoveringCamera`](rustial_math::CoveringCamera)
472    /// suitable for the MapLibre-equivalent covering-tiles traversal with
473    /// per-tile variable zoom.
474    ///
475    /// Returns `None` for orthographic mode or non-Mercator projections.
476    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        // Convert from Mercator meters to normalised [0..1] coords.
489        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    /// Export the current perspective camera as flat-tile selection parameters.
511    ///
512    /// Returns `None` for orthographic mode because footprint-aware flat-tile
513    /// filtering is only needed for pitched perspective views.
514    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    // -- Picking / unprojection -------------------------------------------
538
539    /// Unproject a screen-space pixel coordinate to a world-space ray.
540    ///
541    /// Returns `(origin, direction)` in **absolute** world space
542    /// (Web Mercator metres).  The direction is normalised.
543    ///
544    /// `px`, `py` are in logical pixels with `(0, 0)` at the top-left
545    /// corner of the viewport.
546    ///
547    /// Returns `(DVec3::ZERO, -DVec3::Z)` for degenerate viewports
548    /// (width or height of zero) to avoid NaN propagation.
549    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        // Convert pixel to NDC: x in [-1, 1], y in [-1, 1] (top = +1).
559        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        // Guard against degenerate inverse (w ~= 0).
569        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    /// Intersect the unprojected ray with the ground plane (Z = 0) and
592    /// return the geographic coordinate at the hit point.
593    ///
594    /// Returns `None` if the ray is parallel to the ground or points
595    /// away from it (sky).
596    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        // Ray-plane intersection: t = -origin.z / dir.z
604        if dir.z.abs() < 1e-12 {
605            return None; // Parallel to ground.
606        }
607        let t = -origin.z / dir.z;
608        if t < 0.0 {
609            return None; // Behind the camera.
610        }
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    /// Project a geographic coordinate to a screen-space pixel position.
618    ///
619    /// Returns `(px, py)` in logical pixels with `(0, 0)` at the
620    /// top-left corner of the viewport, or `None` if the point is
621    /// behind the camera.
622    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        // Behind the camera.
635        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        // NDC to pixel: x in [0, w], y in [0, h] (top-left origin).
643        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    // -- Resolution helpers -----------------------------------------------
650
651    /// Approximate meters-per-pixel at the current zoom level (screen center).
652    ///
653    /// For perspective mode this is the ground-plane resolution at the
654    /// target point (not at the edges, which varies with pitch).  For
655    /// orthographic mode the resolution is uniform across the viewport.
656    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    /// Approximate meters-per-pixel at the **near ground** (bottom of
665    /// the screen).
666    ///
667    /// When the camera is pitched toward the horizon, the ground
668    /// closest to the viewer (bottom of the viewport) has a much finer
669    /// resolution than the target point.  Using this value for zoom
670    /// selection ensures tiles near the camera are sharp.
671    ///
672    /// The result is clamped so the zoom level increases by at most
673    /// three steps (factor of 8) relative to
674    /// [`meters_per_pixel`](Self::meters_per_pixel).  When the covering-
675    /// tiles variable-zoom path is active, the per-tile zoom heuristic
676    /// automatically assigns lower zoom levels to distant tiles, so this
677    /// generous ceiling does not cause excessive tile counts.
678    ///
679    /// Returns the same value as `meters_per_pixel` when `pitch` is
680    /// below ~30 degrees or the camera is orthographic.
681    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                // Camera height above the ground plane.
692                let h = self.distance * self.pitch.cos();
693                if h <= 0.0 {
694                    return center_mpp;
695                }
696
697                // The bottom-of-screen ray's angle from vertical.
698                // pitch = angle from vertical to look direction.
699                // Bottom-of-screen = pitch - fov_y/2  (closer to vertical = nearer ground).
700                let half_fov = self.fov_y / 2.0;
701                let near_angle = (self.pitch - half_fov).max(0.01);
702
703                // Ground-plane resolution per radian at angle theta from vertical:
704                //   dr/rad = h / cos^2(theta)
705                // One pixel subtends fov_y / viewport_height radians.
706                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                // Clamp: at most three extra zoom levels (factor of 8).
711                // The covering-tiles variable-zoom heuristic keeps distant
712                // tiles at lower zoom, so this generous ceiling does not
713                // cause excessive tile counts.
714                near_mpp.clamp(center_mpp * 0.125, center_mpp)
715            }
716        }
717    }
718
719    // -- Test helper (not public API) -------------------------------------
720
721}
722
723// ---------------------------------------------------------------------------
724// CameraConstraints
725// ---------------------------------------------------------------------------
726
727/// Per-frame clamps applied to the camera by [`CameraController`].
728///
729/// Prevents the user from zooming too close (sub-meter), too far
730/// (beyond Earth's radius), or pitching past the horizon.
731#[derive(Debug, Clone)]
732pub struct CameraConstraints {
733    /// Minimum camera distance in meters.
734    pub min_distance: f64,
735    /// Maximum camera distance in meters.
736    pub max_distance: f64,
737    /// Minimum pitch in radians (typically 0 = top-down).
738    pub min_pitch: f64,
739    /// Maximum pitch in radians (typically just below PI/2).
740    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
754// ---------------------------------------------------------------------------
755// CameraController
756// ---------------------------------------------------------------------------
757
758/// Stateless helper that maps [`InputEvent`]s to camera mutations.
759///
760/// All methods are associated functions (no `self`) because the
761/// controller carries no state -- it is a pure function namespace.
762/// The actual state lives in [`Camera`] and [`CameraConstraints`].
763pub 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    /// Zoom by a multiplicative factor (>1 zooms in, <1 zooms out).
799    ///
800    /// Non-finite, zero, and negative factors are silently ignored.
801    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    /// Rotate the camera by delta yaw and delta pitch (radians).
829    ///
830    /// Pitch is clamped to [`CameraConstraints`]; yaw wraps freely
831    /// (normalized to `[-?, ?]` by the setter).
832    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    /// Pan the camera by a screen-space pixel delta.
845    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    // Fallback: center-based approximation.
868    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    /// Dispatch an [`InputEvent`] to the appropriate handler.
891    ///
892    /// [`Touch`](InputEvent::Touch) events are ignored here — they
893    /// should be routed through the
894    /// [`GestureRecognizer`](crate::gesture::GestureRecognizer) first,
895    /// which produces derived Pan/Zoom/Rotate events.
896    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                // Raw touch events are handled by GestureRecognizer in
909                // MapState::handle_input, not here.
910            }
911        }
912    }
913}
914
915#[cfg(test)]
916mod tests {
917    use super::*;
918
919    // -- Eye offset -------------------------------------------------------
920
921    #[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    // -- View matrix stability --------------------------------------------
954
955    #[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    // -- Zoom / constraints -----------------------------------------------
1026
1027    #[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    // -- Projection matrices ----------------------------------------------
1107
1108    #[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    // -- Screen-to-geo / screen-to-ray ------------------------------------
1165
1166    #[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    // -- Meters per pixel -------------------------------------------------
1220
1221    #[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        // Round-trip back to perspective should also preserve.
1265        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 }