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}