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    /// Build a [`RowSampler`] for a fixed latitude.
247    ///
248    /// This performs all the latitude-only work of [`sample`](Self::sample)
249    /// — most importantly the web-mercator inverse `tan().asinh()` — once,
250    /// so a whole grid row of longitudes then costs only linear index math
251    /// plus a bilinear blend. The per-lon result is bit-for-bit identical to
252    /// calling `sample(lon, lat)`.
253    #[inline]
254    fn row_sampler(&self, lat: f64) -> RowSampler<'_> {
255        let n_tiles = 1u32 << self.zoom;
256        let world_px = (n_tiles * self.tile_size) as f64;
257        let lat = lat.clamp(-web_mercator::MAX_LAT, web_mercator::MAX_LAT);
258        let lat_rad = lat.to_radians();
259        let gy = (1.0 - lat_rad.tan().asinh() / PI) / 2.0 * world_px;
260
261        let w = self.width_px() as i64;
262        let h = self.height_px() as i64;
263        let ly = gy - (self.y0 * self.tile_size) as f64 - 0.5;
264        let fy = ly.floor();
265        let ty = ly - fy;
266        let fyi = fy as i64;
267        let yi0 = fyi.clamp(0, h - 1);
268        let yi1 = (fyi + 1).clamp(0, h - 1);
269
270        RowSampler {
271            elev: &self.elev,
272            w,
273            row0: yi0 * w,
274            row1: yi1 * w,
275            ty,
276            world_px,
277            x_base: (self.x0 * self.tile_size) as f64 + 0.5,
278        }
279    }
280
281    /// Resample onto a geodetic `grid_size × grid_size` grid covering
282    /// `bounds`, row-major north → south — ready for
283    /// [`crate::terrain::encode_terrain`].
284    ///
285    /// # Panics
286    ///
287    /// Panics if `grid_size < 2`.
288    pub fn geodetic_grid(&self, bounds: &TileBounds, grid_size: u32) -> Vec<f32> {
289        assert!(grid_size >= 2, "grid_size must be >= 2");
290        let gs = grid_size as usize;
291        let lon_span = bounds.east - bounds.west;
292        let lat_span = bounds.north - bounds.south;
293        let denom = (grid_size - 1) as f64;
294        let mut grid = vec![0f32; gs * gs];
295        for j in 0..gs {
296            // Row 0 = north. Latitude is fixed across the row, so the
297            // web-mercator inverse (`tan().asinh()`) is done once here.
298            let lat = bounds.north - (j as f64 / denom) * lat_span;
299            let row = self.row_sampler(lat);
300            let out = &mut grid[j * gs..j * gs + gs];
301            for (i, cell) in out.iter_mut().enumerate() {
302                let lon = bounds.west + (i as f64 / denom) * lon_span;
303                *cell = row.sample_lon(lon);
304            }
305        }
306        grid
307    }
308
309    /// Resample onto a halo-extended geodetic grid — a
310    /// [`BufferedElevations`] for
311    /// [`crate::terrain::NormalMode::BufferedGradient`].
312    ///
313    /// The inner `tile_grid_size × tile_grid_size` block matches
314    /// [`geodetic_grid`](Self::geodetic_grid); the surrounding `buffer`-cell
315    /// strip is sampled from the neighbour area (so make sure this
316    /// `MercatorDem` was built to cover `bounds` widened by the halo).
317    ///
318    /// # Panics
319    ///
320    /// Panics if `tile_grid_size < 2`.
321    pub fn buffered_geodetic(
322        &self,
323        bounds: &TileBounds,
324        tile_grid_size: u32,
325        buffer: u32,
326    ) -> BufferedElevations {
327        assert!(tile_grid_size >= 2, "tile_grid_size must be >= 2");
328        let denom = (tile_grid_size - 1) as f64;
329        let cell_lon = (bounds.east - bounds.west) / denom;
330        let cell_lat = (bounds.north - bounds.south) / denom;
331        let full = (tile_grid_size + 2 * buffer) as usize;
332        let buf = buffer as f64;
333
334        let mut elev = Vec::with_capacity(full * full);
335        for j in 0..full {
336            // j = buffer → north edge; rows increase southward. Latitude is
337            // fixed across the row, so hoist the transcendental setup once.
338            let lat = bounds.north + buf * cell_lat - (j as f64) * cell_lat;
339            let row = self.row_sampler(lat);
340            for i in 0..full {
341                let lon = bounds.west - buf * cell_lon + (i as f64) * cell_lon;
342                elev.push(row.sample_lon(lon) as f64);
343            }
344        }
345        BufferedElevations::new(elev, tile_grid_size, buffer)
346    }
347}
348
349/// Latitude-fixed sampling state for one output row (see
350/// [`MercatorDem::row_sampler`]). Holds the two bracketing pixel rows and the
351/// vertical blend factor precomputed, so [`sample_lon`](Self::sample_lon)
352/// only has to resolve the longitude axis.
353struct RowSampler<'a> {
354    elev: &'a [f32],
355    w: i64,
356    /// `yi0 * w` — base offset of the north bracketing row.
357    row0: i64,
358    /// `yi1 * w` — base offset of the south bracketing row.
359    row1: i64,
360    ty: f64,
361    world_px: f64,
362    /// `x0 * tile_size + 0.5` — the half-pixel-shifted block origin.
363    x_base: f64,
364}
365
366impl RowSampler<'_> {
367    /// Sample the row at a single longitude. Equivalent to
368    /// `MercatorDem::sample(lon, lat)` for this row's latitude.
369    #[inline]
370    fn sample_lon(&self, lon: f64) -> f32 {
371        let lx = (lon + 180.0) / 360.0 * self.world_px - self.x_base;
372        let fx = lx.floor();
373        let tx = lx - fx;
374        let fxi = fx as i64;
375        let xi0 = fxi.clamp(0, self.w - 1);
376        let xi1 = (fxi + 1).clamp(0, self.w - 1);
377        let top = bilerp(
378            self.elev[(self.row0 + xi0) as usize] as f64,
379            self.elev[(self.row0 + xi1) as usize] as f64,
380            tx,
381        );
382        let bot = bilerp(
383            self.elev[(self.row1 + xi0) as usize] as f64,
384            self.elev[(self.row1 + xi1) as usize] as f64,
385            tx,
386        );
387        bilerp(top, bot, self.ty) as f32
388    }
389}
390
391/// NaN-tolerant linear interpolation: falls back to a defined endpoint.
392#[inline]
393fn bilerp(a: f64, b: f64, t: f64) -> f64 {
394    if a.is_nan() {
395        b
396    } else if b.is_nan() {
397        a
398    } else {
399        a * (1.0 - t) + b * t
400    }
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406
407    /// A block sampled at the exact lon/lat of one of its posts should return
408    /// that post's value (within float tolerance).
409    #[test]
410    fn sample_hits_pixel_centres() {
411        let zoom = 4;
412        let tile_size = 4;
413        let (x0, y0) = (3, 5);
414        let w = tile_size;
415        let h = tile_size;
416        // Distinct value per pixel so we can tell which one we hit.
417        let elev: Vec<f32> = (0..(w * h)).map(|i| i as f32).collect();
418        let dem = MercatorDem::new(zoom, x0, y0, 1, 1, tile_size, elev);
419
420        // Reconstruct the lon/lat of pixel-centre (1, 2) in the block.
421        let n_tiles = 1u32 << zoom;
422        let world_px = (n_tiles * tile_size) as f64;
423        let gx = (x0 * tile_size) as f64 + 1.0 + 0.5;
424        let gy = (y0 * tile_size) as f64 + 2.0 + 0.5;
425        let lon = gx / world_px * 360.0 - 180.0;
426        // invert gy = (1 - asinh(tan lat)/PI)/2 * world_px
427        let m = PI * (1.0 - 2.0 * gy / world_px);
428        let lat = m.sinh().atan().to_degrees();
429
430        let expected = (2 * w + 1) as f32; // row 2, col 1
431        let got = dem.sample(lon, lat);
432        assert!(
433            (got - expected).abs() < 1e-3,
434            "expected {expected}, got {got}"
435        );
436    }
437
438    /// Bilinear sampling halfway between two posts averages them.
439    #[test]
440    fn sample_interpolates_between_posts() {
441        let zoom = 4;
442        let tile_size = 4;
443        // Ramp in x: value == column index.
444        let elev: Vec<f32> = (0..(tile_size * tile_size))
445            .map(|i| (i % tile_size) as f32)
446            .collect();
447        let dem = MercatorDem::new(zoom, 0, 0, 1, 1, tile_size, elev);
448
449        let world_px = ((1u32 << zoom) * tile_size) as f64;
450        // Halfway between column 1 (centre gx=1.5) and column 2 (gx=2.5): gx=2.0.
451        let lon = 2.0 / world_px * 360.0 - 180.0;
452        let lat = 0.0; // any latitude inside the tile is fine for the x-ramp
453        let got = dem.sample(lon, lat);
454        assert!((got - 1.5).abs() < 1e-3, "expected ~1.5, got {got}");
455    }
456
457    #[test]
458    fn tiles_covering_is_at_least_one_tile() {
459        let (w, s, e, n) = web_mercator::tile_to_bounds(12, 3626, 1617);
460        let (x0, y0, tx, ty) = MercatorDem::tiles_covering(12, w, s, e, n);
461        // The source box is exactly one z12 tile, so it covers 1–2 tiles per axis.
462        assert_eq!(x0, 3626);
463        assert_eq!(y0, 1617);
464        assert!((1..=2).contains(&tx));
465        assert!((1..=2).contains(&ty));
466    }
467
468    #[test]
469    fn geodetic_grid_of_flat_dem_is_flat() {
470        let dem = MercatorDem::new(10, 0, 0, 1, 1, 8, vec![42.0f32; 64]);
471        let bounds = TileBounds::new(0.0, 0.0, 1.0, 1.0);
472        let grid = dem.geodetic_grid(&bounds, 17);
473        assert_eq!(grid.len(), 17 * 17);
474        assert!(grid.iter().all(|&v| (v - 42.0).abs() < 1e-4));
475    }
476
477    #[test]
478    fn buffered_inner_block_matches_geodetic_grid() {
479        // A smooth ramp so resampling is well-defined, then check the inner
480        // block of the buffered grid equals the plain geodetic grid.
481        let zoom = 10;
482        let tile_size = 64;
483        let elev: Vec<f32> = (0..(tile_size * tile_size))
484            .map(|i| ((i % tile_size) + (i / tile_size)) as f32)
485            .collect();
486        let dem = MercatorDem::new(zoom, 100, 100, 1, 1, tile_size, elev);
487
488        // Bounds well inside the tile so the halo stays in coverage.
489        let (w, s, e, n) = web_mercator::tile_to_bounds(zoom, 100, 100);
490        let inset_x = (e - w) * 0.2;
491        let inset_y = (n - s) * 0.2;
492        let bounds = TileBounds::new(w + inset_x, s + inset_y, e - inset_x, n - inset_y);
493
494        let tile_grid = 33u32;
495        let buffer = 2u32;
496        let plain = dem.geodetic_grid(&bounds, tile_grid);
497        let buffered = dem.buffered_geodetic(&bounds, tile_grid, buffer);
498
499        let full = (tile_grid + 2 * buffer) as usize;
500        let b = buffer as usize;
501        let tg = tile_grid as usize;
502        for j in 0..tg {
503            for i in 0..tg {
504                let inner = buffered.elevations[(j + b) * full + (i + b)] as f32;
505                let p = plain[j * tg + i];
506                assert!(
507                    (inner - p).abs() < 1e-3,
508                    "inner block mismatch at ({i},{j}): {inner} vs {p}"
509                );
510            }
511        }
512    }
513
514    #[test]
515    fn end_to_end_with_encode_terrain() {
516        use crate::terrain::{TerrainOptions, encode_terrain};
517        use quantized_mesh::DecodedMesh;
518
519        let zoom = 12;
520        let tile_size = 64;
521        // A gentle bump so martini produces more than the corner triangles.
522        let elev: Vec<f32> = (0..(tile_size * tile_size))
523            .map(|i| {
524                let x = (i % tile_size) as f32;
525                let y = (i / tile_size) as f32;
526                (x / 8.0).sin() * 20.0 + (y / 8.0).cos() * 15.0
527            })
528            .collect();
529        let dem = MercatorDem::new(zoom, 3626, 1617, 1, 1, tile_size, elev);
530
531        let (w, s, e, n) = web_mercator::tile_to_bounds(zoom, 3626, 1617);
532        let bounds = TileBounds::new(w, s, e, n);
533        let grid = dem.geodetic_grid(&bounds, 65);
534        let bytes = encode_terrain(
535            &grid,
536            65,
537            &bounds,
538            &TerrainOptions {
539                compression_level: 0,
540                ..Default::default()
541            },
542        );
543        let mesh = DecodedMesh::decode(&bytes).expect("decode");
544        assert!(mesh.vertices.len() >= 4);
545        assert!(mesh.indices.len() >= 6);
546    }
547}