Skip to main content

rustial_engine/
camera_projection.rs

1//! Camera-facing projection selection.
2//!
3//! This module provides a small engine-owned projection enum for camera
4//! integration. It intentionally starts with planar projections only so the
5//! existing engine and renderer paths remain correct while projection breadth
6//! is integrated incrementally.
7
8use rustial_math::{
9    tile_to_geo, tile_xy_to_geo, Ellipsoid, Equirectangular, GeoBounds, GeoCoord, Globe,
10    Projection, TileId, VerticalPerspective, WebMercator, WorldCoord,
11};
12
13/// Release-support classification for camera projections.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum ProjectionSupport {
16    /// Stable, intended as part of the supported `v1.0` surface.
17    Stable,
18    /// Present in the architecture and implementation, but not yet a stable
19    /// `v1.0` promise.
20    Experimental,
21}
22
23/// Camera-facing map projection selection.
24#[derive(Debug, Clone, Copy, PartialEq, Default)]
25pub enum CameraProjection {
26    /// Web Mercator (EPSG:3857).
27    #[default]
28    WebMercator,
29    /// Equirectangular / Plate Carree.
30    Equirectangular,
31    /// Globe / geocentric Earth-centered projection.
32    Globe,
33    /// Near-sided vertical perspective projection onto a tangent plane.
34    VerticalPerspective {
35        /// Tangency center of the projection.
36        center: GeoCoord,
37        /// Viewer height above the ellipsoid surface in meters.
38        camera_height: f64,
39    },
40}
41
42impl CameraProjection {
43    /// Release-support classification for this projection.
44    #[inline]
45    pub fn support_level(&self) -> ProjectionSupport {
46        match self {
47            Self::WebMercator | Self::Equirectangular => ProjectionSupport::Stable,
48            Self::Globe | Self::VerticalPerspective { .. } => ProjectionSupport::Experimental,
49        }
50    }
51
52    /// Whether this projection is part of the intended stable `v1.0` surface.
53    #[inline]
54    pub fn is_stable_for_v1(&self) -> bool {
55        matches!(self.support_level(), ProjectionSupport::Stable)
56    }
57
58    /// Whether this projection should be treated as experimental in `v1.0`.
59    #[inline]
60    pub fn is_experimental_for_v1(&self) -> bool {
61        matches!(self.support_level(), ProjectionSupport::Experimental)
62    }
63
64    /// Construct a vertical-perspective projection from a center point and viewer height.
65    #[inline]
66    pub fn vertical_perspective(center: GeoCoord, camera_height: f64) -> Self {
67        Self::VerticalPerspective {
68            center: GeoCoord::from_lat_lon(center.lat, center.lon),
69            camera_height: if camera_height.is_finite() {
70                camera_height.max(1.0)
71            } else {
72                1.0
73            },
74        }
75    }
76
77    /// Return the configured center for center-aware projections.
78    #[inline]
79    pub fn center(&self) -> Option<GeoCoord> {
80        match self {
81            Self::VerticalPerspective { center, .. } => Some(*center),
82            _ => None,
83        }
84    }
85
86    /// Return the configured viewer height for vertical perspective.
87    #[inline]
88    pub fn camera_height(&self) -> Option<f64> {
89        match self {
90            Self::VerticalPerspective { camera_height, .. } => Some(*camera_height),
91            _ => None,
92        }
93    }
94
95    /// Whether this projection is fully compatible with the current
96    /// slippy-map tile and terrain update path.
97    #[inline]
98    pub fn is_tile_compatible(&self) -> bool {
99        matches!(self, Self::WebMercator | Self::Equirectangular)
100    }
101
102    /// Project a geographic coordinate into engine world space.
103    #[inline]
104    pub fn project(&self, geo: &GeoCoord) -> WorldCoord {
105        match self {
106            Self::WebMercator => WebMercator::project(geo),
107            Self::Equirectangular => Equirectangular.project(geo),
108            Self::Globe => Globe::project(geo),
109            Self::VerticalPerspective {
110                center,
111                camera_height,
112            } => VerticalPerspective::new(*center, *camera_height).project(geo),
113        }
114    }
115
116    /// Inverse-project engine world space back into geographic coordinates.
117    #[inline]
118    pub fn unproject(&self, world: &WorldCoord) -> GeoCoord {
119        match self {
120            Self::WebMercator => WebMercator::unproject(world),
121            Self::Equirectangular => Equirectangular.unproject(world),
122            Self::Globe => Globe::unproject(world),
123            Self::VerticalPerspective {
124                center,
125                camera_height,
126            } => VerticalPerspective::new(*center, *camera_height).unproject(world),
127        }
128    }
129
130    /// Projection-local scale factor at the given geographic coordinate.
131    #[inline]
132    pub fn scale_factor(&self, geo: &GeoCoord) -> f64 {
133        match self {
134            Self::WebMercator => WebMercator.scale_factor(geo),
135            Self::Equirectangular => Equirectangular.scale_factor(geo),
136            Self::Globe => Globe.scale_factor(geo),
137            Self::VerticalPerspective {
138                center,
139                camera_height,
140            } => VerticalPerspective::new(*center, *camera_height).scale_factor(geo),
141        }
142    }
143
144    /// Maximum useful half-extent for camera clamping and wrapping.
145    #[inline]
146    pub fn max_extent(&self) -> f64 {
147        match self {
148            Self::WebMercator => WebMercator::max_extent(),
149            Self::Equirectangular => std::f64::consts::PI * Ellipsoid::WGS84.a,
150            Self::Globe => Globe::radius(),
151            Self::VerticalPerspective {
152                center,
153                camera_height,
154            } => {
155                let projection = VerticalPerspective::new(*center, *camera_height);
156                let horizon = projection.horizon_central_angle();
157                projection.radius() * horizon.tan()
158            }
159        }
160    }
161
162    /// Full projected world width in meters.
163    #[inline]
164    pub fn world_size(&self) -> f64 {
165        match self {
166            Self::Globe => Globe::radius() * 2.0,
167            _ => self.max_extent() * 2.0,
168        }
169    }
170
171    /// Project a geographic coordinate into the active planar world space as
172    /// raw XYZ meters.
173    #[inline]
174    pub fn project_position(&self, geo: &GeoCoord) -> [f64; 3] {
175        let world = self.project(geo);
176        [world.position.x, world.position.y, world.position.z]
177    }
178
179    /// Geographic bounds for a slippy-map tile, expressed as south-west and
180    /// north-east corners.
181    #[inline]
182    pub fn tile_geo_bounds(&self, tile: &TileId) -> GeoBounds {
183        match self {
184            Self::Equirectangular => {
185                let tiles_per_axis = (1u32 << tile.zoom) as f64;
186                let west = tile.x as f64 / tiles_per_axis * 360.0 - 180.0;
187                let east = (tile.x as f64 + 1.0) / tiles_per_axis * 360.0 - 180.0;
188                let north = 90.0 - tile.y as f64 / tiles_per_axis * 180.0;
189                let south = 90.0 - (tile.y as f64 + 1.0) / tiles_per_axis * 180.0;
190
191                GeoBounds::new(
192                    GeoCoord::from_lat_lon(south, west),
193                    GeoCoord::from_lat_lon(north, east),
194                )
195            }
196            _ => {
197                let nw = tile_to_geo(tile);
198                let se = tile_xy_to_geo(tile.zoom, tile.x as f64 + 1.0, tile.y as f64 + 1.0);
199
200                GeoBounds::new(
201                    GeoCoord::from_lat_lon(se.lat.min(nw.lat), nw.lon.min(se.lon)),
202                    GeoCoord::from_lat_lon(se.lat.max(nw.lat), nw.lon.max(se.lon)),
203                )
204            }
205        }
206    }
207
208    /// Project a tile corner into the active planar world space.
209    #[inline]
210    pub fn project_tile_corner(&self, tile: &TileId, u: f64, v: f64) -> [f64; 3] {
211        let bounds = self.tile_geo_bounds(tile);
212        let lat = bounds.north() + (bounds.south() - bounds.north()) * v.clamp(0.0, 1.0);
213        let lon = bounds.west() + (bounds.east() - bounds.west()) * u.clamp(0.0, 1.0);
214        self.project_position(&GeoCoord::from_lat_lon(lat, lon))
215    }
216
217    /// Project the geographic centre of a tile into planar world space.
218    ///
219    /// This is equivalent to averaging the four projected corners but
220    /// requires only a single projection call, making it ~4× cheaper for
221    /// the per-frame tile reposition path.
222    #[inline]
223    pub fn project_tile_center(&self, tile: &TileId) -> [f64; 3] {
224        let bounds = self.tile_geo_bounds(tile);
225        let mid_lat = (bounds.north() + bounds.south()) * 0.5;
226        let mid_lon = (bounds.east() + bounds.west()) * 0.5;
227        self.project_position(&GeoCoord::from_lat_lon(mid_lat, mid_lon))
228    }
229}
230
231impl Eq for CameraProjection {}
232
233impl std::hash::Hash for CameraProjection {
234    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
235        std::mem::discriminant(self).hash(state);
236        if let Self::VerticalPerspective {
237            center,
238            camera_height,
239        } = self
240        {
241            center.lat.to_bits().hash(state);
242            center.lon.to_bits().hash(state);
243            camera_height.to_bits().hash(state);
244        }
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    #[test]
253    fn default_is_web_mercator() {
254        assert_eq!(CameraProjection::default(), CameraProjection::WebMercator);
255    }
256
257    #[test]
258    fn support_levels_match_v1_contract() {
259        assert_eq!(
260            CameraProjection::WebMercator.support_level(),
261            ProjectionSupport::Stable
262        );
263        assert_eq!(
264            CameraProjection::Equirectangular.support_level(),
265            ProjectionSupport::Stable
266        );
267        assert_eq!(
268            CameraProjection::Globe.support_level(),
269            ProjectionSupport::Experimental
270        );
271        assert!(matches!(
272            CameraProjection::vertical_perspective(GeoCoord::from_lat_lon(0.0, 0.0), 1_000_000.0)
273                .support_level(),
274            ProjectionSupport::Experimental
275        ));
276    }
277
278    #[test]
279    fn equirectangular_roundtrip() {
280        let projection = CameraProjection::Equirectangular;
281        let geo = GeoCoord::new(25.0, 30.0, 100.0);
282        let world = projection.project(&geo);
283        let back = projection.unproject(&world);
284        assert!((back.lat - geo.lat).abs() < 1e-8);
285        assert!((back.lon - geo.lon).abs() < 1e-8);
286        assert!((back.alt - geo.alt).abs() < 1e-8);
287    }
288
289    #[test]
290    fn globe_roundtrip() {
291        let projection = CameraProjection::Globe;
292        let geo = GeoCoord::new(51.1, 17.0, 1200.0);
293        let world = projection.project(&geo);
294        let back = projection.unproject(&world);
295        assert!((back.lat - geo.lat).abs() < 1e-6);
296        assert!((back.lon - geo.lon).abs() < 1e-6);
297        assert!((back.alt - geo.alt).abs() < 1e-3);
298    }
299
300    #[test]
301    fn vertical_perspective_roundtrip() {
302        let projection =
303            CameraProjection::vertical_perspective(GeoCoord::from_lat_lon(0.0, 0.0), 8_000_000.0);
304        let geo = GeoCoord::new(10.0, 15.0, 250.0);
305        let world = projection.project(&geo);
306        assert!(world.position.x.is_finite());
307        assert!(world.position.y.is_finite());
308        let back = projection.unproject(&world);
309        assert!((back.lat - geo.lat).abs() < 1e-6);
310        assert!((back.lon - geo.lon).abs() < 1e-6);
311    }
312}