Skip to main content

terrain_codec/heightmap/
cesium.rs

1//! Cesium **heightmap-1.0** terrain tile format.
2//!
3//! The legacy Cesium terrain format. Each tile is a regular grid of 16-bit
4//! heights (default 65 × 65, north → south, west → east) plus a 1-byte
5//! child-availability mask. Optional extensions follow: a water mask
6//! (1 byte uniform or 256 × 256 bytes), then oct-encoded per-vertex
7//! normals (2 bytes per vertex).
8//!
9//! Specification:
10//! <https://github.com/CesiumGS/cesium/wiki/heightmap-1.0>
11//!
12//! # Layout
13//!
14//! ```text
15//! +------------------------------------+
16//! | u16 LE heights (size×size × 2 B)   |
17//! +------------------------------------+
18//! | u8 child mask                      |
19//! +------------------------------------+
20//! | water mask (optional, 1 or 256² B) |
21//! +------------------------------------+
22//! | oct normals (optional, 2 × N B)    |
23//! +------------------------------------+
24//! ```
25//!
26//! Encoded files are typically **gzip-compressed**; this module returns
27//! raw uncompressed bytes — compress them yourself with `flate2` or
28//! similar.
29//!
30//! # Height encoding
31//!
32//! `value = (elevation + 1000) * 5`, i.e. range −1000 m … +12107 m at
33//! 0.2 m resolution. Use [`elevation_to_u16`] / [`u16_to_elevation`] for
34//! single-sample conversion.
35
36/// Default heightmap-1.0 tile side length (Cesium streams 65 × 65 tiles).
37pub const TILE_SIZE: u32 = 65;
38
39/// Minimum elevation representable in metres.
40pub const MIN_ELEVATION: f64 = -1000.0;
41
42/// Maximum elevation representable in metres.
43pub const MAX_ELEVATION: f64 = 12107.0;
44
45/// Scale factor: `value = (elevation - MIN_ELEVATION) * SCALE`.
46pub const SCALE: f64 = 5.0;
47
48/// Encode a single elevation sample (metres) into the heightmap-1.0 u16.
49///
50/// Values are clamped to `[MIN_ELEVATION, MAX_ELEVATION]`.
51#[inline]
52pub fn elevation_to_u16(elevation: f64) -> u16 {
53    let clamped = elevation.clamp(MIN_ELEVATION, MAX_ELEVATION);
54    ((clamped - MIN_ELEVATION) * SCALE).round() as u16
55}
56
57/// Decode a heightmap-1.0 u16 back to elevation (metres).
58#[inline]
59pub fn u16_to_elevation(value: u16) -> f64 {
60    value as f64 / SCALE + MIN_ELEVATION
61}
62
63/// Encode a row-major `width × height` elevation grid into little-endian
64/// u16 bytes (`width * height * 2` bytes).
65///
66/// Rows are expected north → south, columns west → east (Cesium's
67/// convention).
68///
69/// # Panics
70///
71/// Panics if `elevations.len() < (width * height) as usize`.
72pub fn encode_heights(elevations: &[f64], width: u32, height: u32) -> Vec<u8> {
73    let expected = (width as usize) * (height as usize);
74    assert!(
75        elevations.len() >= expected,
76        "expected at least {expected} elevations, got {}",
77        elevations.len()
78    );
79    let mut out = Vec::with_capacity(expected * 2);
80    for &e in &elevations[..expected] {
81        out.extend_from_slice(&elevation_to_u16(e).to_le_bytes());
82    }
83    out
84}
85
86/// Decode little-endian u16 bytes back to a flat elevation grid.
87///
88/// Truncates any trailing odd byte.
89pub fn decode_heights(data: &[u8]) -> Vec<f64> {
90    data.chunks_exact(2)
91        .map(|c| u16_to_elevation(u16::from_le_bytes([c[0], c[1]])))
92        .collect()
93}
94
95/// Child-availability mask: one bit per quadrant indicating whether a
96/// child tile exists at the next zoom level.
97///
98/// Bit layout (matches the heightmap-1.0 spec):
99///
100/// | Bit | Value | Quadrant  |
101/// |-----|-------|-----------|
102/// | 0   | 1     | Southwest |
103/// | 1   | 2     | Southeast |
104/// | 2   | 4     | Northwest |
105/// | 3   | 8     | Northeast |
106#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
107pub struct ChildTileMask {
108    /// Southwest child present.
109    pub southwest: bool,
110    /// Southeast child present.
111    pub southeast: bool,
112    /// Northwest child present.
113    pub northwest: bool,
114    /// Northeast child present.
115    pub northeast: bool,
116}
117
118impl ChildTileMask {
119    /// Mask with every quadrant present.
120    pub const fn all() -> Self {
121        Self {
122            southwest: true,
123            southeast: true,
124            northwest: true,
125            northeast: true,
126        }
127    }
128
129    /// Mask with no quadrants present.
130    pub const fn none() -> Self {
131        Self {
132            southwest: false,
133            southeast: false,
134            northwest: false,
135            northeast: false,
136        }
137    }
138
139    /// Pack into a single byte.
140    pub const fn to_byte(self) -> u8 {
141        let mut mask = 0u8;
142        if self.southwest {
143            mask |= 1;
144        }
145        if self.southeast {
146            mask |= 2;
147        }
148        if self.northwest {
149            mask |= 4;
150        }
151        if self.northeast {
152            mask |= 8;
153        }
154        mask
155    }
156
157    /// Unpack from a byte.
158    pub const fn from_byte(byte: u8) -> Self {
159        Self {
160            southwest: byte & 1 != 0,
161            southeast: byte & 2 != 0,
162            northwest: byte & 4 != 0,
163            northeast: byte & 8 != 0,
164        }
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    #[test]
173    fn elevation_roundtrip_within_range() {
174        for &e in &[-1000.0_f64, 0.0, 100.0, 8848.0, 12107.0] {
175            let back = u16_to_elevation(elevation_to_u16(e));
176            assert!((e - back).abs() < 0.2, "{e} → {back}");
177        }
178    }
179
180    #[test]
181    fn elevation_clamps_out_of_range() {
182        let low = u16_to_elevation(elevation_to_u16(-2000.0));
183        assert!((low - MIN_ELEVATION).abs() < 0.2);
184        let high = u16_to_elevation(elevation_to_u16(20000.0));
185        assert!((high - MAX_ELEVATION).abs() < 0.2);
186    }
187
188    #[test]
189    fn encode_decode_bulk() {
190        let elevations: Vec<f64> = vec![0.0, 100.0, -100.0, 1000.0];
191        let bytes = encode_heights(&elevations, 2, 2);
192        assert_eq!(bytes.len(), 8); // 4 × u16
193        let back = decode_heights(&bytes);
194        for (a, b) in elevations.iter().zip(&back) {
195            assert!((a - b).abs() < 0.2);
196        }
197    }
198
199    #[test]
200    fn child_mask_bits_match_spec() {
201        assert_eq!(ChildTileMask::all().to_byte(), 0b1111);
202        assert_eq!(ChildTileMask::none().to_byte(), 0);
203        let only_se = ChildTileMask {
204            southeast: true,
205            ..Default::default()
206        };
207        assert_eq!(only_se.to_byte(), 0b0010);
208        assert_eq!(
209            ChildTileMask::from_byte(0b1010),
210            ChildTileMask {
211                southwest: false,
212                southeast: true,
213                northwest: false,
214                northeast: true,
215            }
216        );
217    }
218}