rustial-engine 0.0.1

Framework-agnostic 2.5D map engine for rustial
Documentation
//! Camera-facing projection selection.
//!
//! This module provides a small engine-owned projection enum for camera
//! integration. It intentionally starts with planar projections only so the
//! existing engine and renderer paths remain correct while projection breadth
//! is integrated incrementally.

use rustial_math::{
    tile_to_geo, tile_xy_to_geo, Ellipsoid, Equirectangular, GeoBounds, GeoCoord, Globe,
    Projection, TileId, VerticalPerspective, WebMercator, WorldCoord,
};

/// Release-support classification for camera projections.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProjectionSupport {
    /// Stable, intended as part of the supported `v1.0` surface.
    Stable,
    /// Present in the architecture and implementation, but not yet a stable
    /// `v1.0` promise.
    Experimental,
}

/// Camera-facing map projection selection.
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum CameraProjection {
    /// Web Mercator (EPSG:3857).
    #[default]
    WebMercator,
    /// Equirectangular / Plate Carree.
    Equirectangular,
    /// Globe / geocentric Earth-centered projection.
    Globe,
    /// Near-sided vertical perspective projection onto a tangent plane.
    VerticalPerspective {
        /// Tangency center of the projection.
        center: GeoCoord,
        /// Viewer height above the ellipsoid surface in meters.
        camera_height: f64,
    },
}

impl CameraProjection {
    /// Release-support classification for this projection.
    #[inline]
    pub fn support_level(&self) -> ProjectionSupport {
        match self {
            Self::WebMercator | Self::Equirectangular => ProjectionSupport::Stable,
            Self::Globe | Self::VerticalPerspective { .. } => ProjectionSupport::Experimental,
        }
    }

    /// Whether this projection is part of the intended stable `v1.0` surface.
    #[inline]
    pub fn is_stable_for_v1(&self) -> bool {
        matches!(self.support_level(), ProjectionSupport::Stable)
    }

    /// Whether this projection should be treated as experimental in `v1.0`.
    #[inline]
    pub fn is_experimental_for_v1(&self) -> bool {
        matches!(self.support_level(), ProjectionSupport::Experimental)
    }

    /// Construct a vertical-perspective projection from a center point and viewer height.
    #[inline]
    pub fn vertical_perspective(center: GeoCoord, camera_height: f64) -> Self {
        Self::VerticalPerspective {
            center: GeoCoord::from_lat_lon(center.lat, center.lon),
            camera_height: if camera_height.is_finite() {
                camera_height.max(1.0)
            } else {
                1.0
            },
        }
    }

    /// Return the configured center for center-aware projections.
    #[inline]
    pub fn center(&self) -> Option<GeoCoord> {
        match self {
            Self::VerticalPerspective { center, .. } => Some(*center),
            _ => None,
        }
    }

    /// Return the configured viewer height for vertical perspective.
    #[inline]
    pub fn camera_height(&self) -> Option<f64> {
        match self {
            Self::VerticalPerspective { camera_height, .. } => Some(*camera_height),
            _ => None,
        }
    }

    /// Whether this projection is fully compatible with the current
    /// slippy-map tile and terrain update path.
    #[inline]
    pub fn is_tile_compatible(&self) -> bool {
        matches!(self, Self::WebMercator | Self::Equirectangular)
    }

    /// Project a geographic coordinate into engine world space.
    #[inline]
    pub fn project(&self, geo: &GeoCoord) -> WorldCoord {
        match self {
            Self::WebMercator => WebMercator::project(geo),
            Self::Equirectangular => Equirectangular.project(geo),
            Self::Globe => Globe::project(geo),
            Self::VerticalPerspective {
                center,
                camera_height,
            } => VerticalPerspective::new(*center, *camera_height).project(geo),
        }
    }

    /// Inverse-project engine world space back into geographic coordinates.
    #[inline]
    pub fn unproject(&self, world: &WorldCoord) -> GeoCoord {
        match self {
            Self::WebMercator => WebMercator::unproject(world),
            Self::Equirectangular => Equirectangular.unproject(world),
            Self::Globe => Globe::unproject(world),
            Self::VerticalPerspective {
                center,
                camera_height,
            } => VerticalPerspective::new(*center, *camera_height).unproject(world),
        }
    }

    /// Projection-local scale factor at the given geographic coordinate.
    #[inline]
    pub fn scale_factor(&self, geo: &GeoCoord) -> f64 {
        match self {
            Self::WebMercator => WebMercator.scale_factor(geo),
            Self::Equirectangular => Equirectangular.scale_factor(geo),
            Self::Globe => Globe.scale_factor(geo),
            Self::VerticalPerspective {
                center,
                camera_height,
            } => VerticalPerspective::new(*center, *camera_height).scale_factor(geo),
        }
    }

    /// Maximum useful half-extent for camera clamping and wrapping.
    #[inline]
    pub fn max_extent(&self) -> f64 {
        match self {
            Self::WebMercator => WebMercator::max_extent(),
            Self::Equirectangular => std::f64::consts::PI * Ellipsoid::WGS84.a,
            Self::Globe => Globe::radius(),
            Self::VerticalPerspective {
                center,
                camera_height,
            } => {
                let projection = VerticalPerspective::new(*center, *camera_height);
                let horizon = projection.horizon_central_angle();
                projection.radius() * horizon.tan()
            }
        }
    }

    /// Full projected world width in meters.
    #[inline]
    pub fn world_size(&self) -> f64 {
        match self {
            Self::Globe => Globe::radius() * 2.0,
            _ => self.max_extent() * 2.0,
        }
    }

    /// Project a geographic coordinate into the active planar world space as
    /// raw XYZ meters.
    #[inline]
    pub fn project_position(&self, geo: &GeoCoord) -> [f64; 3] {
        let world = self.project(geo);
        [world.position.x, world.position.y, world.position.z]
    }

    /// Geographic bounds for a slippy-map tile, expressed as south-west and
    /// north-east corners.
    #[inline]
    pub fn tile_geo_bounds(&self, tile: &TileId) -> GeoBounds {
        match self {
            Self::Equirectangular => {
                let tiles_per_axis = (1u32 << tile.zoom) as f64;
                let west = tile.x as f64 / tiles_per_axis * 360.0 - 180.0;
                let east = (tile.x as f64 + 1.0) / tiles_per_axis * 360.0 - 180.0;
                let north = 90.0 - tile.y as f64 / tiles_per_axis * 180.0;
                let south = 90.0 - (tile.y as f64 + 1.0) / tiles_per_axis * 180.0;

                GeoBounds::new(
                    GeoCoord::from_lat_lon(south, west),
                    GeoCoord::from_lat_lon(north, east),
                )
            }
            _ => {
                let nw = tile_to_geo(tile);
                let se = tile_xy_to_geo(tile.zoom, tile.x as f64 + 1.0, tile.y as f64 + 1.0);

                GeoBounds::new(
                    GeoCoord::from_lat_lon(se.lat.min(nw.lat), nw.lon.min(se.lon)),
                    GeoCoord::from_lat_lon(se.lat.max(nw.lat), nw.lon.max(se.lon)),
                )
            }
        }
    }

    /// Project a tile corner into the active planar world space.
    #[inline]
    pub fn project_tile_corner(&self, tile: &TileId, u: f64, v: f64) -> [f64; 3] {
        let bounds = self.tile_geo_bounds(tile);
        let lat = bounds.north() + (bounds.south() - bounds.north()) * v.clamp(0.0, 1.0);
        let lon = bounds.west() + (bounds.east() - bounds.west()) * u.clamp(0.0, 1.0);
        self.project_position(&GeoCoord::from_lat_lon(lat, lon))
    }

    /// Project the geographic centre of a tile into planar world space.
    ///
    /// This is equivalent to averaging the four projected corners but
    /// requires only a single projection call, making it ~4× cheaper for
    /// the per-frame tile reposition path.
    #[inline]
    pub fn project_tile_center(&self, tile: &TileId) -> [f64; 3] {
        let bounds = self.tile_geo_bounds(tile);
        let mid_lat = (bounds.north() + bounds.south()) * 0.5;
        let mid_lon = (bounds.east() + bounds.west()) * 0.5;
        self.project_position(&GeoCoord::from_lat_lon(mid_lat, mid_lon))
    }
}

impl Eq for CameraProjection {}

impl std::hash::Hash for CameraProjection {
    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
        std::mem::discriminant(self).hash(state);
        if let Self::VerticalPerspective {
            center,
            camera_height,
        } = self
        {
            center.lat.to_bits().hash(state);
            center.lon.to_bits().hash(state);
            camera_height.to_bits().hash(state);
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn default_is_web_mercator() {
        assert_eq!(CameraProjection::default(), CameraProjection::WebMercator);
    }

    #[test]
    fn support_levels_match_v1_contract() {
        assert_eq!(
            CameraProjection::WebMercator.support_level(),
            ProjectionSupport::Stable
        );
        assert_eq!(
            CameraProjection::Equirectangular.support_level(),
            ProjectionSupport::Stable
        );
        assert_eq!(
            CameraProjection::Globe.support_level(),
            ProjectionSupport::Experimental
        );
        assert!(matches!(
            CameraProjection::vertical_perspective(GeoCoord::from_lat_lon(0.0, 0.0), 1_000_000.0)
                .support_level(),
            ProjectionSupport::Experimental
        ));
    }

    #[test]
    fn equirectangular_roundtrip() {
        let projection = CameraProjection::Equirectangular;
        let geo = GeoCoord::new(25.0, 30.0, 100.0);
        let world = projection.project(&geo);
        let back = projection.unproject(&world);
        assert!((back.lat - geo.lat).abs() < 1e-8);
        assert!((back.lon - geo.lon).abs() < 1e-8);
        assert!((back.alt - geo.alt).abs() < 1e-8);
    }

    #[test]
    fn globe_roundtrip() {
        let projection = CameraProjection::Globe;
        let geo = GeoCoord::new(51.1, 17.0, 1200.0);
        let world = projection.project(&geo);
        let back = projection.unproject(&world);
        assert!((back.lat - geo.lat).abs() < 1e-6);
        assert!((back.lon - geo.lon).abs() < 1e-6);
        assert!((back.alt - geo.alt).abs() < 1e-3);
    }

    #[test]
    fn vertical_perspective_roundtrip() {
        let projection =
            CameraProjection::vertical_perspective(GeoCoord::from_lat_lon(0.0, 0.0), 8_000_000.0);
        let geo = GeoCoord::new(10.0, 15.0, 250.0);
        let world = projection.project(&geo);
        assert!(world.position.x.is_finite());
        assert!(world.position.y.is_finite());
        let back = projection.unproject(&world);
        assert!((back.lat - geo.lat).abs() < 1e-6);
        assert!((back.lon - geo.lon).abs() < 1e-6);
    }
}