Skip to main content

terrain_codec/heightmap/
mod.rs

1//! Heightmap codecs for elevation tile formats.
2//!
3//! Two flavours of heightmap encoding are supported:
4//!
5//! - **RGB tiles** (this module, top level): [`terrarium`], [`mapbox`],
6//!   [`gsi`]. Elevations packed into 3-byte `(R, G, B)` pixels, usually
7//!   served wrapped in a PNG or WebP container.
8//! - **Cesium heightmap-1.0** ([`cesium`]): 16-bit little-endian heights
9//!   plus child-tile mask. Cesium's legacy terrain format.
10//!
11//! The [`container`] submodule (behind the `image` cargo feature) wraps
12//! the encoded RGB bytes in PNG or WebP for serving as image tiles.
13//!
14//! Three formats are supported, each as a `(encode_pixel, decode_pixel,
15//! encode, decode)` quadruplet:
16//!
17//! - [`terrarium`] — Mapzen / Tilezen / Stadia "Terrarium" encoding.
18//!   `elevation = (R * 256 + G + B / 256) - 32768`
19//! - [`mapbox`] — Mapbox "Terrain-RGB" encoding.
20//!   `elevation = -10000 + (R * 65536 + G * 256 + B) * 0.1`
21//! - [`gsi`] — Geospatial Information Authority of Japan
22//!   ([地理院](https://maps.gsi.go.jp/development/demtile.html))
23//!   signed-integer DEM PNG. `(128, 0, 0)` is the no-data sentinel
24//!   (decodes to `NaN`).
25//!
26//! Per-pixel functions (`encode_pixel` / `decode_pixel`) convert single
27//! samples and are useful for streaming, hot loops, custom layouts, and
28//! tests. Bulk `encode` / `decode` are thin wrappers that walk row-major
29//! `width × height` buffers.
30//!
31//! ## Unified runtime-dispatch API
32//!
33//! When the format is only known at runtime (CLI flag, request param,
34//! config file), use the [`HeightmapFormat`] enum and the top-level
35//! [`encode_pixel`], [`decode_pixel`], [`encode`], [`decode`] functions:
36//!
37//! ```
38//! use terrain_codec::heightmap::{HeightmapFormat, encode_pixel};
39//! let fmt: HeightmapFormat = "terrarium".parse().unwrap();
40//! let rgb = encode_pixel(fmt, 123.45);
41//! ```
42//!
43//! The codecs operate on raw `(R, G, B)` byte triplets and produce flat
44//! row-major buffers, so they're agnostic to the container format. Wrap
45//! the encoded bytes in PNG/WebP yourself (e.g. via the `image` crate)
46//! depending on your service.
47
48use std::fmt;
49use std::str::FromStr;
50
51pub mod cesium;
52#[cfg(any(feature = "png", feature = "webp", feature = "avif"))]
53pub mod container;
54
55/// Identifies one of the supported RGB heightmap encodings, for
56/// runtime-dispatched encode/decode via the top-level functions
57/// ([`encode_pixel`], [`decode_pixel`], [`encode`], [`decode`]).
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
59pub enum HeightmapFormat {
60    /// Mapzen / Tilezen / Stadia "Terrarium" encoding.
61    Terrarium,
62    /// Mapbox "Terrain-RGB" encoding.
63    Mapbox,
64    /// GSI / 国土地理院 DEM tile encoding.
65    Gsi,
66}
67
68impl HeightmapFormat {
69    /// All supported formats, in declaration order. Useful for iterating
70    /// in tests or building a format-picker UI.
71    pub const ALL: [HeightmapFormat; 3] = [Self::Terrarium, Self::Mapbox, Self::Gsi];
72
73    /// Canonical lowercase name (`"terrarium"` / `"mapbox"` / `"gsi"`).
74    pub const fn name(self) -> &'static str {
75        match self {
76            Self::Terrarium => "terrarium",
77            Self::Mapbox => "mapbox",
78            Self::Gsi => "gsi",
79        }
80    }
81}
82
83impl fmt::Display for HeightmapFormat {
84    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85        f.write_str(self.name())
86    }
87}
88
89/// Error returned by `HeightmapFormat::from_str` for an unrecognised name.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct ParseHeightmapFormatError {
92    /// The input string that failed to parse.
93    pub input: String,
94}
95
96impl fmt::Display for ParseHeightmapFormatError {
97    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98        write!(
99            f,
100            "unknown heightmap format `{}` (expected one of: terrarium, mapbox, gsi)",
101            self.input
102        )
103    }
104}
105
106impl std::error::Error for ParseHeightmapFormatError {}
107
108impl FromStr for HeightmapFormat {
109    type Err = ParseHeightmapFormatError;
110
111    /// Parses case-insensitively. Accepts `"terrarium"`, `"mapbox"` (or
112    /// the alias `"mapbox-rgb"` / `"terrain-rgb"`), and `"gsi"` (or
113    /// `"gsi-dem"`).
114    fn from_str(s: &str) -> Result<Self, Self::Err> {
115        match s.to_ascii_lowercase().as_str() {
116            "terrarium" => Ok(Self::Terrarium),
117            "mapbox" | "mapbox-rgb" | "terrain-rgb" => Ok(Self::Mapbox),
118            "gsi" | "gsi-dem" => Ok(Self::Gsi),
119            _ => Err(ParseHeightmapFormatError {
120                input: s.to_string(),
121            }),
122        }
123    }
124}
125
126/// Encode a single elevation sample (metres) into `(R, G, B)` using the
127/// chosen format.
128#[inline]
129pub fn encode_pixel(format: HeightmapFormat, elevation: f32) -> [u8; 3] {
130    match format {
131        HeightmapFormat::Terrarium => terrarium::encode_pixel(elevation),
132        HeightmapFormat::Mapbox => mapbox::encode_pixel(elevation),
133        HeightmapFormat::Gsi => gsi::encode_pixel(elevation),
134    }
135}
136
137/// Decode a single `(R, G, B)` pixel into elevation (metres) using the
138/// chosen format.
139#[inline]
140pub fn decode_pixel(format: HeightmapFormat, rgb: [u8; 3]) -> f32 {
141    match format {
142        HeightmapFormat::Terrarium => terrarium::decode_pixel(rgb),
143        HeightmapFormat::Mapbox => mapbox::decode_pixel(rgb),
144        HeightmapFormat::Gsi => gsi::decode_pixel(rgb),
145    }
146}
147
148/// Encode `elevations` (metres) into a flat `width × height × 3` RGB
149/// buffer using the chosen format.
150///
151/// # Panics
152///
153/// Panics if `elevations.len() != (width * height) as usize`.
154pub fn encode(format: HeightmapFormat, elevations: &[f32], width: u32, height: u32) -> Vec<u8> {
155    match format {
156        HeightmapFormat::Terrarium => terrarium::encode(elevations, width, height),
157        HeightmapFormat::Mapbox => mapbox::encode(elevations, width, height),
158        HeightmapFormat::Gsi => gsi::encode(elevations, width, height),
159    }
160}
161
162/// Decode a flat `width × height × 3` RGB buffer back to elevations
163/// using the chosen format.
164///
165/// # Panics
166///
167/// Panics if `rgb.len() != (width * height * 3) as usize`.
168pub fn decode(format: HeightmapFormat, rgb: &[u8], width: u32, height: u32) -> Vec<f32> {
169    match format {
170        HeightmapFormat::Terrarium => terrarium::decode(rgb, width, height),
171        HeightmapFormat::Mapbox => mapbox::decode(rgb, width, height),
172        HeightmapFormat::Gsi => gsi::decode(rgb, width, height),
173    }
174}
175
176/// Mapzen / Tilezen / Stadia "Terrarium" elevation tile encoding.
177///
178/// Formula: `elevation = (R * 256 + G + B / 256) - 32768` (metres).
179///
180/// Reference: <https://github.com/tilezen/joerd/blob/master/docs/formats.md#terrarium>
181pub mod terrarium {
182    /// Encode a single elevation sample (metres) into Terrarium `(R, G, B)`.
183    ///
184    /// Values are clamped to the representable range
185    /// `[-32768, 8388.99…]` metres. `NaN` encodes as zero metres
186    /// (mid-range — Terrarium has no dedicated no-data sentinel).
187    #[inline]
188    pub fn encode_pixel(elevation: f32) -> [u8; 3] {
189        let v = if elevation.is_nan() {
190            0.0
191        } else {
192            (elevation + 32768.0) * 256.0
193        };
194        let v = v.clamp(0.0, (1u32 << 24) as f32 - 1.0) as u32;
195        [
196            ((v >> 16) & 0xff) as u8,
197            ((v >> 8) & 0xff) as u8,
198            (v & 0xff) as u8,
199        ]
200    }
201
202    /// Decode a single Terrarium `(R, G, B)` pixel into elevation (metres).
203    #[inline]
204    pub fn decode_pixel(rgb: [u8; 3]) -> f32 {
205        let r = rgb[0] as f32;
206        let g = rgb[1] as f32;
207        let b = rgb[2] as f32;
208        r * 256.0 + g + b / 256.0 - 32768.0
209    }
210
211    /// Encode `elevations` (metres) into a flat `width × height × 3` RGB buffer.
212    ///
213    /// Walks `elevations` in row-major order and delegates to
214    /// [`encode_pixel`] for each sample.
215    ///
216    /// # Panics
217    ///
218    /// Panics if `elevations.len() != (width * height) as usize`.
219    pub fn encode(elevations: &[f32], width: u32, height: u32) -> Vec<u8> {
220        let expected = (width as usize) * (height as usize);
221        assert_eq!(
222            elevations.len(),
223            expected,
224            "elevations length mismatch: expected {expected}, got {}",
225            elevations.len()
226        );
227        let mut out = Vec::with_capacity(expected * 3);
228        for &e in elevations {
229            out.extend_from_slice(&encode_pixel(e));
230        }
231        out
232    }
233
234    /// Decode a flat `width × height × 3` RGB buffer back to elevations.
235    ///
236    /// # Panics
237    ///
238    /// Panics if `rgb.len() != (width * height * 3) as usize`.
239    pub fn decode(rgb: &[u8], width: u32, height: u32) -> Vec<f32> {
240        let pixels = (width as usize) * (height as usize);
241        assert_eq!(
242            rgb.len(),
243            pixels * 3,
244            "rgb length mismatch: expected {}, got {}",
245            pixels * 3,
246            rgb.len()
247        );
248        rgb.chunks_exact(3)
249            .map(|c| decode_pixel([c[0], c[1], c[2]]))
250            .collect()
251    }
252}
253
254/// Mapbox "Terrain-RGB" elevation tile encoding.
255///
256/// Formula: `elevation = -10000 + (R * 65536 + G * 256 + B) * 0.1` (metres).
257///
258/// Reference: <https://docs.mapbox.com/data/tilesets/reference/mapbox-terrain-rgb-v1/>
259pub mod mapbox {
260    /// Encode a single elevation sample (metres) into Mapbox `(R, G, B)`.
261    ///
262    /// Values are clamped to the representable range
263    /// `[-10000, +1667721.5]` metres. `NaN` encodes as zero metres
264    /// (Mapbox Terrain-RGB has no dedicated no-data sentinel).
265    #[inline]
266    pub fn encode_pixel(elevation: f32) -> [u8; 3] {
267        let v = if elevation.is_nan() {
268            0.0
269        } else {
270            ((elevation + 10000.0) * 10.0).round()
271        };
272        let v = v.clamp(0.0, (1u32 << 24) as f32 - 1.0) as u32;
273        [
274            ((v >> 16) & 0xff) as u8,
275            ((v >> 8) & 0xff) as u8,
276            (v & 0xff) as u8,
277        ]
278    }
279
280    /// Decode a single Mapbox Terrain-RGB `(R, G, B)` pixel into elevation
281    /// (metres).
282    #[inline]
283    pub fn decode_pixel(rgb: [u8; 3]) -> f32 {
284        let r = rgb[0] as f32;
285        let g = rgb[1] as f32;
286        let b = rgb[2] as f32;
287        -10000.0 + (r * 65536.0 + g * 256.0 + b) * 0.1
288    }
289
290    /// Encode `elevations` (metres) into a flat `width × height × 3` RGB buffer.
291    ///
292    /// # Panics
293    ///
294    /// Panics if `elevations.len() != (width * height) as usize`.
295    pub fn encode(elevations: &[f32], width: u32, height: u32) -> Vec<u8> {
296        let expected = (width as usize) * (height as usize);
297        assert_eq!(
298            elevations.len(),
299            expected,
300            "elevations length mismatch: expected {expected}, got {}",
301            elevations.len()
302        );
303        let mut out = Vec::with_capacity(expected * 3);
304        for &e in elevations {
305            out.extend_from_slice(&encode_pixel(e));
306        }
307        out
308    }
309
310    /// Decode a flat `width × height × 3` RGB buffer back to elevations.
311    ///
312    /// # Panics
313    ///
314    /// Panics if `rgb.len() != (width * height * 3) as usize`.
315    pub fn decode(rgb: &[u8], width: u32, height: u32) -> Vec<f32> {
316        let pixels = (width as usize) * (height as usize);
317        assert_eq!(
318            rgb.len(),
319            pixels * 3,
320            "rgb length mismatch: expected {}, got {}",
321            pixels * 3,
322            rgb.len()
323        );
324        rgb.chunks_exact(3)
325            .map(|c| decode_pixel([c[0], c[1], c[2]]))
326            .collect()
327    }
328}
329
330/// Geospatial Information Authority of Japan (GSI / 国土地理院) DEM tile
331/// encoding.
332///
333/// Each pixel packs a signed 24-bit integer of 0.01 m units:
334///
335/// ```text
336/// x = (R << 16) | (G << 8) | B
337/// if x == 0x800000  → NaN (no-data sentinel = RGB(128, 0, 0))
338/// if x >= 0x800000  → elevation = (x - 0x1000000) * 0.01
339/// else              → elevation = x * 0.01
340/// ```
341///
342/// Reference: <https://maps.gsi.go.jp/development/demtile.html>
343pub mod gsi {
344    /// No-data sentinel `(R=128, G=0, B=0)`, decoded as `NaN`.
345    pub const SENTINEL_RGB: [u8; 3] = [0x80, 0x00, 0x00];
346    /// 2^23 — boundary between positive and negative values in the 24-bit
347    /// two's complement representation. Also the bit pattern of the
348    /// no-data sentinel.
349    const SIGN_BIT: u32 = 1 << 23;
350    /// 2^24 — modulus used to wrap into negative values.
351    const RANGE: i64 = 1 << 24;
352
353    /// Encode a single elevation sample (metres) into GSI `(R, G, B)`.
354    ///
355    /// `NaN` encodes as the no-data sentinel `(128, 0, 0)`. Finite values
356    /// are quantised to 0.01 m and stored as a 24-bit signed integer,
357    /// wrapping out-of-range values modulo 2²⁴ (matching the GSI spec).
358    #[inline]
359    pub fn encode_pixel(elevation: f32) -> [u8; 3] {
360        if elevation.is_nan() {
361            return SENTINEL_RGB;
362        }
363        let raw = (elevation as f64 * 100.0).round() as i64;
364        let x = raw.rem_euclid(RANGE) as u32;
365        [
366            ((x >> 16) & 0xff) as u8,
367            ((x >> 8) & 0xff) as u8,
368            (x & 0xff) as u8,
369        ]
370    }
371
372    /// Decode a single GSI `(R, G, B)` pixel into elevation (metres).
373    ///
374    /// The no-data sentinel `(128, 0, 0)` decodes to `NaN`.
375    #[inline]
376    pub fn decode_pixel(rgb: [u8; 3]) -> f32 {
377        let r = rgb[0] as u32;
378        let g = rgb[1] as u32;
379        let b = rgb[2] as u32;
380        let x = (r << 16) | (g << 8) | b;
381        if x == SIGN_BIT {
382            f32::NAN
383        } else if x >= SIGN_BIT {
384            (x as i64 - RANGE) as f32 * 0.01
385        } else {
386            x as f32 * 0.01
387        }
388    }
389
390    /// Encode `elevations` (metres) into a flat `width × height × 3` RGB buffer.
391    ///
392    /// # Panics
393    ///
394    /// Panics if `elevations.len() != (width * height) as usize`.
395    pub fn encode(elevations: &[f32], width: u32, height: u32) -> Vec<u8> {
396        let expected = (width as usize) * (height as usize);
397        assert_eq!(
398            elevations.len(),
399            expected,
400            "elevations length mismatch: expected {expected}, got {}",
401            elevations.len()
402        );
403        let mut out = Vec::with_capacity(expected * 3);
404        for &e in elevations {
405            out.extend_from_slice(&encode_pixel(e));
406        }
407        out
408    }
409
410    /// Decode a flat `width × height × 3` RGB buffer back to elevations.
411    ///
412    /// The no-data sentinel `(128, 0, 0)` decodes to `NaN`.
413    ///
414    /// # Panics
415    ///
416    /// Panics if `rgb.len() != (width * height * 3) as usize`.
417    pub fn decode(rgb: &[u8], width: u32, height: u32) -> Vec<f32> {
418        let pixels = (width as usize) * (height as usize);
419        assert_eq!(
420            rgb.len(),
421            pixels * 3,
422            "rgb length mismatch: expected {}, got {}",
423            pixels * 3,
424            rgb.len()
425        );
426        rgb.chunks_exact(3)
427            .map(|c| decode_pixel([c[0], c[1], c[2]]))
428            .collect()
429    }
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435
436    fn approx_eq(a: f32, b: f32, tol: f32) -> bool {
437        (a - b).abs() <= tol
438    }
439
440    #[test]
441    fn terrarium_pixel_roundtrip() {
442        for e in [-100.0_f32, 0.0, 100.0, 1234.5, 8000.0, -500.0] {
443            let back = terrarium::decode_pixel(terrarium::encode_pixel(e));
444            assert!(approx_eq(e, back, 0.01), "{e} → {back}");
445        }
446    }
447
448    #[test]
449    fn terrarium_zero_sea_level_is_8000() {
450        // (R, G, B) = (128, 0, 0) ↔ elevation 0 m (sea level)
451        assert_eq!(terrarium::encode_pixel(0.0), [0x80, 0x00, 0x00]);
452    }
453
454    #[test]
455    fn terrarium_bulk_matches_pixel() {
456        let elevations: Vec<f32> = vec![-100.0, 0.0, 100.0, 1234.5, 8000.0, -500.0];
457        let bulk = terrarium::encode(&elevations, 6, 1);
458        let from_pixels: Vec<u8> = elevations
459            .iter()
460            .flat_map(|&e| terrarium::encode_pixel(e))
461            .collect();
462        assert_eq!(bulk, from_pixels);
463    }
464
465    #[test]
466    fn mapbox_pixel_roundtrip() {
467        for e in [-100.0_f32, 0.0, 100.0, 1234.5, 8000.0, -500.0] {
468            let back = mapbox::decode_pixel(mapbox::encode_pixel(e));
469            assert!(approx_eq(e, back, 0.1), "{e} → {back}");
470        }
471    }
472
473    #[test]
474    fn mapbox_minimum_value_is_minus_10000() {
475        assert_eq!(mapbox::encode_pixel(-10000.0), [0, 0, 0]);
476        assert_eq!(mapbox::decode_pixel([0, 0, 0]), -10000.0);
477    }
478
479    #[test]
480    fn gsi_sentinel_decodes_to_nan() {
481        assert!(gsi::decode_pixel([0x80, 0x00, 0x00]).is_nan());
482    }
483
484    #[test]
485    fn gsi_nan_encodes_to_sentinel() {
486        assert_eq!(gsi::encode_pixel(f32::NAN), [0x80, 0x00, 0x00]);
487    }
488
489    #[test]
490    fn gsi_pixel_roundtrip_positive_and_negative() {
491        for e in [0.0_f32, 100.0, 3776.24, -10.5, -429.4] {
492            let back = gsi::decode_pixel(gsi::encode_pixel(e));
493            assert!(approx_eq(e, back, 0.01), "{e} → {back}");
494        }
495    }
496
497    #[test]
498    fn gsi_zero_is_all_zero_rgb() {
499        assert_eq!(gsi::encode_pixel(0.0), [0, 0, 0]);
500    }
501
502    #[test]
503    fn format_from_str_accepts_aliases() {
504        assert_eq!("terrarium".parse(), Ok(HeightmapFormat::Terrarium));
505        assert_eq!("TERRARIUM".parse(), Ok(HeightmapFormat::Terrarium));
506        assert_eq!("mapbox".parse(), Ok(HeightmapFormat::Mapbox));
507        assert_eq!("mapbox-rgb".parse(), Ok(HeightmapFormat::Mapbox));
508        assert_eq!("terrain-rgb".parse(), Ok(HeightmapFormat::Mapbox));
509        assert_eq!("gsi".parse(), Ok(HeightmapFormat::Gsi));
510        assert_eq!("gsi-dem".parse(), Ok(HeightmapFormat::Gsi));
511        assert!("bogus".parse::<HeightmapFormat>().is_err());
512    }
513
514    #[test]
515    fn format_display_roundtrips_through_from_str() {
516        for fmt in HeightmapFormat::ALL {
517            let parsed: HeightmapFormat = fmt.to_string().parse().unwrap();
518            assert_eq!(parsed, fmt);
519        }
520    }
521
522    #[test]
523    fn dispatch_matches_per_module_for_every_format() {
524        let elevations: Vec<f32> = vec![-100.0, 0.0, 100.0, 1234.5, -500.0];
525        for fmt in HeightmapFormat::ALL {
526            // Bulk via dispatch == bulk via the module directly.
527            let dispatched = encode(fmt, &elevations, elevations.len() as u32, 1);
528            let direct = match fmt {
529                HeightmapFormat::Terrarium => terrarium::encode(&elevations, 5, 1),
530                HeightmapFormat::Mapbox => mapbox::encode(&elevations, 5, 1),
531                HeightmapFormat::Gsi => gsi::encode(&elevations, 5, 1),
532            };
533            assert_eq!(dispatched, direct, "encode mismatch for {fmt}");
534
535            // Per-pixel dispatch agrees too.
536            for &e in &elevations {
537                let px = encode_pixel(fmt, e);
538                let back = decode_pixel(fmt, px);
539                // Round-trip tolerance varies per format; pick the loosest.
540                assert!((e - back).abs() <= 0.1, "[{fmt}] {e} → {px:?} → {back}");
541            }
542        }
543    }
544
545    #[test]
546    fn gsi_bulk_matches_pixel() {
547        let elevations: Vec<f32> = vec![0.0, 100.0, -10.5, f32::NAN, 3776.24];
548        let bulk = gsi::encode(&elevations, elevations.len() as u32, 1);
549        let from_pixels: Vec<u8> = elevations
550            .iter()
551            .flat_map(|&e| gsi::encode_pixel(e))
552            .collect();
553        assert_eq!(bulk, from_pixels);
554    }
555}