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}