Skip to main content

terrain_codec/
tile_coords.rs

1//! Tile coordinate utilities for the tiling schemes commonly used with
2//! Cesium terrain and Web Mercator raster sources.
3//!
4//! Three schemes are supported, each in its own submodule:
5//!
6//! - [`web_mercator`] — XYZ Web Mercator (Google / OSM / Mapbox).
7//!   `2^z × 2^z` tiles per zoom. Y=0 is north.
8//! - [`web_mercator_tms`] — TMS-flipped Web Mercator. Same X math as
9//!   `web_mercator`, but Y=0 is south (Y axis flipped).
10//! - [`geodetic_tms`] — Global-geodetic TMS used by Cesium terrain
11//!   (heightmap-1.0 / quantized-mesh-1.0). `2^(z+1) × 2^z` tiles per
12//!   zoom; X covers 360° of longitude, Y covers 180° of latitude.
13//!   Y=0 is south.
14//!
15//! All functions return `(x, y)` integer tile coordinates and accept
16//! longitudes / latitudes in **degrees**. Out-of-range inputs are
17//! clamped to the scheme's valid domain.
18
19/// XYZ Web Mercator tiling: `2^z × 2^z` tiles per zoom, Y=0 north.
20///
21/// Used by OpenStreetMap, Google Maps, Mapbox raster tiles, etc.
22pub mod web_mercator {
23    /// Maximum latitude representable in Web Mercator (degrees).
24    ///
25    /// Derived from `2 * atan(e^π) - π/2 ≈ 85.05112878°`. Beyond this
26    /// latitude the Mercator projection diverges.
27    pub const MAX_LAT: f64 = 85.05112877980659;
28
29    /// Number of tiles along each axis at `zoom`.
30    #[inline]
31    pub const fn tile_count(zoom: u8) -> u32 {
32        1u32 << zoom
33    }
34
35    /// Convert `(lon, lat)` in degrees to XYZ tile coordinates at `zoom`.
36    ///
37    /// Longitudes are clamped to `[-180, 180]` and latitudes to
38    /// `[-MAX_LAT, MAX_LAT]`.
39    pub fn lonlat_to_tile(lon: f64, lat: f64, zoom: u8) -> (u32, u32) {
40        let n = tile_count(zoom);
41        let lon = lon.clamp(-180.0, 180.0);
42        let lat = lat.clamp(-MAX_LAT, MAX_LAT);
43
44        let x = (((lon + 180.0) / 360.0) * n as f64).floor() as u32;
45        let lat_rad = lat.to_radians();
46        let y =
47            ((1.0 - lat_rad.tan().asinh() / std::f64::consts::PI) / 2.0 * n as f64).floor() as u32;
48        (x.min(n - 1), y.min(n - 1))
49    }
50
51    /// Convert XYZ tile `(z, x, y)` to its geographic bounds in degrees,
52    /// returning `(west, south, east, north)`.
53    pub fn tile_to_bounds(z: u8, x: u32, y: u32) -> (f64, f64, f64, f64) {
54        let n = tile_count(z) as f64;
55        let west = x as f64 / n * 360.0 - 180.0;
56        let east = (x as f64 + 1.0) / n * 360.0 - 180.0;
57
58        let lat_at = |yy: f64| -> f64 {
59            let m = std::f64::consts::PI * (1.0 - 2.0 * yy / n);
60            m.sinh().atan().to_degrees()
61        };
62        let north = lat_at(y as f64);
63        let south = lat_at(y as f64 + 1.0);
64        (west, south, east, north)
65    }
66}
67
68/// TMS-flipped Web Mercator tiling. Same X math as [`web_mercator`], but
69/// Y=0 is south (i.e. `tms_y = (2^z - 1) - xyz_y`).
70pub mod web_mercator_tms {
71    use super::web_mercator;
72
73    /// Convert `(lon, lat)` to TMS-flipped Web Mercator tile coordinates.
74    pub fn lonlat_to_tile(lon: f64, lat: f64, zoom: u8) -> (u32, u32) {
75        let (x, xyz_y) = web_mercator::lonlat_to_tile(lon, lat, zoom);
76        let max_y = web_mercator::tile_count(zoom) - 1;
77        (x, max_y - xyz_y)
78    }
79
80    /// Convert TMS tile `(z, x, y)` to geographic bounds in degrees,
81    /// returning `(west, south, east, north)`.
82    pub fn tile_to_bounds(z: u8, x: u32, y: u32) -> (f64, f64, f64, f64) {
83        let max_y = web_mercator::tile_count(z) - 1;
84        web_mercator::tile_to_bounds(z, x, max_y - y)
85    }
86
87    /// Convert an XYZ Y coordinate to its TMS equivalent (or vice versa).
88    #[inline]
89    pub const fn flip_y(zoom: u8, y: u32) -> u32 {
90        ((1u32 << zoom) - 1) - y
91    }
92}
93
94/// Global-geodetic TMS tiling used by Cesium terrain (heightmap-1.0 /
95/// quantized-mesh-1.0). `2^(z+1) × 2^z` tiles per zoom level. Y=0 is at
96/// the south pole.
97pub mod geodetic_tms {
98    /// Number of tiles in the X direction at `zoom`.
99    #[inline]
100    pub const fn tile_count_x(zoom: u8) -> u32 {
101        1u32 << (zoom + 1)
102    }
103
104    /// Number of tiles in the Y direction at `zoom`.
105    #[inline]
106    pub const fn tile_count_y(zoom: u8) -> u32 {
107        1u32 << zoom
108    }
109
110    /// Convert `(lon, lat)` in degrees to geodetic TMS tile coordinates.
111    pub fn lonlat_to_tile(lon: f64, lat: f64, zoom: u8) -> (u32, u32) {
112        let lon = lon.clamp(-180.0, 180.0);
113        let lat = lat.clamp(-90.0, 90.0);
114
115        let nx = tile_count_x(zoom);
116        let ny = tile_count_y(zoom);
117
118        let x = (((lon + 180.0) / 360.0) * nx as f64).floor() as u32;
119        let y = (((lat + 90.0) / 180.0) * ny as f64).floor() as u32;
120        (x.min(nx - 1), y.min(ny - 1))
121    }
122
123    /// Convert geodetic TMS tile `(z, x, y)` to bounds in degrees,
124    /// returning `(west, south, east, north)`.
125    pub fn tile_to_bounds(z: u8, x: u32, y: u32) -> (f64, f64, f64, f64) {
126        let nx = tile_count_x(z) as f64;
127        let ny = tile_count_y(z) as f64;
128        let west = x as f64 / nx * 360.0 - 180.0;
129        let east = (x as f64 + 1.0) / nx * 360.0 - 180.0;
130        let south = y as f64 / ny * 180.0 - 90.0;
131        let north = (y as f64 + 1.0) / ny * 180.0 - 90.0;
132        (west, south, east, north)
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn web_mercator_origin_at_zoom_0_is_top_left() {
142        // (lon=-180, lat=+MAX_LAT) sits at the NW corner of tile (0,0).
143        let (x, y) = web_mercator::lonlat_to_tile(-180.0, web_mercator::MAX_LAT - 0.01, 0);
144        assert_eq!((x, y), (0, 0));
145    }
146
147    #[test]
148    fn web_mercator_y_inverts_to_tms() {
149        let zoom = 3;
150        for y in 0..web_mercator::tile_count(zoom) {
151            assert_eq!(web_mercator_tms::flip_y(zoom, y), (1 << zoom) - 1 - y);
152            // Double-flip is identity.
153            assert_eq!(
154                web_mercator_tms::flip_y(zoom, web_mercator_tms::flip_y(zoom, y)),
155                y
156            );
157        }
158    }
159
160    #[test]
161    fn web_mercator_tile_to_bounds_roundtrip_for_centre_points() {
162        let zoom = 4;
163        for x in 0..web_mercator::tile_count(zoom) {
164            for y in 0..web_mercator::tile_count(zoom) {
165                let (w, s, e, n) = web_mercator::tile_to_bounds(zoom, x, y);
166                let cx = (w + e) * 0.5;
167                let cy = (s + n) * 0.5;
168                let (x2, y2) = web_mercator::lonlat_to_tile(cx, cy, zoom);
169                assert_eq!((x, y), (x2, y2), "tile centre should map back to its tile");
170            }
171        }
172    }
173
174    #[test]
175    fn geodetic_tms_zoom_0_has_two_tiles_in_x() {
176        assert_eq!(geodetic_tms::tile_count_x(0), 2);
177        assert_eq!(geodetic_tms::tile_count_y(0), 1);
178    }
179
180    #[test]
181    fn geodetic_tms_y0_is_south() {
182        let (_, y) = geodetic_tms::lonlat_to_tile(0.0, -89.0, 4);
183        assert_eq!(y, 0);
184        let (_, y) = geodetic_tms::lonlat_to_tile(0.0, 89.0, 4);
185        assert_eq!(y, geodetic_tms::tile_count_y(4) - 1);
186    }
187
188    #[test]
189    fn geodetic_tms_bounds_cover_globe_at_zoom_0() {
190        let (w0, s0, e0, n0) = geodetic_tms::tile_to_bounds(0, 0, 0);
191        let (w1, _, e1, _) = geodetic_tms::tile_to_bounds(0, 1, 0);
192        assert_eq!((w0, s0, e0, n0), (-180.0, -90.0, 0.0, 90.0));
193        assert_eq!((w1, e1), (0.0, 180.0));
194    }
195}