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}