Skip to main content

rustial_engine/math/
coord.rs

1//! Geographic and projected coordinate types.
2//!
3//! # Overview
4//!
5//! This module defines the two fundamental coordinate types used
6//! throughout the rustial workspace:
7//!
8//! - [`GeoCoord`] -- a position on the Earth's surface in WGS-84
9//!   degrees + altitude.
10//! - [`WorldCoord`] -- a position in projected world space (meters),
11//!   typically EPSG:3857 (Web Mercator).
12//!
13//! The conversion between them is performed by a [`Projection`]
14//! implementation (e.g. [`WebMercator`], [`Equirectangular`]).
15//!
16//! ```text
17//! GeoCoord (lat/lon degrees, WGS-84)
18//!     |  Projection::project()
19//!     v
20//! WorldCoord (meters, EPSG:3857, right-handed Z-up)
21//! ```
22//!
23//! # Coordinate conventions
24//!
25//! | Type | X / lon | Y / lat | Z / alt |
26//! |------|---------|---------|---------|
27//! | `GeoCoord` | east (+) / west (-) | north (+) / south (-) | meters above ellipsoid |
28//! | `WorldCoord` | east | north | up |
29//!
30//! # Web Mercator latitude limit
31//!
32//! The constant [`MAX_MERCATOR_LAT`] (~85.051 129 degrees) is the
33//! latitude at which the Mercator projection reaches +/-pi * R.
34//! It is derived from `atan(sinh(pi))` converted to degrees.
35//! Coordinates beyond this limit produce infinite projected values
36//! and are rejected by [`GeoCoord::is_web_mercator_valid`].
37//!
38//! [`Projection`]: crate::Projection
39//! [`WebMercator`]: crate::WebMercator
40//! [`Equirectangular`]: crate::Equirectangular
41
42use glam::DVec3;
43use std::fmt;
44
45/// Maximum latitude supported by Web Mercator (~85.051 129 degrees).
46///
47/// Derived from `atan(sinh(pi))` in degrees.  Beyond this latitude the
48/// Mercator projection yields infinite Y values.  Used by
49/// [`GeoCoord::is_web_mercator_valid`] and
50/// [`GeoCoord::clamped_mercator`].
51pub(crate) const MAX_MERCATOR_LAT: f64 = 85.051_129;
52
53// ---------------------------------------------------------------------------
54// GeoCoord
55// ---------------------------------------------------------------------------
56
57/// A geographic coordinate in WGS-84 (latitude / longitude in degrees,
58/// altitude in meters).
59///
60/// # Construction
61///
62/// | Method | Validation | Altitude |
63/// |--------|-----------|----------|
64/// | [`new`](Self::new) | `debug_assert` only | explicit |
65/// | [`from_lat_lon`](Self::from_lat_lon) | `debug_assert` only | 0.0 |
66/// | [`new_checked`](Self::new_checked) | returns `Option` | explicit |
67/// | `From<(f64, f64)>` | `debug_assert` only | 0.0 |
68/// | `From<(f64, f64, f64)>` | `debug_assert` only | explicit |
69/// | `From<[f64; 2]>` | `debug_assert` only | 0.0 |
70/// | `From<[f64; 3]>` | `debug_assert` only | explicit |
71///
72/// # Default
73///
74/// `Default` returns Null Island: `(0, 0, 0)`.
75///
76/// # Display
77///
78/// Formats as `"51.100000 N 17.000000 E 100.0m"` (absolute lat/lon
79/// with hemisphere suffix).
80#[derive(Debug, Clone, Copy, PartialEq)]
81#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
82pub struct GeoCoord {
83    /// Latitude in degrees, positive north (`[-90, 90]`).
84    pub lat: f64,
85    /// Longitude in degrees, positive east (`[-180, 180]`).
86    pub lon: f64,
87    /// Altitude above the WGS-84 ellipsoid, in meters.
88    pub alt: f64,
89}
90
91impl GeoCoord {
92    /// Create a new geographic coordinate.
93    ///
94    /// # Panics (debug only)
95    ///
96    /// Debug-asserts that latitude is in `[-90, 90]` and longitude is
97    /// in `[-180, 180]`, with a small tolerance for floating-point
98    /// rounding.  In release builds the values are unchecked -- use
99    /// [`new_checked`](Self::new_checked) when the inputs come from
100    /// untrusted sources.
101    #[inline]
102    pub fn new(lat: f64, lon: f64, alt: f64) -> Self {
103        const EPS: f64 = 1e-10;
104        debug_assert!(
105            (-90.0 - EPS..=90.0 + EPS).contains(&lat),
106            "latitude {lat} out of range [-90, 90]"
107        );
108        debug_assert!(
109            (-180.0 - EPS..=180.0 + EPS).contains(&lon),
110            "longitude {lon} out of range [-180, 180]"
111        );
112        Self { lat, lon, alt }
113    }
114
115    /// Convenience constructor without altitude (defaults to 0.0).
116    #[inline]
117    pub fn from_lat_lon(lat: f64, lon: f64) -> Self {
118        Self::new(lat, lon, 0.0)
119    }
120
121    /// Checked constructor that returns `None` for out-of-range values.
122    ///
123    /// Valid ranges: latitude `[-90, 90]`, longitude `[-180, 180]`.
124    /// Altitude is unrestricted.
125    #[inline]
126    pub fn new_checked(lat: f64, lon: f64, alt: f64) -> Option<Self> {
127        if !(-90.0..=90.0).contains(&lat) || !(-180.0..=180.0).contains(&lon) {
128            return None;
129        }
130        Some(Self { lat, lon, alt })
131    }
132
133    /// Whether this coordinate is within the valid range for Web
134    /// Mercator projection.
135    ///
136    /// Returns `true` when `|lat| <= 85.051129` and `|lon| <= 180`.
137    #[inline]
138    pub fn is_web_mercator_valid(&self) -> bool {
139        self.lat.abs() <= MAX_MERCATOR_LAT && self.lon.abs() <= 180.0
140    }
141
142    /// Clamp latitude to the Web Mercator valid range and **wrap**
143    /// longitude to `[-180, 180]`.
144    ///
145    /// Note: latitude is *clamped* (saturated), but longitude is
146    /// *wrapped* via modular arithmetic so that e.g. 200.0 becomes
147    /// -160.0.  Altitude is preserved unchanged.
148    #[inline]
149    pub fn clamped_mercator(&self) -> Self {
150        let lat = self.lat.clamp(-MAX_MERCATOR_LAT, MAX_MERCATOR_LAT);
151        let mut lon = self.lon % 360.0;
152        if lon > 180.0 {
153            lon -= 360.0;
154        }
155        if lon < -180.0 {
156            lon += 360.0;
157        }
158        Self {
159            lat,
160            lon,
161            alt: self.alt,
162        }
163    }
164}
165
166impl Default for GeoCoord {
167    /// Returns Null Island: `(lat=0, lon=0, alt=0)`.
168    fn default() -> Self {
169        Self {
170            lat: 0.0,
171            lon: 0.0,
172            alt: 0.0,
173        }
174    }
175}
176
177impl fmt::Display for GeoCoord {
178    /// Formats as `"51.100000 N 17.000000 E 100.0m"`.
179    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
180        let ns = if self.lat >= 0.0 { 'N' } else { 'S' };
181        let ew = if self.lon >= 0.0 { 'E' } else { 'W' };
182        write!(
183            f,
184            "{:.6} {} {:.6} {} {:.1}m",
185            self.lat.abs(),
186            ns,
187            self.lon.abs(),
188            ew,
189            self.alt
190        )
191    }
192}
193
194// -- From conversions for GeoCoord ----------------------------------------
195
196impl From<(f64, f64)> for GeoCoord {
197    /// Create from `(lat, lon)` with altitude 0.
198    #[inline]
199    fn from((lat, lon): (f64, f64)) -> Self {
200        Self::from_lat_lon(lat, lon)
201    }
202}
203
204impl From<(f64, f64, f64)> for GeoCoord {
205    /// Create from `(lat, lon, alt)`.
206    #[inline]
207    fn from((lat, lon, alt): (f64, f64, f64)) -> Self {
208        Self::new(lat, lon, alt)
209    }
210}
211
212impl From<[f64; 2]> for GeoCoord {
213    /// Create from `[lat, lon]` with altitude 0.
214    #[inline]
215    fn from(arr: [f64; 2]) -> Self {
216        Self::from_lat_lon(arr[0], arr[1])
217    }
218}
219
220impl From<[f64; 3]> for GeoCoord {
221    /// Create from `[lat, lon, alt]`.
222    #[inline]
223    fn from(arr: [f64; 3]) -> Self {
224        Self::new(arr[0], arr[1], arr[2])
225    }
226}
227
228impl From<GeoCoord> for (f64, f64, f64) {
229    /// Convert to `(lat, lon, alt)`.
230    #[inline]
231    fn from(c: GeoCoord) -> Self {
232        (c.lat, c.lon, c.alt)
233    }
234}
235
236impl From<GeoCoord> for [f64; 3] {
237    /// Convert to `[lat, lon, alt]`.
238    #[inline]
239    fn from(c: GeoCoord) -> Self {
240        [c.lat, c.lon, c.alt]
241    }
242}
243
244// ---------------------------------------------------------------------------
245// WorldCoord
246// ---------------------------------------------------------------------------
247
248/// A position in projected world space (meters, typically EPSG:3857).
249///
250/// The coordinate system is **right-handed Z-up**:
251///
252/// - **X** points east
253/// - **Y** points north
254/// - **Z** points up (altitude / terrain elevation)
255///
256/// Backed by a [`glam::DVec3`] for f64 precision.  The engine performs
257/// all math in f64 and only casts to f32 at GPU upload time
258/// (camera-relative to avoid jitter).
259///
260/// # Default
261///
262/// `Default` returns the origin `(0, 0, 0)`.
263///
264/// # Display
265///
266/// Formats as `"(1234.56, 5678.90, 0.00)m"`.
267///
268/// # Serde
269///
270/// When the `serde` feature is enabled, serializes as
271/// `{ "x": ..., "y": ..., "z": ... }` (manual impl because `DVec3`
272/// does not derive serde).
273#[derive(Debug, Clone, Copy, PartialEq)]
274pub struct WorldCoord {
275    /// The 3D position vector in meters (X=east, Y=north, Z=up).
276    pub position: DVec3,
277}
278
279impl WorldCoord {
280    /// Create a new world coordinate from meters.
281    #[inline]
282    pub fn new(x: f64, y: f64, z: f64) -> Self {
283        Self {
284            position: DVec3::new(x, y, z),
285        }
286    }
287}
288
289impl Default for WorldCoord {
290    /// Returns the origin `(0, 0, 0)`.
291    fn default() -> Self {
292        Self {
293            position: DVec3::ZERO,
294        }
295    }
296}
297
298impl fmt::Display for WorldCoord {
299    /// Formats as `"(x, y, z)m"` with 2 decimal places.
300    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
301        write!(
302            f,
303            "({:.2}, {:.2}, {:.2})m",
304            self.position.x, self.position.y, self.position.z
305        )
306    }
307}
308
309// -- From conversions for WorldCoord --------------------------------------
310
311impl From<DVec3> for WorldCoord {
312    /// Wrap a `DVec3` as a `WorldCoord`.
313    #[inline]
314    fn from(v: DVec3) -> Self {
315        Self { position: v }
316    }
317}
318
319impl From<WorldCoord> for DVec3 {
320    /// Extract the inner `DVec3`.
321    #[inline]
322    fn from(c: WorldCoord) -> Self {
323        c.position
324    }
325}
326
327impl From<[f64; 3]> for WorldCoord {
328    /// Create from `[x, y, z]` in meters.
329    #[inline]
330    fn from(arr: [f64; 3]) -> Self {
331        Self::new(arr[0], arr[1], arr[2])
332    }
333}
334
335impl From<WorldCoord> for [f64; 3] {
336    /// Convert to `[x, y, z]` in meters.
337    #[inline]
338    fn from(c: WorldCoord) -> Self {
339        [c.position.x, c.position.y, c.position.z]
340    }
341}
342
343// -- Serde for WorldCoord (manual, because DVec3 has no serde derive) -----
344
345#[cfg(feature = "serde")]
346impl serde::Serialize for WorldCoord {
347    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
348        use serde::ser::SerializeStruct;
349        let mut s = serializer.serialize_struct("WorldCoord", 3)?;
350        s.serialize_field("x", &self.position.x)?;
351        s.serialize_field("y", &self.position.y)?;
352        s.serialize_field("z", &self.position.z)?;
353        s.end()
354    }
355}
356
357#[cfg(feature = "serde")]
358impl<'de> serde::Deserialize<'de> for WorldCoord {
359    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
360        #[derive(serde::Deserialize)]
361        struct Helper {
362            x: f64,
363            y: f64,
364            z: f64,
365        }
366        let h = Helper::deserialize(deserializer)?;
367        Ok(Self::new(h.x, h.y, h.z))
368    }
369}
370
371// ---------------------------------------------------------------------------
372// Tests
373// ---------------------------------------------------------------------------
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378
379    // -- GeoCoord construction --------------------------------------------
380
381    #[test]
382    fn default_geo_coord() {
383        let c = GeoCoord::default();
384        assert_eq!(c.lat, 0.0);
385        assert_eq!(c.lon, 0.0);
386        assert_eq!(c.alt, 0.0);
387    }
388
389    #[test]
390    fn geo_coord_checked_valid() {
391        assert!(GeoCoord::new_checked(45.0, 90.0, 0.0).is_some());
392    }
393
394    #[test]
395    fn geo_coord_checked_invalid_lat() {
396        assert!(GeoCoord::new_checked(91.0, 0.0, 0.0).is_none());
397    }
398
399    #[test]
400    fn geo_coord_checked_invalid_lon() {
401        assert!(GeoCoord::new_checked(0.0, 181.0, 0.0).is_none());
402    }
403
404    #[test]
405    fn geo_coord_checked_boundary_values() {
406        // Exact boundary values must be accepted.
407        assert!(GeoCoord::new_checked(90.0, 180.0, 0.0).is_some());
408        assert!(GeoCoord::new_checked(-90.0, -180.0, 0.0).is_some());
409    }
410
411    // -- GeoCoord Display -------------------------------------------------
412
413    #[test]
414    fn geo_coord_display_north_east() {
415        let c = GeoCoord::new(51.1, 17.0, 100.0);
416        let s = format!("{c}");
417        assert!(s.contains('N'));
418        assert!(s.contains('E'));
419        assert!(s.contains("100.0m"));
420    }
421
422    #[test]
423    fn geo_coord_display_south_west() {
424        let c = GeoCoord::new(-33.9, -70.6, 0.0);
425        let s = format!("{c}");
426        assert!(s.contains('S'));
427        assert!(s.contains('W'));
428    }
429
430    // -- GeoCoord From conversions ----------------------------------------
431
432    #[test]
433    fn from_tuple_2() {
434        let c: GeoCoord = (51.1, 17.0).into();
435        assert_eq!(c.lat, 51.1);
436        assert_eq!(c.lon, 17.0);
437        assert_eq!(c.alt, 0.0);
438    }
439
440    #[test]
441    fn from_tuple_3() {
442        let c: GeoCoord = (51.1, 17.0, 500.0).into();
443        assert_eq!(c.lat, 51.1);
444        assert_eq!(c.lon, 17.0);
445        assert_eq!(c.alt, 500.0);
446    }
447
448    #[test]
449    fn from_array_2() {
450        let c: GeoCoord = [51.1, 17.0].into();
451        assert_eq!(c.lat, 51.1);
452        assert_eq!(c.lon, 17.0);
453        assert_eq!(c.alt, 0.0);
454    }
455
456    #[test]
457    fn from_array_3() {
458        let c: GeoCoord = [51.1, 17.0, 100.0].into();
459        assert_eq!(c.lat, 51.1);
460        assert_eq!(c.alt, 100.0);
461    }
462
463    #[test]
464    fn into_tuple() {
465        let c = GeoCoord::new(51.1, 17.0, 100.0);
466        let (lat, lon, alt): (f64, f64, f64) = c.into();
467        assert_eq!(lat, 51.1);
468        assert_eq!(lon, 17.0);
469        assert_eq!(alt, 100.0);
470    }
471
472    #[test]
473    fn into_array() {
474        let c = GeoCoord::new(51.1, 17.0, 100.0);
475        let arr: [f64; 3] = c.into();
476        assert_eq!(arr, [51.1, 17.0, 100.0]);
477    }
478
479    // -- GeoCoord Mercator helpers ----------------------------------------
480
481    #[test]
482    fn is_web_mercator_valid() {
483        assert!(GeoCoord::from_lat_lon(51.0, 17.0).is_web_mercator_valid());
484        assert!(!GeoCoord::from_lat_lon(86.0, 17.0).is_web_mercator_valid());
485    }
486
487    #[test]
488    fn clamped_mercator_positive_overflow() {
489        let c = GeoCoord {
490            lat: 89.0,
491            lon: 200.0,
492            alt: 42.0,
493        };
494        let m = c.clamped_mercator();
495        assert!(m.lat <= MAX_MERCATOR_LAT);
496        assert!(m.lon >= -180.0 && m.lon <= 180.0);
497        // 200 % 360 = 200, then 200 - 360 = -160.
498        assert!((m.lon - (-160.0)).abs() < 1e-10);
499        // Altitude must be preserved.
500        assert_eq!(m.alt, 42.0);
501    }
502
503    #[test]
504    fn clamped_mercator_negative_overflow() {
505        let c = GeoCoord {
506            lat: -89.0,
507            lon: -200.0,
508            alt: 0.0,
509        };
510        let m = c.clamped_mercator();
511        assert!(m.lat >= -MAX_MERCATOR_LAT);
512        // -200 % 360 = -200, then -200 + 360 = 160.
513        assert!((m.lon - 160.0).abs() < 1e-10);
514    }
515
516    #[test]
517    fn clamped_mercator_already_valid() {
518        let c = GeoCoord::from_lat_lon(51.0, 17.0);
519        let m = c.clamped_mercator();
520        assert!((m.lat - 51.0).abs() < 1e-10);
521        assert!((m.lon - 17.0).abs() < 1e-10);
522    }
523
524    // -- WorldCoord construction ------------------------------------------
525
526    #[test]
527    fn default_world_coord() {
528        let c = WorldCoord::default();
529        assert_eq!(c.position, DVec3::ZERO);
530    }
531
532    // -- WorldCoord Display -----------------------------------------------
533
534    #[test]
535    fn world_coord_display() {
536        let c = WorldCoord::new(1.0, 2.0, 3.0);
537        let s = format!("{c}");
538        assert!(s.contains("1.00"));
539        assert!(s.contains("2.00"));
540        assert!(s.contains("3.00"));
541        assert!(s.ends_with(")m"));
542    }
543
544    // -- WorldCoord From conversions --------------------------------------
545
546    #[test]
547    fn world_coord_from_dvec3() {
548        let v = DVec3::new(1.0, 2.0, 3.0);
549        let c: WorldCoord = v.into();
550        assert_eq!(c.position, v);
551        let back: DVec3 = c.into();
552        assert_eq!(back, v);
553    }
554
555    #[test]
556    fn world_coord_from_array() {
557        let c: WorldCoord = [10.0, 20.0, 30.0].into();
558        assert_eq!(c.position.x, 10.0);
559        assert_eq!(c.position.y, 20.0);
560        assert_eq!(c.position.z, 30.0);
561    }
562
563    #[test]
564    fn world_coord_into_array() {
565        let c = WorldCoord::new(10.0, 20.0, 30.0);
566        let arr: [f64; 3] = c.into();
567        assert_eq!(arr, [10.0, 20.0, 30.0]);
568    }
569}