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, Equirectangular, Ellipsoid, 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 { center, camera_height } => {
110                VerticalPerspective::new(*center, *camera_height).project(geo)
111            }
112        }
113    }
114
115    /// Inverse-project engine world space back into geographic coordinates.
116    #[inline]
117    pub fn unproject(&self, world: &WorldCoord) -> GeoCoord {
118        match self {
119            Self::WebMercator => WebMercator::unproject(world),
120            Self::Equirectangular => Equirectangular.unproject(world),
121            Self::Globe => Globe::unproject(world),
122            Self::VerticalPerspective { center, camera_height } => {
123                VerticalPerspective::new(*center, *camera_height).unproject(world)
124            }
125        }
126    }
127
128    /// Projection-local scale factor at the given geographic coordinate.
129    #[inline]
130    pub fn scale_factor(&self, geo: &GeoCoord) -> f64 {
131        match self {
132            Self::WebMercator => WebMercator.scale_factor(geo),
133            Self::Equirectangular => Equirectangular.scale_factor(geo),
134            Self::Globe => Globe.scale_factor(geo),
135            Self::VerticalPerspective { center, camera_height } => {
136                VerticalPerspective::new(*center, *camera_height).scale_factor(geo)
137            }
138        }
139    }
140
141    /// Maximum useful half-extent for camera clamping and wrapping.
142    #[inline]
143    pub fn max_extent(&self) -> f64 {
144        match self {
145            Self::WebMercator => WebMercator::max_extent(),
146            Self::Equirectangular => std::f64::consts::PI * Ellipsoid::WGS84.a,
147            Self::Globe => Globe::radius(),
148            Self::VerticalPerspective { center, camera_height } => {
149                let projection = VerticalPerspective::new(*center, *camera_height);
150                let horizon = projection.horizon_central_angle();
151                projection.radius() * horizon.tan()
152            }
153        }
154    }
155
156    /// Full projected world width in meters.
157    #[inline]
158    pub fn world_size(&self) -> f64 {
159        match self {
160            Self::Globe => Globe::radius() * 2.0,
161            _ => self.max_extent() * 2.0,
162        }
163    }
164
165    /// Project a geographic coordinate into the active planar world space as
166    /// raw XYZ meters.
167    #[inline]
168    pub fn project_position(&self, geo: &GeoCoord) -> [f64; 3] {
169        let world = self.project(geo);
170        [world.position.x, world.position.y, world.position.z]
171    }
172
173    /// Geographic bounds for a slippy-map tile, expressed as south-west and
174    /// north-east corners.
175    #[inline]
176    pub fn tile_geo_bounds(&self, tile: &TileId) -> GeoBounds {
177        match self {
178            Self::Equirectangular => {
179                let tiles_per_axis = (1u32 << tile.zoom) as f64;
180                let west = tile.x as f64 / tiles_per_axis * 360.0 - 180.0;
181                let east = (tile.x as f64 + 1.0) / tiles_per_axis * 360.0 - 180.0;
182                let north = 90.0 - tile.y as f64 / tiles_per_axis * 180.0;
183                let south = 90.0 - (tile.y as f64 + 1.0) / tiles_per_axis * 180.0;
184
185                GeoBounds::new(
186                    GeoCoord::from_lat_lon(south, west),
187                    GeoCoord::from_lat_lon(north, east),
188                )
189            }
190            _ => {
191                let nw = tile_to_geo(tile);
192                let se = tile_xy_to_geo(tile.zoom, tile.x as f64 + 1.0, tile.y as f64 + 1.0);
193
194                GeoBounds::new(
195                    GeoCoord::from_lat_lon(se.lat.min(nw.lat), nw.lon.min(se.lon)),
196                    GeoCoord::from_lat_lon(se.lat.max(nw.lat), nw.lon.max(se.lon)),
197                )
198            }
199        }
200    }
201
202    /// Project a tile corner into the active planar world space.
203    #[inline]
204    pub fn project_tile_corner(&self, tile: &TileId, u: f64, v: f64) -> [f64; 3] {
205        let bounds = self.tile_geo_bounds(tile);
206        let lat = bounds.north() + (bounds.south() - bounds.north()) * v.clamp(0.0, 1.0);
207        let lon = bounds.west() + (bounds.east() - bounds.west()) * u.clamp(0.0, 1.0);
208        self.project_position(&GeoCoord::from_lat_lon(lat, lon))
209    }
210
211    /// Project the geographic centre of a tile into planar world space.
212    ///
213    /// This is equivalent to averaging the four projected corners but
214    /// requires only a single projection call, making it ~4× cheaper for
215    /// the per-frame tile reposition path.
216    #[inline]
217    pub fn project_tile_center(&self, tile: &TileId) -> [f64; 3] {
218        let bounds = self.tile_geo_bounds(tile);
219        let mid_lat = (bounds.north() + bounds.south()) * 0.5;
220        let mid_lon = (bounds.east() + bounds.west()) * 0.5;
221        self.project_position(&GeoCoord::from_lat_lon(mid_lat, mid_lon))
222    }
223}
224
225impl Eq for CameraProjection {}
226
227impl std::hash::Hash for CameraProjection {
228    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
229        std::mem::discriminant(self).hash(state);
230        if let Self::VerticalPerspective { center, camera_height } = self {
231            center.lat.to_bits().hash(state);
232            center.lon.to_bits().hash(state);
233            camera_height.to_bits().hash(state);
234        }
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    #[test]
243    fn default_is_web_mercator() {
244        assert_eq!(CameraProjection::default(), CameraProjection::WebMercator);
245    }
246
247    #[test]
248    fn support_levels_match_v1_contract() {
249        assert_eq!(CameraProjection::WebMercator.support_level(), ProjectionSupport::Stable);
250        assert_eq!(CameraProjection::Equirectangular.support_level(), ProjectionSupport::Stable);
251        assert_eq!(CameraProjection::Globe.support_level(), ProjectionSupport::Experimental);
252        assert!(matches!(
253            CameraProjection::vertical_perspective(GeoCoord::from_lat_lon(0.0, 0.0), 1_000_000.0)
254                .support_level(),
255            ProjectionSupport::Experimental
256        ));
257    }
258
259    #[test]
260    fn equirectangular_roundtrip() {
261        let projection = CameraProjection::Equirectangular;
262        let geo = GeoCoord::new(25.0, 30.0, 100.0);
263        let world = projection.project(&geo);
264        let back = projection.unproject(&world);
265        assert!((back.lat - geo.lat).abs() < 1e-8);
266        assert!((back.lon - geo.lon).abs() < 1e-8);
267        assert!((back.alt - geo.alt).abs() < 1e-8);
268    }
269
270    #[test]
271    fn globe_roundtrip() {
272        let projection = CameraProjection::Globe;
273        let geo = GeoCoord::new(51.1, 17.0, 1200.0);
274        let world = projection.project(&geo);
275        let back = projection.unproject(&world);
276        assert!((back.lat - geo.lat).abs() < 1e-6);
277        assert!((back.lon - geo.lon).abs() < 1e-6);
278        assert!((back.alt - geo.alt).abs() < 1e-3);
279    }
280
281    #[test]
282    fn vertical_perspective_roundtrip() {
283        let projection = CameraProjection::vertical_perspective(
284            GeoCoord::from_lat_lon(0.0, 0.0),
285            8_000_000.0,
286        );
287        let geo = GeoCoord::new(10.0, 15.0, 250.0);
288        let world = projection.project(&geo);
289        assert!(world.position.x.is_finite());
290        assert!(world.position.y.is_finite());
291        let back = projection.unproject(&world);
292        assert!((back.lat - geo.lat).abs() < 1e-6);
293        assert!((back.lon - geo.lon).abs() < 1e-6);
294    }
295}