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 a caller-owned
64/// little-endian u16 byte buffer. `out.len()` must equal `width * height * 2`.
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` or if `out`
72/// is the wrong size.
73pub fn encode_heights_into(elevations: &[f64], width: u32, height: u32, out: &mut [u8]) {
74    let expected = (width as usize) * (height as usize);
75    assert!(
76        elevations.len() >= expected,
77        "expected at least {expected} elevations, got {}",
78        elevations.len()
79    );
80    assert_eq!(
81        out.len(),
82        expected * 2,
83        "out buffer length mismatch: expected {}, got {}",
84        expected * 2,
85        out.len()
86    );
87    for (&e, chunk) in elevations[..expected].iter().zip(out.chunks_exact_mut(2)) {
88        chunk.copy_from_slice(&elevation_to_u16(e).to_le_bytes());
89    }
90}
91
92/// Stream-encode a `width × height` elevation grid as little-endian u16
93/// bytes to a writer. Uses a 4 KiB stack buffer.
94pub fn encode_heights_to<W: std::io::Write>(
95    elevations: &[f64],
96    width: u32,
97    height: u32,
98    mut writer: W,
99) -> std::io::Result<()> {
100    let expected = (width as usize) * (height as usize);
101    assert!(
102        elevations.len() >= expected,
103        "expected at least {expected} elevations, got {}",
104        elevations.len()
105    );
106    let mut buf = [0u8; 4096];
107    let mut len = 0;
108    for &e in &elevations[..expected] {
109        let bytes = elevation_to_u16(e).to_le_bytes();
110        buf[len] = bytes[0];
111        buf[len + 1] = bytes[1];
112        len += 2;
113        if len + 2 > buf.len() {
114            writer.write_all(&buf[..len])?;
115            len = 0;
116        }
117    }
118    if len > 0 {
119        writer.write_all(&buf[..len])?;
120    }
121    Ok(())
122}
123
124/// Encode a row-major elevation grid into a freshly allocated `Vec<u8>`.
125///
126/// # Panics
127///
128/// Panics if `elevations.len() < (width * height) as usize`.
129pub fn encode_heights(elevations: &[f64], width: u32, height: u32) -> Vec<u8> {
130    let expected = (width as usize) * (height as usize);
131    let mut out = vec![0u8; expected * 2];
132    encode_heights_into(elevations, width, height, &mut out);
133    out
134}
135
136/// Lazily iterate decoded elevations from little-endian u16 bytes.
137/// Trailing odd bytes are ignored.
138pub fn iter_heights(data: &[u8]) -> impl Iterator<Item = f64> + '_ {
139    data.chunks_exact(2)
140        .map(|c| u16_to_elevation(u16::from_le_bytes([c[0], c[1]])))
141}
142
143/// Decode little-endian u16 bytes back to a flat elevation grid.
144///
145/// Truncates any trailing odd byte.
146pub fn decode_heights(data: &[u8]) -> Vec<f64> {
147    iter_heights(data).collect()
148}
149
150/// Child-availability mask: one bit per quadrant indicating whether a
151/// child tile exists at the next zoom level.
152///
153/// Bit layout (matches the heightmap-1.0 spec):
154///
155/// | Bit | Value | Quadrant  |
156/// |-----|-------|-----------|
157/// | 0   | 1     | Southwest |
158/// | 1   | 2     | Southeast |
159/// | 2   | 4     | Northwest |
160/// | 3   | 8     | Northeast |
161#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
162pub struct ChildTileMask {
163    /// Southwest child present.
164    pub southwest: bool,
165    /// Southeast child present.
166    pub southeast: bool,
167    /// Northwest child present.
168    pub northwest: bool,
169    /// Northeast child present.
170    pub northeast: bool,
171}
172
173impl ChildTileMask {
174    /// Mask with every quadrant present.
175    pub const fn all() -> Self {
176        Self {
177            southwest: true,
178            southeast: true,
179            northwest: true,
180            northeast: true,
181        }
182    }
183
184    /// Mask with no quadrants present.
185    pub const fn none() -> Self {
186        Self {
187            southwest: false,
188            southeast: false,
189            northwest: false,
190            northeast: false,
191        }
192    }
193
194    /// Pack into a single byte.
195    pub const fn to_byte(self) -> u8 {
196        let mut mask = 0u8;
197        if self.southwest {
198            mask |= 1;
199        }
200        if self.southeast {
201            mask |= 2;
202        }
203        if self.northwest {
204            mask |= 4;
205        }
206        if self.northeast {
207            mask |= 8;
208        }
209        mask
210    }
211
212    /// Unpack from a byte.
213    pub const fn from_byte(byte: u8) -> Self {
214        Self {
215            southwest: byte & 1 != 0,
216            southeast: byte & 2 != 0,
217            northwest: byte & 4 != 0,
218            northeast: byte & 8 != 0,
219        }
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    #[test]
228    fn elevation_roundtrip_within_range() {
229        for &e in &[-1000.0_f64, 0.0, 100.0, 8848.0, 12107.0] {
230            let back = u16_to_elevation(elevation_to_u16(e));
231            assert!((e - back).abs() < 0.2, "{e} → {back}");
232        }
233    }
234
235    #[test]
236    fn elevation_clamps_out_of_range() {
237        let low = u16_to_elevation(elevation_to_u16(-2000.0));
238        assert!((low - MIN_ELEVATION).abs() < 0.2);
239        let high = u16_to_elevation(elevation_to_u16(20000.0));
240        assert!((high - MAX_ELEVATION).abs() < 0.2);
241    }
242
243    #[test]
244    fn encode_decode_bulk() {
245        let elevations: Vec<f64> = vec![0.0, 100.0, -100.0, 1000.0];
246        let bytes = encode_heights(&elevations, 2, 2);
247        assert_eq!(bytes.len(), 8); // 4 × u16
248        let back = decode_heights(&bytes);
249        for (a, b) in elevations.iter().zip(&back) {
250            assert!((a - b).abs() < 0.2);
251        }
252    }
253
254    #[test]
255    fn child_mask_bits_match_spec() {
256        assert_eq!(ChildTileMask::all().to_byte(), 0b1111);
257        assert_eq!(ChildTileMask::none().to_byte(), 0);
258        let only_se = ChildTileMask {
259            southeast: true,
260            ..Default::default()
261        };
262        assert_eq!(only_se.to_byte(), 0b0010);
263        assert_eq!(
264            ChildTileMask::from_byte(0b1010),
265            ChildTileMask {
266                southwest: false,
267                southeast: true,
268                northwest: false,
269                northeast: true,
270            }
271        );
272    }
273}