Skip to main content

terrain_codec/
mercator.rs

1//! Reproject web-mercator (XYZ) DEM tiles onto a geodetic (EPSG:4326) grid.
2//!
3//! Elevation tiles are almost always served in the **web-mercator** XYZ
4//! tiling (Terrarium, Mapbox Terrain-RGB, …), while Cesium quantized-mesh
5//! terrain is served in the **geodetic TMS** scheme (`EPSG:4326`). Those are
6//! different *projections*, not just different tilings — web-mercator's
7//! latitude axis is non-linear — so producing a geodetic terrain tile means
8//! **resampling** (warping) the mercator DEM, not merely cropping and
9//! stitching it.
10//!
11//! [`MercatorDem`] holds a contiguous block of decoded web-mercator DEM
12//! tiles stitched into one grid and lets you sample it by longitude /
13//! latitude (bilinear). From there:
14//!
15//! - [`MercatorDem::geodetic_grid`] produces the `2^n+1` elevation grid that
16//!   [`crate::terrain::encode_terrain`] expects, and
17//! - [`MercatorDem::buffered_geodetic`] produces the halo-extended
18//!   [`BufferedElevations`] that [`crate::terrain::NormalMode::BufferedGradient`]
19//!   expects for seam-free normals.
20//!
21//! Fetching the source tiles is left to the caller (HTTP, disk, cache, …) so
22//! this module stays free of any IO/async assumptions — supply an already
23//! decoded tile via [`MercatorDem::new`] / [`MercatorDem::from_tiles`].
24//!
25//! # Example
26//!
27//! ```no_run
28//! use terrain_codec::quantized_mesh::TileBounds;
29//! use terrain_codec::mercator::MercatorDem;
30//! use terrain_codec::terrain::{encode_terrain, TerrainOptions};
31//! use terrain_codec::tile_coords::geodetic_tms;
32//!
33//! // Target geodetic TMS tile we want to emit.
34//! let (w, s, e, n) = geodetic_tms::tile_to_bounds(12, 7252, 2852);
35//! let bounds = TileBounds::new(w, s, e, n);
36//! let grid_size = 257; // 2^8 + 1
37//!
38//! // Source web-mercator DEM: decide a source zoom, find the covering XYZ
39//! // tiles, and assemble them (the closure does your fetch + heightmap decode).
40//! let src_zoom = 13;
41//! let tile_size = 512;
42//! let (x0, y0, tx, ty) = MercatorDem::tiles_covering(src_zoom, w, s, e, n);
43//! let dem = MercatorDem::from_tiles(src_zoom, x0, y0, tx, ty, tile_size, |z, x, y| {
44//!     // fetch z/x/y.png, decode to elevations (tile_size², row-major N→S)
45//!     # unimplemented!()
46//! });
47//!
48//! let grid = dem.geodetic_grid(&bounds, grid_size);
49//! let terrain = encode_terrain(&grid, grid_size, &bounds, &TerrainOptions::default());
50//! ```
51
52use std::f64::consts::PI;
53
54use quantized_mesh::TileBounds;
55
56use crate::normals::BufferedElevations;
57use crate::tile_coords::web_mercator;
58
59/// A contiguous rectangular block of decoded web-mercator (XYZ) DEM tiles,
60/// stitched into a single elevation grid and sampleable by longitude /
61/// latitude.
62///
63/// The stitched grid is row-major **north → south**, `tiles_x * tile_size`
64/// columns by `tiles_y * tile_size` rows. Sampling outside the block clamps
65/// to the nearest edge sample, so a halo that slightly overshoots the
66/// fetched coverage degrades gracefully rather than panicking — but for
67/// correct results the block should cover the target bounds (plus any halo).
68#[derive(Debug, Clone)]
69pub struct MercatorDem {
70    zoom: u8,
71    x0: u32,
72    y0: u32,
73    tiles_x: u32,
74    tiles_y: u32,
75    tile_size: u32,
76    /// `(tiles_x*tile_size) × (tiles_y*tile_size)` elevations, row-major N→S.
77    elev: Vec<f32>,
78}
79
80impl MercatorDem {
81    /// Wrap an already-stitched elevation block.
82    ///
83    /// `elev` must be row-major north → south with
84    /// `(tiles_x*tile_size) * (tiles_y*tile_size)` entries.
85    ///
86    /// # Panics
87    ///
88    /// Panics on a length mismatch, or if any tile dimension is zero.
89    pub fn new(
90        zoom: u8,
91        x0: u32,
92        y0: u32,
93        tiles_x: u32,
94        tiles_y: u32,
95        tile_size: u32,
96        elev: Vec<f32>,
97    ) -> Self {
98        assert!(
99            tiles_x > 0 && tiles_y > 0 && tile_size > 0,
100            "tiles_x, tiles_y and tile_size must be non-zero"
101        );
102        let expected = (tiles_x * tile_size) as usize * (tiles_y * tile_size) as usize;
103        assert_eq!(
104            elev.len(),
105            expected,
106            "stitched elevation length mismatch: expected {expected}, got {}",
107            elev.len()
108        );
109        Self {
110            zoom,
111            x0,
112            y0,
113            tiles_x,
114            tiles_y,
115            tile_size,
116            elev,
117        }
118    }
119
120    /// Build a block by pulling each XYZ tile through `get_tile`, which
121    /// returns that tile's decoded elevations (`tile_size²`, row-major
122    /// north → south). The closure is where you do your fetch + heightmap
123    /// decode; for async callers, pre-fetch into a map and look it up here.
124    ///
125    /// Tiles are requested in row-major order `(x0, y0) … (x0+tiles_x-1,
126    /// y0+tiles_y-1)`.
127    ///
128    /// # Panics
129    ///
130    /// Panics if any returned tile does not have exactly `tile_size²` samples.
131    pub fn from_tiles<F>(
132        zoom: u8,
133        x0: u32,
134        y0: u32,
135        tiles_x: u32,
136        tiles_y: u32,
137        tile_size: u32,
138        mut get_tile: F,
139    ) -> Self
140    where
141        F: FnMut(u8, u32, u32) -> Vec<f32>,
142    {
143        let ts = tile_size as usize;
144        let w = (tiles_x * tile_size) as usize;
145        let h = (tiles_y * tile_size) as usize;
146        let mut elev = vec![0f32; w * h];
147
148        for tj in 0..tiles_y {
149            for ti in 0..tiles_x {
150                let tile = get_tile(zoom, x0 + ti, y0 + tj);
151                assert_eq!(
152                    tile.len(),
153                    ts * ts,
154                    "tile {}/{}/{} has {} samples, expected {}",
155                    zoom,
156                    x0 + ti,
157                    y0 + tj,
158                    tile.len(),
159                    ts * ts
160                );
161                let ox = ti as usize * ts;
162                let oy = tj as usize * ts;
163                for r in 0..ts {
164                    let dst = (oy + r) * w + ox;
165                    let src = r * ts;
166                    elev[dst..dst + ts].copy_from_slice(&tile[src..src + ts]);
167                }
168            }
169        }
170
171        Self::new(zoom, x0, y0, tiles_x, tiles_y, tile_size, elev)
172    }
173
174    /// Range of XYZ tiles at `zoom` covering a longitude/latitude box,
175    /// returning `(x0, y0, tiles_x, tiles_y)`.
176    ///
177    /// Widen the box by your halo before calling if you intend to sample a
178    /// buffer beyond the tile (e.g. for [`buffered_geodetic`](Self::buffered_geodetic)).
179    pub fn tiles_covering(
180        zoom: u8,
181        west: f64,
182        south: f64,
183        east: f64,
184        north: f64,
185    ) -> (u32, u32, u32, u32) {
186        // North maps to the smaller Y, south to the larger Y.
187        let (xw, yn) = web_mercator::lonlat_to_tile(west, north, zoom);
188        let (xe, ys) = web_mercator::lonlat_to_tile(east, south, zoom);
189        let x0 = xw.min(xe);
190        let x1 = xw.max(xe);
191        let y0 = yn.min(ys);
192        let y1 = yn.max(ys);
193        (x0, y0, x1 - x0 + 1, y1 - y0 + 1)
194    }
195
196    /// Stitched-grid width in pixels (`tiles_x * tile_size`).
197    #[inline]
198    pub fn width_px(&self) -> u32 {
199        self.tiles_x * self.tile_size
200    }
201
202    /// Stitched-grid height in pixels (`tiles_y * tile_size`).
203    #[inline]
204    pub fn height_px(&self) -> u32 {
205        self.tiles_y * self.tile_size
206    }
207
208    /// Bilinearly sample the elevation at `(lon, lat)` in degrees.
209    ///
210    /// Latitude is clamped to the web-mercator limit. Positions outside the
211    /// fetched block clamp to the nearest edge sample. `NaN` samples (e.g.
212    /// missing data filled by the caller) are tolerated: the interpolation
213    /// falls back to any defined neighbour, returning `NaN` only if all four
214    /// corners are `NaN`.
215    pub fn sample(&self, lon: f64, lat: f64) -> f32 {
216        let n_tiles = 1u32 << self.zoom;
217        let world_px = (n_tiles * self.tile_size) as f64;
218        let lat = lat.clamp(-web_mercator::MAX_LAT, web_mercator::MAX_LAT);
219
220        // Continuous global pixel coordinate, then local to the block, with a
221        // half-pixel shift so integer indices land on pixel centres.
222        let gx = (lon + 180.0) / 360.0 * world_px;
223        let lat_rad = lat.to_radians();
224        let gy = (1.0 - lat_rad.tan().asinh() / PI) / 2.0 * world_px;
225        let lx = gx - (self.x0 * self.tile_size) as f64 - 0.5;
226        let ly = gy - (self.y0 * self.tile_size) as f64 - 0.5;
227
228        let w = self.width_px() as i64;
229        let h = self.height_px() as i64;
230        let fx = lx.floor();
231        let fy = ly.floor();
232        let tx = lx - fx;
233        let ty = ly - fy;
234        let clamp = |v: i64, max: i64| v.clamp(0, max - 1);
235        let xi0 = clamp(fx as i64, w);
236        let xi1 = clamp(fx as i64 + 1, w);
237        let yi0 = clamp(fy as i64, h);
238        let yi1 = clamp(fy as i64 + 1, h);
239        let at = |xi: i64, yi: i64| -> f64 { self.elev[(yi * w + xi) as usize] as f64 };
240
241        let top = bilerp(at(xi0, yi0), at(xi1, yi0), tx);
242        let bot = bilerp(at(xi0, yi1), at(xi1, yi1), tx);
243        bilerp(top, bot, ty) as f32
244    }
245
246    /// Resample onto a geodetic `grid_size × grid_size` grid covering
247    /// `bounds`, row-major north → south — ready for
248    /// [`crate::terrain::encode_terrain`].
249    ///
250    /// # Panics
251    ///
252    /// Panics if `grid_size < 2`.
253    pub fn geodetic_grid(&self, bounds: &TileBounds, grid_size: u32) -> Vec<f32> {
254        assert!(grid_size >= 2, "grid_size must be >= 2");
255        let gs = grid_size as usize;
256        let lon_span = bounds.east - bounds.west;
257        let lat_span = bounds.north - bounds.south;
258        let denom = (grid_size - 1) as f64;
259        let mut grid = vec![0f32; gs * gs];
260        for j in 0..gs {
261            // Row 0 = north.
262            let lat = bounds.north - (j as f64 / denom) * lat_span;
263            for i in 0..gs {
264                let lon = bounds.west + (i as f64 / denom) * lon_span;
265                grid[j * gs + i] = self.sample(lon, lat);
266            }
267        }
268        grid
269    }
270
271    /// Resample onto a halo-extended geodetic grid — a
272    /// [`BufferedElevations`] for
273    /// [`crate::terrain::NormalMode::BufferedGradient`].
274    ///
275    /// The inner `tile_grid_size × tile_grid_size` block matches
276    /// [`geodetic_grid`](Self::geodetic_grid); the surrounding `buffer`-cell
277    /// strip is sampled from the neighbour area (so make sure this
278    /// `MercatorDem` was built to cover `bounds` widened by the halo).
279    ///
280    /// # Panics
281    ///
282    /// Panics if `tile_grid_size < 2`.
283    pub fn buffered_geodetic(
284        &self,
285        bounds: &TileBounds,
286        tile_grid_size: u32,
287        buffer: u32,
288    ) -> BufferedElevations {
289        assert!(tile_grid_size >= 2, "tile_grid_size must be >= 2");
290        let denom = (tile_grid_size - 1) as f64;
291        let cell_lon = (bounds.east - bounds.west) / denom;
292        let cell_lat = (bounds.north - bounds.south) / denom;
293        let full = (tile_grid_size + 2 * buffer) as usize;
294        let buf = buffer as f64;
295
296        let mut elev = Vec::with_capacity(full * full);
297        for j in 0..full {
298            // j = buffer → north edge; rows increase southward.
299            let lat = bounds.north + buf * cell_lat - (j as f64) * cell_lat;
300            for i in 0..full {
301                let lon = bounds.west - buf * cell_lon + (i as f64) * cell_lon;
302                elev.push(self.sample(lon, lat) as f64);
303            }
304        }
305        BufferedElevations::new(elev, tile_grid_size, buffer)
306    }
307}
308
309/// NaN-tolerant linear interpolation: falls back to a defined endpoint.
310#[inline]
311fn bilerp(a: f64, b: f64, t: f64) -> f64 {
312    if a.is_nan() {
313        b
314    } else if b.is_nan() {
315        a
316    } else {
317        a * (1.0 - t) + b * t
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    /// A block sampled at the exact lon/lat of one of its posts should return
326    /// that post's value (within float tolerance).
327    #[test]
328    fn sample_hits_pixel_centres() {
329        let zoom = 4;
330        let tile_size = 4;
331        let (x0, y0) = (3, 5);
332        let w = tile_size;
333        let h = tile_size;
334        // Distinct value per pixel so we can tell which one we hit.
335        let elev: Vec<f32> = (0..(w * h)).map(|i| i as f32).collect();
336        let dem = MercatorDem::new(zoom, x0, y0, 1, 1, tile_size, elev);
337
338        // Reconstruct the lon/lat of pixel-centre (1, 2) in the block.
339        let n_tiles = 1u32 << zoom;
340        let world_px = (n_tiles * tile_size) as f64;
341        let gx = (x0 * tile_size) as f64 + 1.0 + 0.5;
342        let gy = (y0 * tile_size) as f64 + 2.0 + 0.5;
343        let lon = gx / world_px * 360.0 - 180.0;
344        // invert gy = (1 - asinh(tan lat)/PI)/2 * world_px
345        let m = PI * (1.0 - 2.0 * gy / world_px);
346        let lat = m.sinh().atan().to_degrees();
347
348        let expected = (2 * w + 1) as f32; // row 2, col 1
349        let got = dem.sample(lon, lat);
350        assert!(
351            (got - expected).abs() < 1e-3,
352            "expected {expected}, got {got}"
353        );
354    }
355
356    /// Bilinear sampling halfway between two posts averages them.
357    #[test]
358    fn sample_interpolates_between_posts() {
359        let zoom = 4;
360        let tile_size = 4;
361        // Ramp in x: value == column index.
362        let elev: Vec<f32> = (0..(tile_size * tile_size))
363            .map(|i| (i % tile_size) as f32)
364            .collect();
365        let dem = MercatorDem::new(zoom, 0, 0, 1, 1, tile_size, elev);
366
367        let world_px = ((1u32 << zoom) * tile_size) as f64;
368        // Halfway between column 1 (centre gx=1.5) and column 2 (gx=2.5): gx=2.0.
369        let lon = 2.0 / world_px * 360.0 - 180.0;
370        let lat = 0.0; // any latitude inside the tile is fine for the x-ramp
371        let got = dem.sample(lon, lat);
372        assert!((got - 1.5).abs() < 1e-3, "expected ~1.5, got {got}");
373    }
374
375    #[test]
376    fn tiles_covering_is_at_least_one_tile() {
377        let (w, s, e, n) = web_mercator::tile_to_bounds(12, 3626, 1617);
378        let (x0, y0, tx, ty) = MercatorDem::tiles_covering(12, w, s, e, n);
379        // The source box is exactly one z12 tile, so it covers 1–2 tiles per axis.
380        assert_eq!(x0, 3626);
381        assert_eq!(y0, 1617);
382        assert!((1..=2).contains(&tx));
383        assert!((1..=2).contains(&ty));
384    }
385
386    #[test]
387    fn geodetic_grid_of_flat_dem_is_flat() {
388        let dem = MercatorDem::new(10, 0, 0, 1, 1, 8, vec![42.0f32; 64]);
389        let bounds = TileBounds::new(0.0, 0.0, 1.0, 1.0);
390        let grid = dem.geodetic_grid(&bounds, 17);
391        assert_eq!(grid.len(), 17 * 17);
392        assert!(grid.iter().all(|&v| (v - 42.0).abs() < 1e-4));
393    }
394
395    #[test]
396    fn buffered_inner_block_matches_geodetic_grid() {
397        // A smooth ramp so resampling is well-defined, then check the inner
398        // block of the buffered grid equals the plain geodetic grid.
399        let zoom = 10;
400        let tile_size = 64;
401        let elev: Vec<f32> = (0..(tile_size * tile_size))
402            .map(|i| ((i % tile_size) + (i / tile_size)) as f32)
403            .collect();
404        let dem = MercatorDem::new(zoom, 100, 100, 1, 1, tile_size, elev);
405
406        // Bounds well inside the tile so the halo stays in coverage.
407        let (w, s, e, n) = web_mercator::tile_to_bounds(zoom, 100, 100);
408        let inset_x = (e - w) * 0.2;
409        let inset_y = (n - s) * 0.2;
410        let bounds = TileBounds::new(w + inset_x, s + inset_y, e - inset_x, n - inset_y);
411
412        let tile_grid = 33u32;
413        let buffer = 2u32;
414        let plain = dem.geodetic_grid(&bounds, tile_grid);
415        let buffered = dem.buffered_geodetic(&bounds, tile_grid, buffer);
416
417        let full = (tile_grid + 2 * buffer) as usize;
418        let b = buffer as usize;
419        let tg = tile_grid as usize;
420        for j in 0..tg {
421            for i in 0..tg {
422                let inner = buffered.elevations[(j + b) * full + (i + b)] as f32;
423                let p = plain[j * tg + i];
424                assert!(
425                    (inner - p).abs() < 1e-3,
426                    "inner block mismatch at ({i},{j}): {inner} vs {p}"
427                );
428            }
429        }
430    }
431
432    #[test]
433    fn end_to_end_with_encode_terrain() {
434        use crate::terrain::{TerrainOptions, encode_terrain};
435        use quantized_mesh::DecodedMesh;
436
437        let zoom = 12;
438        let tile_size = 64;
439        // A gentle bump so martini produces more than the corner triangles.
440        let elev: Vec<f32> = (0..(tile_size * tile_size))
441            .map(|i| {
442                let x = (i % tile_size) as f32;
443                let y = (i / tile_size) as f32;
444                (x / 8.0).sin() * 20.0 + (y / 8.0).cos() * 15.0
445            })
446            .collect();
447        let dem = MercatorDem::new(zoom, 3626, 1617, 1, 1, tile_size, elev);
448
449        let (w, s, e, n) = web_mercator::tile_to_bounds(zoom, 3626, 1617);
450        let bounds = TileBounds::new(w, s, e, n);
451        let grid = dem.geodetic_grid(&bounds, 65);
452        let bytes = encode_terrain(
453            &grid,
454            65,
455            &bounds,
456            &TerrainOptions {
457                compression_level: 0,
458                ..Default::default()
459            },
460        );
461        let mesh = DecodedMesh::decode(&bytes).expect("decode");
462        assert!(mesh.vertices.len() >= 4);
463        assert!(mesh.indices.len() >= 6);
464    }
465}