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 `png` / `webp` / `avif` cargo
12//! features) wraps the encoded RGB bytes in PNG, WebP, or AVIF for serving
13//! as image tiles.
14//!
15//! ## API shape: allocation-free by default
16//!
17//! Every codec ships in three flavours so callers can pick the right
18//! allocation profile:
19//!
20//! | Function           | Output                | Allocation |
21//! |--------------------|-----------------------|------------|
22//! | `encode_pixel`     | `[u8; 3]`             | none       |
23//! | `encode_into`      | caller-owned `&mut [u8]` | none    |
24//! | `encode_to<W>`     | `impl Write`          | none (4 KiB stack buffer) |
25//! | `encode`           | `Vec<u8>`             | one Vec    |
26//!
27//! Decoding mirrors this: `decode_pixel`, `decode_into`, [`HeightmapView`]
28//! (zero-copy borrowed iterator), and `decode` (`Vec<f32>`).
29//!
30//! ## Unified runtime-dispatch API
31//!
32//! When the format is only known at runtime, use the [`HeightmapFormat`]
33//! enum and the top-level [`encode_pixel`], [`decode_pixel`],
34//! [`encode_into`], [`decode_into`], [`encode_to`], [`encode`], [`decode`]
35//! 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
43use std::fmt;
44use std::io::{self, Write};
45use std::str::FromStr;
46
47pub mod cesium;
48#[cfg(any(feature = "png", feature = "webp", feature = "avif"))]
49pub mod container;
50
51/// Identifies one of the supported RGB heightmap encodings, for
52/// runtime-dispatched encode/decode via the top-level functions.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
54pub enum HeightmapFormat {
55    /// Mapzen / Tilezen / Stadia "Terrarium" encoding.
56    Terrarium,
57    /// Mapbox "Terrain-RGB" encoding.
58    Mapbox,
59    /// GSI / 国土地理院 DEM tile encoding.
60    Gsi,
61}
62
63impl HeightmapFormat {
64    /// All supported formats, in declaration order.
65    pub const ALL: [HeightmapFormat; 3] = [Self::Terrarium, Self::Mapbox, Self::Gsi];
66
67    /// Canonical lowercase name.
68    pub const fn name(self) -> &'static str {
69        match self {
70            Self::Terrarium => "terrarium",
71            Self::Mapbox => "mapbox",
72            Self::Gsi => "gsi",
73        }
74    }
75}
76
77impl fmt::Display for HeightmapFormat {
78    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79        f.write_str(self.name())
80    }
81}
82
83/// Error returned by `HeightmapFormat::from_str` for an unrecognised name.
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct ParseHeightmapFormatError {
86    /// The input string that failed to parse.
87    pub input: String,
88}
89
90impl fmt::Display for ParseHeightmapFormatError {
91    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
92        write!(
93            f,
94            "unknown heightmap format `{}` (expected one of: terrarium, mapbox, gsi)",
95            self.input
96        )
97    }
98}
99
100impl std::error::Error for ParseHeightmapFormatError {}
101
102impl FromStr for HeightmapFormat {
103    type Err = ParseHeightmapFormatError;
104
105    fn from_str(s: &str) -> Result<Self, Self::Err> {
106        match s.to_ascii_lowercase().as_str() {
107            "terrarium" => Ok(Self::Terrarium),
108            "mapbox" | "mapbox-rgb" | "terrain-rgb" => Ok(Self::Mapbox),
109            "gsi" | "gsi-dem" => Ok(Self::Gsi),
110            _ => Err(ParseHeightmapFormatError {
111                input: s.to_string(),
112            }),
113        }
114    }
115}
116
117/// Encode a single elevation sample (metres) into `(R, G, B)` using the
118/// chosen format.
119#[inline]
120pub fn encode_pixel(format: HeightmapFormat, elevation: f32) -> [u8; 3] {
121    match format {
122        HeightmapFormat::Terrarium => terrarium::encode_pixel(elevation),
123        HeightmapFormat::Mapbox => mapbox::encode_pixel(elevation),
124        HeightmapFormat::Gsi => gsi::encode_pixel(elevation),
125    }
126}
127
128/// Decode a single `(R, G, B)` pixel into elevation (metres) using the
129/// chosen format.
130#[inline]
131pub fn decode_pixel(format: HeightmapFormat, rgb: [u8; 3]) -> f32 {
132    match format {
133        HeightmapFormat::Terrarium => terrarium::decode_pixel(rgb),
134        HeightmapFormat::Mapbox => mapbox::decode_pixel(rgb),
135        HeightmapFormat::Gsi => gsi::decode_pixel(rgb),
136    }
137}
138
139/// Encode `elevations` into a caller-owned RGB buffer.
140///
141/// `out.len()` must equal `elevations.len() * 3`. No allocation occurs.
142pub fn encode_into(format: HeightmapFormat, elevations: &[f32], out: &mut [u8]) {
143    encode_into_with(elevations, out, |e| encode_pixel(format, e))
144}
145
146/// Decode RGB bytes into a caller-owned `f32` buffer.
147///
148/// `rgb.len()` must equal `out.len() * 3`. No allocation occurs.
149pub fn decode_into(format: HeightmapFormat, rgb: &[u8], out: &mut [f32]) {
150    decode_into_with(rgb, out, |px| decode_pixel(format, px))
151}
152
153/// Encode `elevations` into RGB bytes streamed to a writer.
154///
155/// Uses a 4 KiB stack-allocated chunk to amortise per-call write overhead.
156pub fn encode_to<W: Write>(
157    format: HeightmapFormat,
158    elevations: &[f32],
159    writer: W,
160) -> io::Result<()> {
161    encode_to_with(elevations, writer, |e| encode_pixel(format, e))
162}
163
164/// Encode `elevations` into a freshly allocated RGB `Vec`.
165///
166/// # Panics
167///
168/// Panics if `elevations.len() != (width * height) as usize`.
169pub fn encode(format: HeightmapFormat, elevations: &[f32], width: u32, height: u32) -> Vec<u8> {
170    let expected = (width as usize) * (height as usize);
171    assert_eq!(
172        elevations.len(),
173        expected,
174        "elevations length mismatch: expected {expected}, got {}",
175        elevations.len()
176    );
177    let mut out = vec![0u8; expected * 3];
178    encode_into(format, elevations, &mut out);
179    out
180}
181
182/// Decode a flat `width × height × 3` RGB buffer back to elevations.
183///
184/// # Panics
185///
186/// Panics if `rgb.len() != (width * height * 3) as usize`.
187pub fn decode(format: HeightmapFormat, rgb: &[u8], width: u32, height: u32) -> Vec<f32> {
188    let pixels = (width as usize) * (height as usize);
189    assert_eq!(
190        rgb.len(),
191        pixels * 3,
192        "rgb length mismatch: expected {}, got {}",
193        pixels * 3,
194        rgb.len()
195    );
196    let mut out = vec![0f32; pixels];
197    decode_into(format, rgb, &mut out);
198    out
199}
200
201// ---------------------------------------------------------------------------
202// Zero-copy decode view
203// ---------------------------------------------------------------------------
204
205/// Zero-copy borrowed view over an encoded RGB heightmap.
206///
207/// Holds a `&[u8]` referencing the source bytes and lazily yields decoded
208/// elevations via [`Self::iter`] / [`Self::get`] — no `Vec<f32>` is
209/// materialised unless the caller explicitly asks via [`Self::to_vec`] or
210/// [`Self::decode_into`].
211#[derive(Debug, Clone, Copy)]
212pub struct HeightmapView<'a> {
213    /// Format used to interpret the bytes.
214    pub format: HeightmapFormat,
215    /// Raw RGB bytes (`width * height * 3` bytes, row-major).
216    pub rgb: &'a [u8],
217    /// Image width in pixels.
218    pub width: u32,
219    /// Image height in pixels.
220    pub height: u32,
221}
222
223impl<'a> HeightmapView<'a> {
224    /// Wrap an RGB buffer. Asserts `rgb.len() == width * height * 3`.
225    pub fn new(format: HeightmapFormat, rgb: &'a [u8], width: u32, height: u32) -> Self {
226        let pixels = (width as usize) * (height as usize);
227        assert_eq!(
228            rgb.len(),
229            pixels * 3,
230            "rgb length mismatch: expected {}, got {}",
231            pixels * 3,
232            rgb.len()
233        );
234        Self {
235            format,
236            rgb,
237            width,
238            height,
239        }
240    }
241
242    /// Number of pixels.
243    #[inline]
244    pub fn len(&self) -> usize {
245        (self.width as usize) * (self.height as usize)
246    }
247
248    /// `true` if the view has zero pixels.
249    #[inline]
250    pub fn is_empty(&self) -> bool {
251        self.rgb.is_empty()
252    }
253
254    /// Sample the elevation at `(x, y)` in pixels.
255    #[inline]
256    pub fn get(&self, x: u32, y: u32) -> f32 {
257        let i = (y as usize) * (self.width as usize) + (x as usize);
258        let o = i * 3;
259        decode_pixel(self.format, [self.rgb[o], self.rgb[o + 1], self.rgb[o + 2]])
260    }
261
262    /// Iterator over decoded elevations in row-major order.
263    pub fn iter(&self) -> impl Iterator<Item = f32> + '_ {
264        let fmt = self.format;
265        self.rgb
266            .chunks_exact(3)
267            .map(move |c| decode_pixel(fmt, [c[0], c[1], c[2]]))
268    }
269
270    /// Decode the entire view into a caller-owned buffer.
271    pub fn decode_into(&self, out: &mut [f32]) {
272        decode_into(self.format, self.rgb, out);
273    }
274
275    /// Decode into a freshly allocated `Vec<f32>`.
276    pub fn to_vec(&self) -> Vec<f32> {
277        let mut out = vec![0f32; self.len()];
278        self.decode_into(&mut out);
279        out
280    }
281}
282
283// ---------------------------------------------------------------------------
284// Generic helpers shared by per-format modules
285// ---------------------------------------------------------------------------
286
287#[inline]
288fn encode_into_with(elevations: &[f32], out: &mut [u8], encode_pixel: impl Fn(f32) -> [u8; 3]) {
289    assert_eq!(
290        out.len(),
291        elevations.len() * 3,
292        "rgb buffer length mismatch: expected {}, got {}",
293        elevations.len() * 3,
294        out.len()
295    );
296    for (&e, chunk) in elevations.iter().zip(out.chunks_exact_mut(3)) {
297        chunk.copy_from_slice(&encode_pixel(e));
298    }
299}
300
301#[inline]
302fn decode_into_with(rgb: &[u8], out: &mut [f32], decode_pixel: impl Fn([u8; 3]) -> f32) {
303    assert_eq!(
304        rgb.len(),
305        out.len() * 3,
306        "rgb buffer length mismatch: expected {}, got {}",
307        out.len() * 3,
308        rgb.len()
309    );
310    for (chunk, dst) in rgb.chunks_exact(3).zip(out.iter_mut()) {
311        *dst = decode_pixel([chunk[0], chunk[1], chunk[2]]);
312    }
313}
314
315#[inline]
316fn encode_to_with<W: Write>(
317    elevations: &[f32],
318    mut writer: W,
319    encode_pixel: impl Fn(f32) -> [u8; 3],
320) -> io::Result<()> {
321    let mut buf = [0u8; 4095]; // multiple of 3
322    let mut len = 0;
323    for &e in elevations {
324        let px = encode_pixel(e);
325        buf[len] = px[0];
326        buf[len + 1] = px[1];
327        buf[len + 2] = px[2];
328        len += 3;
329        if len + 3 > buf.len() {
330            writer.write_all(&buf[..len])?;
331            len = 0;
332        }
333    }
334    if len > 0 {
335        writer.write_all(&buf[..len])?;
336    }
337    Ok(())
338}
339
340// ---------------------------------------------------------------------------
341// Per-format modules
342// ---------------------------------------------------------------------------
343
344/// Mapzen / Tilezen / Stadia "Terrarium" elevation tile encoding.
345///
346/// Formula: `elevation = (R * 256 + G + B / 256) - 32768` (metres).
347///
348/// Reference: <https://github.com/tilezen/joerd/blob/master/docs/formats.md#terrarium>
349pub mod terrarium {
350    use std::io::{self, Write};
351
352    /// Encode a single elevation sample (metres) into Terrarium `(R, G, B)`.
353    ///
354    /// Values are clamped to the representable range
355    /// `[-32768, 8388.99…]` metres. `NaN` encodes as zero metres.
356    #[inline]
357    pub fn encode_pixel(elevation: f32) -> [u8; 3] {
358        let v = if elevation.is_nan() {
359            0.0
360        } else {
361            (elevation + 32768.0) * 256.0
362        };
363        let v = v.clamp(0.0, (1u32 << 24) as f32 - 1.0) as u32;
364        [
365            ((v >> 16) & 0xff) as u8,
366            ((v >> 8) & 0xff) as u8,
367            (v & 0xff) as u8,
368        ]
369    }
370
371    /// Decode a single Terrarium `(R, G, B)` pixel into elevation (metres).
372    #[inline]
373    pub fn decode_pixel(rgb: [u8; 3]) -> f32 {
374        let r = rgb[0] as f32;
375        let g = rgb[1] as f32;
376        let b = rgb[2] as f32;
377        r * 256.0 + g + b / 256.0 - 32768.0
378    }
379
380    /// Encode `elevations` into a caller-owned RGB buffer (no allocation).
381    pub fn encode_into(elevations: &[f32], out: &mut [u8]) {
382        super::encode_into_with(elevations, out, encode_pixel);
383    }
384
385    /// Decode RGB bytes into a caller-owned `f32` buffer (no allocation).
386    pub fn decode_into(rgb: &[u8], out: &mut [f32]) {
387        super::decode_into_with(rgb, out, decode_pixel);
388    }
389
390    /// Stream-encode `elevations` to a writer.
391    pub fn encode_to<W: Write>(elevations: &[f32], writer: W) -> io::Result<()> {
392        super::encode_to_with(elevations, writer, encode_pixel)
393    }
394
395    /// Encode `elevations` into a freshly allocated `Vec<u8>`.
396    ///
397    /// # Panics
398    ///
399    /// Panics if `elevations.len() != (width * height) as usize`.
400    pub fn encode(elevations: &[f32], width: u32, height: u32) -> Vec<u8> {
401        super::encode(super::HeightmapFormat::Terrarium, elevations, width, height)
402    }
403
404    /// Decode RGB bytes into a freshly allocated `Vec<f32>`.
405    ///
406    /// # Panics
407    ///
408    /// Panics if `rgb.len() != (width * height * 3) as usize`.
409    pub fn decode(rgb: &[u8], width: u32, height: u32) -> Vec<f32> {
410        super::decode(super::HeightmapFormat::Terrarium, rgb, width, height)
411    }
412}
413
414/// Mapbox "Terrain-RGB" elevation tile encoding.
415///
416/// Formula: `elevation = -10000 + (R * 65536 + G * 256 + B) * 0.1` (metres).
417///
418/// Reference: <https://docs.mapbox.com/data/tilesets/reference/mapbox-terrain-rgb-v1/>
419pub mod mapbox {
420    use std::io::{self, Write};
421
422    /// Encode a single elevation sample (metres) into Mapbox `(R, G, B)`.
423    #[inline]
424    pub fn encode_pixel(elevation: f32) -> [u8; 3] {
425        let v = if elevation.is_nan() {
426            0.0
427        } else {
428            ((elevation + 10000.0) * 10.0).round()
429        };
430        let v = v.clamp(0.0, (1u32 << 24) as f32 - 1.0) as u32;
431        [
432            ((v >> 16) & 0xff) as u8,
433            ((v >> 8) & 0xff) as u8,
434            (v & 0xff) as u8,
435        ]
436    }
437
438    /// Decode a single Mapbox Terrain-RGB `(R, G, B)` pixel into elevation (metres).
439    #[inline]
440    pub fn decode_pixel(rgb: [u8; 3]) -> f32 {
441        let r = rgb[0] as f32;
442        let g = rgb[1] as f32;
443        let b = rgb[2] as f32;
444        -10000.0 + (r * 65536.0 + g * 256.0 + b) * 0.1
445    }
446
447    /// Encode `elevations` into a caller-owned RGB buffer (no allocation).
448    pub fn encode_into(elevations: &[f32], out: &mut [u8]) {
449        super::encode_into_with(elevations, out, encode_pixel);
450    }
451
452    /// Decode RGB bytes into a caller-owned `f32` buffer (no allocation).
453    pub fn decode_into(rgb: &[u8], out: &mut [f32]) {
454        super::decode_into_with(rgb, out, decode_pixel);
455    }
456
457    /// Stream-encode `elevations` to a writer.
458    pub fn encode_to<W: Write>(elevations: &[f32], writer: W) -> io::Result<()> {
459        super::encode_to_with(elevations, writer, encode_pixel)
460    }
461
462    /// Encode `elevations` into a freshly allocated `Vec<u8>`.
463    pub fn encode(elevations: &[f32], width: u32, height: u32) -> Vec<u8> {
464        super::encode(super::HeightmapFormat::Mapbox, elevations, width, height)
465    }
466
467    /// Decode RGB bytes into a freshly allocated `Vec<f32>`.
468    pub fn decode(rgb: &[u8], width: u32, height: u32) -> Vec<f32> {
469        super::decode(super::HeightmapFormat::Mapbox, rgb, width, height)
470    }
471}
472
473/// Geospatial Information Authority of Japan (GSI / 国土地理院) DEM tile
474/// encoding.
475///
476/// Each pixel packs a signed 24-bit integer of 0.01 m units.
477/// The no-data sentinel `(128, 0, 0)` decodes to `NaN`.
478///
479/// Reference: <https://maps.gsi.go.jp/development/demtile.html>
480pub mod gsi {
481    use std::io::{self, Write};
482
483    /// No-data sentinel `(R=128, G=0, B=0)`, decoded as `NaN`.
484    pub const SENTINEL_RGB: [u8; 3] = [0x80, 0x00, 0x00];
485    const SIGN_BIT: u32 = 1 << 23;
486    const RANGE: i64 = 1 << 24;
487
488    /// Encode a single elevation sample (metres) into GSI `(R, G, B)`.
489    ///
490    /// `NaN` encodes as the no-data sentinel `(128, 0, 0)`.
491    #[inline]
492    pub fn encode_pixel(elevation: f32) -> [u8; 3] {
493        if elevation.is_nan() {
494            return SENTINEL_RGB;
495        }
496        let raw = (elevation as f64 * 100.0).round() as i64;
497        let x = raw.rem_euclid(RANGE) as u32;
498        [
499            ((x >> 16) & 0xff) as u8,
500            ((x >> 8) & 0xff) as u8,
501            (x & 0xff) as u8,
502        ]
503    }
504
505    /// Decode a single GSI `(R, G, B)` pixel into elevation (metres).
506    #[inline]
507    pub fn decode_pixel(rgb: [u8; 3]) -> f32 {
508        let r = rgb[0] as u32;
509        let g = rgb[1] as u32;
510        let b = rgb[2] as u32;
511        let x = (r << 16) | (g << 8) | b;
512        if x == SIGN_BIT {
513            f32::NAN
514        } else if x >= SIGN_BIT {
515            (x as i64 - RANGE) as f32 * 0.01
516        } else {
517            x as f32 * 0.01
518        }
519    }
520
521    /// Encode `elevations` into a caller-owned RGB buffer (no allocation).
522    pub fn encode_into(elevations: &[f32], out: &mut [u8]) {
523        super::encode_into_with(elevations, out, encode_pixel);
524    }
525
526    /// Decode RGB bytes into a caller-owned `f32` buffer (no allocation).
527    pub fn decode_into(rgb: &[u8], out: &mut [f32]) {
528        super::decode_into_with(rgb, out, decode_pixel);
529    }
530
531    /// Stream-encode `elevations` to a writer.
532    pub fn encode_to<W: Write>(elevations: &[f32], writer: W) -> io::Result<()> {
533        super::encode_to_with(elevations, writer, encode_pixel)
534    }
535
536    /// Encode `elevations` into a freshly allocated `Vec<u8>`.
537    pub fn encode(elevations: &[f32], width: u32, height: u32) -> Vec<u8> {
538        super::encode(super::HeightmapFormat::Gsi, elevations, width, height)
539    }
540
541    /// Decode RGB bytes into a freshly allocated `Vec<f32>`.
542    pub fn decode(rgb: &[u8], width: u32, height: u32) -> Vec<f32> {
543        super::decode(super::HeightmapFormat::Gsi, rgb, width, height)
544    }
545}
546
547#[cfg(test)]
548mod tests {
549    use super::*;
550
551    fn approx_eq(a: f32, b: f32, tol: f32) -> bool {
552        (a - b).abs() <= tol
553    }
554
555    #[test]
556    fn terrarium_pixel_roundtrip() {
557        for e in [-100.0_f32, 0.0, 100.0, 1234.5, 8000.0, -500.0] {
558            let back = terrarium::decode_pixel(terrarium::encode_pixel(e));
559            assert!(approx_eq(e, back, 0.01), "{e} → {back}");
560        }
561    }
562
563    #[test]
564    fn terrarium_zero_sea_level_is_8000() {
565        assert_eq!(terrarium::encode_pixel(0.0), [0x80, 0x00, 0x00]);
566    }
567
568    #[test]
569    fn terrarium_bulk_matches_pixel() {
570        let elevations: Vec<f32> = vec![-100.0, 0.0, 100.0, 1234.5, 8000.0, -500.0];
571        let bulk = terrarium::encode(&elevations, 6, 1);
572        let from_pixels: Vec<u8> = elevations
573            .iter()
574            .flat_map(|&e| terrarium::encode_pixel(e))
575            .collect();
576        assert_eq!(bulk, from_pixels);
577    }
578
579    #[test]
580    fn mapbox_pixel_roundtrip() {
581        for e in [-100.0_f32, 0.0, 100.0, 1234.5, 8000.0, -500.0] {
582            let back = mapbox::decode_pixel(mapbox::encode_pixel(e));
583            assert!(approx_eq(e, back, 0.1), "{e} → {back}");
584        }
585    }
586
587    #[test]
588    fn mapbox_minimum_value_is_minus_10000() {
589        assert_eq!(mapbox::encode_pixel(-10000.0), [0, 0, 0]);
590        assert_eq!(mapbox::decode_pixel([0, 0, 0]), -10000.0);
591    }
592
593    #[test]
594    fn gsi_sentinel_decodes_to_nan() {
595        assert!(gsi::decode_pixel([0x80, 0x00, 0x00]).is_nan());
596    }
597
598    #[test]
599    fn gsi_nan_encodes_to_sentinel() {
600        assert_eq!(gsi::encode_pixel(f32::NAN), [0x80, 0x00, 0x00]);
601    }
602
603    #[test]
604    fn gsi_pixel_roundtrip_positive_and_negative() {
605        for e in [0.0_f32, 100.0, 3776.24, -10.5, -429.4] {
606            let back = gsi::decode_pixel(gsi::encode_pixel(e));
607            assert!(approx_eq(e, back, 0.01), "{e} → {back}");
608        }
609    }
610
611    #[test]
612    fn gsi_zero_is_all_zero_rgb() {
613        assert_eq!(gsi::encode_pixel(0.0), [0, 0, 0]);
614    }
615
616    #[test]
617    fn format_from_str_accepts_aliases() {
618        assert_eq!("terrarium".parse(), Ok(HeightmapFormat::Terrarium));
619        assert_eq!("TERRARIUM".parse(), Ok(HeightmapFormat::Terrarium));
620        assert_eq!("mapbox".parse(), Ok(HeightmapFormat::Mapbox));
621        assert_eq!("mapbox-rgb".parse(), Ok(HeightmapFormat::Mapbox));
622        assert_eq!("terrain-rgb".parse(), Ok(HeightmapFormat::Mapbox));
623        assert_eq!("gsi".parse(), Ok(HeightmapFormat::Gsi));
624        assert_eq!("gsi-dem".parse(), Ok(HeightmapFormat::Gsi));
625        assert!("bogus".parse::<HeightmapFormat>().is_err());
626    }
627
628    #[test]
629    fn format_display_roundtrips_through_from_str() {
630        for fmt in HeightmapFormat::ALL {
631            let parsed: HeightmapFormat = fmt.to_string().parse().unwrap();
632            assert_eq!(parsed, fmt);
633        }
634    }
635
636    #[test]
637    fn dispatch_matches_per_module_for_every_format() {
638        let elevations: Vec<f32> = vec![-100.0, 0.0, 100.0, 1234.5, -500.0];
639        for fmt in HeightmapFormat::ALL {
640            let dispatched = encode(fmt, &elevations, elevations.len() as u32, 1);
641            let direct = match fmt {
642                HeightmapFormat::Terrarium => terrarium::encode(&elevations, 5, 1),
643                HeightmapFormat::Mapbox => mapbox::encode(&elevations, 5, 1),
644                HeightmapFormat::Gsi => gsi::encode(&elevations, 5, 1),
645            };
646            assert_eq!(dispatched, direct, "encode mismatch for {fmt}");
647
648            for &e in &elevations {
649                let px = encode_pixel(fmt, e);
650                let back = decode_pixel(fmt, px);
651                assert!((e - back).abs() <= 0.1, "[{fmt}] {e} → {px:?} → {back}");
652            }
653        }
654    }
655
656    #[test]
657    fn gsi_bulk_matches_pixel() {
658        let elevations: Vec<f32> = vec![0.0, 100.0, -10.5, f32::NAN, 3776.24];
659        let bulk = gsi::encode(&elevations, elevations.len() as u32, 1);
660        let from_pixels: Vec<u8> = elevations
661            .iter()
662            .flat_map(|&e| gsi::encode_pixel(e))
663            .collect();
664        assert_eq!(bulk, from_pixels);
665    }
666
667    #[test]
668    fn encode_into_matches_encode_vec() {
669        let elevations: Vec<f32> = (0..16).map(|i| i as f32 * 10.0).collect();
670        for fmt in HeightmapFormat::ALL {
671            let expected = encode(fmt, &elevations, 16, 1);
672            let mut buf = vec![0u8; elevations.len() * 3];
673            encode_into(fmt, &elevations, &mut buf);
674            assert_eq!(expected, buf, "encode_into mismatch for {fmt}");
675        }
676    }
677
678    #[test]
679    fn encode_to_writer_matches_encode_vec() {
680        let elevations: Vec<f32> = (0..2000).map(|i| i as f32 * 0.5).collect();
681        for fmt in HeightmapFormat::ALL {
682            let expected = encode(fmt, &elevations, elevations.len() as u32, 1);
683            let mut buf = Vec::new();
684            encode_to(fmt, &elevations, &mut buf).unwrap();
685            assert_eq!(expected, buf, "encode_to mismatch for {fmt}");
686        }
687    }
688
689    #[test]
690    fn heightmap_view_iter_matches_decode() {
691        let elevations: Vec<f32> = vec![0.0, 100.0, 200.0, -50.0];
692        for fmt in HeightmapFormat::ALL {
693            let rgb = encode(fmt, &elevations, 4, 1);
694            let view = HeightmapView::new(fmt, &rgb, 4, 1);
695            let decoded: Vec<f32> = view.iter().collect();
696            let direct = decode(fmt, &rgb, 4, 1);
697            assert_eq!(decoded, direct);
698            assert_eq!(view.get(0, 0), direct[0]);
699            assert_eq!(view.get(3, 0), direct[3]);
700            assert_eq!(view.to_vec(), direct);
701        }
702    }
703}