Skip to main content

rustial_engine/math/
projection.rs

1//! Map projection trait.
2
3use crate::bounds::GeoBounds;
4use crate::coord::{GeoCoord, WorldCoord};
5
6/// A map projection that converts between geographic and projected coordinates.
7///
8/// Implementations must be thread-safe (`Send + Sync`) so they can be
9/// shared across rendering and engine threads.
10pub trait Projection: Send + Sync {
11    /// Project a geographic coordinate to world space (meters).
12    fn project(&self, geo: &GeoCoord) -> WorldCoord;
13
14    /// Inverse-project world coordinates back to geographic.
15    fn unproject(&self, world: &WorldCoord) -> GeoCoord;
16
17    /// Local linear scale factor at the given geographic coordinate.
18    ///
19    /// Returns the ratio of projected distance to true geodesic distance
20    /// at `geo`.  For a conformal projection like Web Mercator this is
21    /// `1 / cos(lat)` (i.e. `sec(lat)`); for Equirectangular at the
22    /// equator it is `1.0`.
23    ///
24    /// Useful for computing meters-per-pixel, line-width scaling, and
25    /// LOD thresholds.
26    ///
27    /// The default implementation returns `1.0` (no distortion),
28    /// which is correct only at the standard parallel.
29    fn scale_factor(&self, _geo: &GeoCoord) -> f64 {
30        1.0
31    }
32
33    /// The geographic bounding box of valid input for this projection.
34    ///
35    /// Coordinates outside this range may produce `NaN` or `Infinity`
36    /// when projected.  For example, Web Mercator is valid only within
37    /// approximately 85.06 degrees latitude.
38    ///
39    /// The default implementation returns the full geographic range
40    /// (90 degrees lat, 180 degrees lon).
41    fn projection_bounds(&self) -> GeoBounds {
42        GeoBounds::new(
43            GeoCoord::from_lat_lon(-90.0, -180.0),
44            GeoCoord::from_lat_lon(90.0, 180.0),
45        )
46    }
47}
48
49#[cfg(test)]
50mod tests {
51    use super::*;
52
53    /// Verify the trait is object-safe (can be used as `dyn Projection`).
54    #[test]
55    fn object_safety() {
56        fn _accept(_p: &dyn Projection) {}
57    }
58
59    /// Verify a boxed trait object is Send + Sync.
60    #[test]
61    fn boxed_send_sync() {
62        fn _assert_send_sync<T: Send + Sync>() {}
63        _assert_send_sync::<Box<dyn Projection>>();
64    }
65
66    /// Default `scale_factor` returns 1.0.
67    #[test]
68    fn default_scale_factor() {
69        struct Dummy;
70        impl Projection for Dummy {
71            fn project(&self, _geo: &GeoCoord) -> WorldCoord {
72                WorldCoord::default()
73            }
74            fn unproject(&self, _world: &WorldCoord) -> GeoCoord {
75                GeoCoord::default()
76            }
77        }
78        let d = Dummy;
79        assert!((d.scale_factor(&GeoCoord::from_lat_lon(45.0, 10.0)) - 1.0).abs() < f64::EPSILON);
80    }
81
82    /// Default `projection_bounds` covers the full globe.
83    #[test]
84    fn default_projection_bounds() {
85        struct Dummy;
86        impl Projection for Dummy {
87            fn project(&self, _geo: &GeoCoord) -> WorldCoord {
88                WorldCoord::default()
89            }
90            fn unproject(&self, _world: &WorldCoord) -> GeoCoord {
91                GeoCoord::default()
92            }
93        }
94        let bounds = Dummy.projection_bounds();
95        assert!((bounds.sw().lat - (-90.0)).abs() < f64::EPSILON);
96        assert!((bounds.ne().lat - 90.0).abs() < f64::EPSILON);
97        assert!((bounds.sw().lon - (-180.0)).abs() < f64::EPSILON);
98        assert!((bounds.ne().lon - 180.0).abs() < f64::EPSILON);
99    }
100}