Skip to main content

terrain_codec/
terrain.rs

1//! One-shot heightmap → quantized-mesh (`.terrain`) encoding.
2//!
3//! This module ties together the three crates that otherwise have to be
4//! wired up by hand:
5//!
6//! 1. [`martini`] generates an adaptive RTIN mesh from the elevation grid.
7//! 2. The mesh's `(u, v, height)` are quantised to the 0..=32767 range.
8//! 3. [`quantized_mesh`] encodes the header, vertices, edge indices and
9//!    optional extensions into the quantized-mesh-1.0 byte stream.
10//!
11//! The fiddly bits it handles for you:
12//!
13//! - Re-sampling each mesh vertex's height (martini discards heights once
14//!   the error pyramid is built, so the transform has to recover the grid
15//!   coordinate from `(u, v)` and read the DEM again).
16//! - Computing the encoded height range from the *mesh* vertices (what is
17//!   actually stored), not the full grid.
18//! - Streaming the mesh vertices through
19//!   [`QuantizedMeshHeader::from_bounds_with_vertices_iter`] for a tight
20//!   horizon-occlusion point.
21//! - Vertex normals via the [`NormalMode`] of your choice.
22//!
23//! # Grid orientation
24//!
25//! `elevations` (and the `get_height` closure's `y`) are **row-major,
26//! north → south**: row `0` is the northern edge, row `grid_size - 1` the
27//! southern edge. This matches [`crate::normals::BufferedElevations`], so a
28//! buffered grid can be reused directly for [`NormalMode::BufferedGradient`].
29//!
30//! # Seamless tiling — the caller supplies the halo
31//!
32//! These functions encode **one tile in isolation**; they never fetch
33//! neighbouring tiles. For gap-free, seam-free output the *caller* must
34//! widen the input to overlap the neighbours — fetch the halo cells along
35//! with the tile and stitch them in before calling:
36//!
37//! - **Geometry seam.** martini needs a `2^n + 1` grid, so an `N`-post DEM
38//!   tile needs one extra post on its east and south edges. That `+1` post
39//!   is the neighbour tile's *first* post for the shared edge — read it
40//!   from the neighbour, don't edge-replicate, or adjacent tiles won't
41//!   agree on the boundary and the globe cracks along tile seams.
42//! - **Normal seam.** [`NormalMode::BufferedGradient`] needs a
43//!   `buffer`-cell halo of neighbour samples on **every** side (see
44//!   [`BufferedElevations`]). Edge vertices read their `±1` neighbours out
45//!   of that halo, so the same physical edge gets identical normals from
46//!   either tile and lighting stays continuous.
47//!
48//! Gathering that neighbour data (over HTTP, from disk, from a cache, …) is
49//! deliberately left to the caller — hence this module takes an
50//! already-assembled grid rather than fetching tiles itself, which also
51//! keeps it free of any async/runtime assumptions.
52//!
53//! # Example
54//!
55//! ```
56//! use terrain_codec::quantized_mesh::TileBounds;
57//! use terrain_codec::terrain::{encode_terrain, TerrainOptions};
58//!
59//! let grid_size = 65; // 2^6 + 1
60//! let elevations = vec![0.0f32; (grid_size * grid_size) as usize];
61//! let bounds = TileBounds::new(139.0, 35.0, 139.01, 35.01);
62//!
63//! let terrain: Vec<u8> = encode_terrain(
64//!     &elevations,
65//!     grid_size,
66//!     &bounds,
67//!     &TerrainOptions {
68//!         max_error: 1.0,
69//!         ..Default::default()
70//!     },
71//! );
72//! assert!(terrain.starts_with(&[0x1f, 0x8b])); // gzip magic (default level 6)
73//! ```
74
75use std::io::{self, Write};
76
77use martini::Martini;
78use quantized_mesh::{
79    EdgeIndices, EncodeOptions, QUANTIZED_MAX, QuantizedMeshEncoder, QuantizedMeshHeader,
80    QuantizedVertices, TileBounds, TileMetadata, WaterMask,
81};
82
83use crate::normals::{BufferedElevations, buffered_gradient_normals, face_normals};
84
85/// How (and whether) per-vertex normals are computed for the oct-encoded
86/// vertex-normals extension.
87#[derive(Debug, Clone, Default)]
88pub enum NormalMode {
89    /// No vertex-normals extension.
90    #[default]
91    None,
92    /// Per-tile face normals ([`crate::normals::face_normals`]). Simple, but
93    /// produces a visible shading seam at tile boundaries.
94    FaceNormals,
95    /// Seam-free DEM-gradient normals
96    /// ([`crate::normals::buffered_gradient_normals`]) sampled from a
97    /// buffer-extended grid. Its `tile_grid_size` must equal the encode
98    /// `grid_size`.
99    ///
100    /// The caller is responsible for filling the `buffer`-cell halo around
101    /// the tile with the **neighbour tiles'** elevations — that overlap is
102    /// what makes edge normals match across the seam. A halo filled by
103    /// edge-replication still encodes fine, but won't be seam-free.
104    BufferedGradient(BufferedElevations),
105}
106
107/// Options controlling [`encode_terrain`] and the other encode functions in
108/// this module.
109#[derive(Debug, Clone)]
110pub struct TerrainOptions {
111    /// Maximum RTIN error threshold in metres. Lower values keep more
112    /// triangles (higher fidelity, larger output).
113    pub max_error: f64,
114    /// Gzip compression level: `0` emits uncompressed bytes, `1..=9` gzip at
115    /// that level. Defaults to `6`.
116    pub compression_level: u32,
117    /// Vertex-normal strategy.
118    pub normals: NormalMode,
119    /// Optional water-mask extension.
120    pub water_mask: Option<WaterMask>,
121    /// Optional metadata (child-tile availability) extension.
122    pub metadata: Option<TileMetadata>,
123}
124
125impl Default for TerrainOptions {
126    fn default() -> Self {
127        Self {
128            max_error: 1.0,
129            compression_level: 6,
130            normals: NormalMode::None,
131            water_mask: None,
132            metadata: None,
133        }
134    }
135}
136
137/// Encode a heightmap to a quantized-mesh `.terrain` byte vector, sampling
138/// elevations through a closure.
139///
140/// `get_height(x, y)` returns the elevation in metres at grid column `x`
141/// (`0..grid_size`, west → east) and row `y` (`0..grid_size`, north →
142/// south).
143///
144/// This is the primitive form; [`encode_terrain`] wraps it for a flat
145/// `&[f32]` grid. `get_height` is called twice per grid vertex that ends up
146/// in the mesh (once while building the error pyramid, once to recover the
147/// stored height), so keep it cheap or memoised.
148///
149/// # Panics
150///
151/// Panics if `grid_size` is not `2^n + 1`, or — for
152/// [`NormalMode::BufferedGradient`] — if the buffered grid's
153/// `tile_grid_size` does not equal `grid_size`.
154pub fn encode_terrain_from_fn<F>(
155    grid_size: u32,
156    bounds: &TileBounds,
157    get_height: F,
158    options: &TerrainOptions,
159) -> Vec<u8>
160where
161    F: Fn(u32, u32) -> f64,
162{
163    let (encoder, encode_opts) = build(grid_size, bounds, get_height, options);
164    encoder.encode_with_options(&encode_opts)
165}
166
167/// Like [`encode_terrain_from_fn`], but streams the encoded bytes to a
168/// writer instead of allocating a `Vec`.
169///
170/// # Panics
171///
172/// Same panics as [`encode_terrain_from_fn`].
173pub fn encode_terrain_from_fn_to<F, W>(
174    grid_size: u32,
175    bounds: &TileBounds,
176    get_height: F,
177    options: &TerrainOptions,
178    writer: W,
179) -> io::Result<()>
180where
181    F: Fn(u32, u32) -> f64,
182    W: Write,
183{
184    let (encoder, encode_opts) = build(grid_size, bounds, get_height, options);
185    encoder.encode_to_with_options(writer, &encode_opts)
186}
187
188/// Encode a flat row-major (north → south) `f32` elevation grid to a
189/// quantized-mesh `.terrain` byte vector.
190///
191/// `elevations.len()` must equal `grid_size * grid_size`.
192///
193/// # Panics
194///
195/// Panics if the length check fails, or for the panics listed on
196/// [`encode_terrain_from_fn`].
197pub fn encode_terrain(
198    elevations: &[f32],
199    grid_size: u32,
200    bounds: &TileBounds,
201    options: &TerrainOptions,
202) -> Vec<u8> {
203    assert_grid_len(elevations.len(), grid_size);
204    let gs = grid_size as usize;
205    encode_terrain_from_fn(
206        grid_size,
207        bounds,
208        |x, y| elevations[y as usize * gs + x as usize] as f64,
209        options,
210    )
211}
212
213/// Like [`encode_terrain`], but streams the encoded bytes to a writer.
214///
215/// # Panics
216///
217/// Same panics as [`encode_terrain`].
218pub fn encode_terrain_to<W: Write>(
219    elevations: &[f32],
220    grid_size: u32,
221    bounds: &TileBounds,
222    options: &TerrainOptions,
223    writer: W,
224) -> io::Result<()> {
225    assert_grid_len(elevations.len(), grid_size);
226    let gs = grid_size as usize;
227    encode_terrain_from_fn_to(
228        grid_size,
229        bounds,
230        |x, y| elevations[y as usize * gs + x as usize] as f64,
231        options,
232        writer,
233    )
234}
235
236fn assert_grid_len(len: usize, grid_size: u32) {
237    let expected = (grid_size as usize) * (grid_size as usize);
238    assert_eq!(
239        len, expected,
240        "elevations length mismatch: expected {expected} ({grid_size}×{grid_size}), got {len}"
241    );
242}
243
244/// Run martini, quantise the mesh, build the header + extensions, and return
245/// a ready-to-encode [`QuantizedMeshEncoder`] alongside its [`EncodeOptions`].
246fn build<F>(
247    grid_size: u32,
248    bounds: &TileBounds,
249    get_height: F,
250    options: &TerrainOptions,
251) -> (QuantizedMeshEncoder, EncodeOptions)
252where
253    F: Fn(u32, u32) -> f64,
254{
255    if let NormalMode::BufferedGradient(buf) = &options.normals {
256        assert_eq!(
257            buf.tile_grid_size, grid_size,
258            "BufferedGradient tile_grid_size ({}) must equal encode grid_size ({grid_size})",
259            buf.tile_grid_size
260        );
261    }
262
263    let mut martini = Martini::new(grid_size);
264    let max = (grid_size - 1) as f64;
265    let tile = martini.create_terrain(|x, y| get_height(x as u32, y as u32));
266
267    // Hijack the UV transform to keep martini's `(u, v)` and re-sample the
268    // height at the grid vertex. Martini computes `u = x/max` and
269    // `v = 1 - y/max`, both exact for grid points, so the inverse recovers
270    // the integer grid coordinate without drift.
271    let (positions, indices, _uvs) =
272        tile.construct_mesh(&mut martini, options.max_error, &mut |(u, v)| {
273            let gx = (u * max).round();
274            let gy = ((1.0 - v) * max).round();
275            (u, v, get_height(gx as u32, gy as u32))
276        });
277
278    let vertex_count = positions.len() / 3;
279
280    // Height range over the mesh vertices — i.e. exactly the heights we
281    // quantise and store. A flat tile collapses to a zero span.
282    let mut min_h = f64::INFINITY;
283    let mut max_h = f64::NEG_INFINITY;
284    for i in 0..vertex_count {
285        let h = positions[i * 3 + 2] as f64;
286        min_h = min_h.min(h);
287        max_h = max_h.max(h);
288    }
289    if vertex_count == 0 {
290        min_h = 0.0;
291        max_h = 0.0;
292    }
293    let height_span = max_h - min_h;
294
295    // Quantise (u, v, height) → 0..=32767.
296    let quant_max = QUANTIZED_MAX as f64;
297    let mut vertices = QuantizedVertices::with_capacity(vertex_count);
298    for i in 0..vertex_count {
299        let u = positions[i * 3] as f64;
300        let v = positions[i * 3 + 1] as f64;
301        let h = positions[i * 3 + 2] as f64;
302        let uq = (u * quant_max).round().clamp(0.0, quant_max) as u16;
303        let vq = (v * quant_max).round().clamp(0.0, quant_max) as u16;
304        let hq = if height_span > 0.0 {
305            (((h - min_h) / height_span) * quant_max)
306                .round()
307                .clamp(0.0, quant_max) as u16
308        } else {
309            0
310        };
311        vertices.push(uq, vq, hq);
312    }
313
314    let edge_indices = EdgeIndices::from_vertices(&vertices);
315
316    // Feed the mesh vertices (geodetic) to the header so the horizon
317    // occlusion point is as tight as possible.
318    let lon_span = bounds.east - bounds.west;
319    let lat_span = bounds.north - bounds.south;
320    let geodetic = (0..vertex_count).map(|i| {
321        let u = positions[i * 3] as f64;
322        let v = positions[i * 3 + 1] as f64;
323        let h = positions[i * 3 + 2] as f64;
324        [bounds.west + u * lon_span, bounds.south + v * lat_span, h]
325    });
326    let header = QuantizedMeshHeader::from_bounds_with_vertices_iter(
327        bounds,
328        min_h as f32,
329        max_h as f32,
330        geodetic,
331    );
332
333    let normals = match &options.normals {
334        NormalMode::None => None,
335        NormalMode::FaceNormals => Some(face_normals(&vertices, &indices, bounds, min_h, max_h)),
336        NormalMode::BufferedGradient(buf) => {
337            Some(buffered_gradient_normals(&vertices, bounds, buf))
338        }
339    };
340
341    let encode_opts = EncodeOptions {
342        include_normals: normals.is_some(),
343        normals,
344        include_water_mask: options.water_mask.is_some(),
345        water_mask: options.water_mask.clone(),
346        include_metadata: options.metadata.is_some(),
347        metadata: options.metadata.clone(),
348        compression_level: options.compression_level,
349    };
350
351    let encoder = QuantizedMeshEncoder::new(header, vertices, indices, edge_indices);
352    (encoder, encode_opts)
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358    use quantized_mesh::DecodedMesh;
359
360    fn bumpy(x: u32, y: u32) -> f64 {
361        ((x as f64) / 8.0).sin() * 50.0 + ((y as f64) / 8.0).cos() * 30.0
362    }
363
364    #[test]
365    fn flat_tile_roundtrips_to_two_triangles() {
366        let bounds = TileBounds::new(139.0, 35.0, 139.01, 35.01);
367        let bytes = encode_terrain_from_fn(
368            65,
369            &bounds,
370            |_, _| 0.0,
371            &TerrainOptions {
372                max_error: 0.0,
373                compression_level: 0,
374                ..Default::default()
375            },
376        );
377
378        let mesh = DecodedMesh::decode(&bytes).expect("decode");
379        // Flat terrain with zero error → just the 2 corner triangles.
380        assert_eq!(mesh.indices.len(), 6);
381        assert_eq!(mesh.header.min_height, 0.0);
382        assert_eq!(mesh.header.max_height, 0.0);
383        // All four corners present, heights all quantise to 0.
384        assert!(mesh.vertices.height.iter().all(|&h| h == 0));
385    }
386
387    #[test]
388    fn default_options_gzip_compress() {
389        let bounds = TileBounds::new(139.0, 35.0, 139.01, 35.01);
390        let bytes = encode_terrain_from_fn(65, &bounds, bumpy, &TerrainOptions::default());
391        assert_eq!(&bytes[0..2], &[0x1f, 0x8b]); // gzip magic
392    }
393
394    #[test]
395    fn height_range_matches_decoded_extremes() {
396        let bounds = TileBounds::new(139.0, 35.0, 139.01, 35.01);
397        let bytes = encode_terrain_from_fn(
398            129,
399            &bounds,
400            bumpy,
401            &TerrainOptions {
402                max_error: 0.5,
403                compression_level: 0,
404                ..Default::default()
405            },
406        );
407        let mesh = DecodedMesh::decode(&bytes).expect("decode");
408
409        // The lowest mesh vertex must quantise to 0 and the highest to
410        // QUANTIZED_MAX (the encoded range is defined by the header extremes).
411        assert_eq!(*mesh.vertices.height.iter().min().unwrap(), 0);
412        assert_eq!(*mesh.vertices.height.iter().max().unwrap(), QUANTIZED_MAX);
413        assert!(mesh.header.max_height > mesh.header.min_height);
414    }
415
416    #[test]
417    fn slice_and_closure_agree() {
418        let grid_size = 65u32;
419        let gs = grid_size as usize;
420        let elevations: Vec<f32> = (0..gs * gs)
421            .map(|i| bumpy((i % gs) as u32, (i / gs) as u32) as f32)
422            .collect();
423        let bounds = TileBounds::new(139.0, 35.0, 139.01, 35.01);
424        let opts = TerrainOptions {
425            max_error: 1.0,
426            compression_level: 0,
427            ..Default::default()
428        };
429
430        let from_slice = encode_terrain(&elevations, grid_size, &bounds, &opts);
431        let from_fn = encode_terrain_from_fn(
432            grid_size,
433            &bounds,
434            |x, y| elevations[y as usize * gs + x as usize] as f64,
435            &opts,
436        );
437        assert_eq!(from_slice, from_fn);
438    }
439
440    #[test]
441    fn writer_form_matches_vec_form() {
442        let bounds = TileBounds::new(139.0, 35.0, 139.01, 35.01);
443        let opts = TerrainOptions {
444            max_error: 1.0,
445            compression_level: 6,
446            ..Default::default()
447        };
448        let vec_form = encode_terrain_from_fn(129, &bounds, bumpy, &opts);
449
450        let mut writer_form = Vec::new();
451        encode_terrain_from_fn_to(129, &bounds, bumpy, &opts, &mut writer_form).unwrap();
452        assert_eq!(vec_form, writer_form);
453    }
454
455    #[test]
456    fn face_normals_are_emitted_and_unit_length() {
457        let bounds = TileBounds::new(139.0, 35.0, 139.01, 35.01);
458        let bytes = encode_terrain_from_fn(
459            65,
460            &bounds,
461            bumpy,
462            &TerrainOptions {
463                max_error: 1.0,
464                compression_level: 0,
465                normals: NormalMode::FaceNormals,
466                ..Default::default()
467            },
468        );
469        let mesh = DecodedMesh::decode(&bytes).expect("decode");
470        let normals = mesh.extensions.normals.expect("normals present");
471        assert_eq!(normals.len(), mesh.vertices.len());
472        for n in &normals {
473            let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
474            // Oct-encoding is lossy, so allow a little slack around unit length.
475            assert!(
476                (len - 1.0).abs() < 0.05,
477                "normal not ~unit: {n:?} (len {len})"
478            );
479        }
480    }
481
482    #[test]
483    fn buffered_gradient_normals_are_emitted() {
484        let grid_size = 65u32;
485        let buffer = 1u32;
486        let full = (grid_size + 2 * buffer) as usize;
487        // Buffered grid sampling the same bumpy field, including the halo.
488        let mut buffered = Vec::with_capacity(full * full);
489        for j in 0..full {
490            for i in 0..full {
491                let x = i as i64 - buffer as i64;
492                let y = j as i64 - buffer as i64;
493                buffered.push(bumpy(x.max(0) as u32, y.max(0) as u32));
494            }
495        }
496        let buffered = BufferedElevations::new(buffered, grid_size, buffer);
497
498        let bounds = TileBounds::new(139.0, 35.0, 139.01, 35.01);
499        let bytes = encode_terrain_from_fn(
500            grid_size,
501            &bounds,
502            bumpy,
503            &TerrainOptions {
504                max_error: 1.0,
505                compression_level: 0,
506                normals: NormalMode::BufferedGradient(buffered),
507                ..Default::default()
508            },
509        );
510        let mesh = DecodedMesh::decode(&bytes).expect("decode");
511        let normals = mesh.extensions.normals.expect("normals present");
512        assert_eq!(normals.len(), mesh.vertices.len());
513    }
514
515    #[test]
516    #[should_panic(expected = "elevations length mismatch")]
517    fn slice_length_mismatch_panics() {
518        let bounds = TileBounds::new(139.0, 35.0, 139.01, 35.01);
519        encode_terrain(&[0.0f32; 10], 65, &bounds, &TerrainOptions::default());
520    }
521}