Skip to main content

sidereon_core/
terrain_store.rs

1//! Memory-mappable terrain tile store with an explicit vertical datum contract.
2//!
3//! The store is a single canonical container: a fixed header, a sorted tile
4//! index, and one aligned `i16` posting payload per tile. Payloads are decoded
5//! DTED posting values in orthometric metres, stored longitude-major with
6//! latitude as the inner index. The reader keeps the input bytes in place and
7//! indexes posting bytes directly, so an application can pass an mmap-backed
8//! slice through [`MmapTerrain::from_bytes`].
9//!
10//! DTED and SRTM postings are orthometric heights, `H`, above the EGM96 mean sea
11//! level geoid. Ellipsoidal height conversion is an explicit `h = H + N` step
12//! using [`TerrainGeoidModel`].
13
14use std::borrow::Cow;
15use std::collections::HashMap;
16use std::fs;
17use std::path::{Path, PathBuf};
18
19use crate::geoid::{egm96_undulation, GeoidError, GeoidGrid};
20use crate::terrain::{
21    self, terrain_grid_candidates, validate_lookup_coordinates, DtedInterpolation,
22    DtedLookupOptions, DtedTile,
23};
24use crate::{Error, Result};
25
26const STORE_MAGIC: &[u8; 8] = b"TMMAP001";
27const STORE_VERSION: u16 = 1;
28const STORE_ALIGNMENT: usize = 4096;
29const STORE_HEADER_LEN: usize = 64;
30const STORE_INDEX_RECORD_LEN: usize = 80;
31const HEADER_VERSION_OFFSET: usize = 8;
32const HEADER_DATUM_OFFSET: usize = 10;
33const HEADER_TILE_COUNT_OFFSET: usize = 12;
34const HEADER_INDEX_OFFSET_OFFSET: usize = 16;
35const HEADER_DATA_OFFSET_OFFSET: usize = 24;
36const HEADER_TOTAL_LEN_OFFSET: usize = 32;
37const INDEX_LAT_OFFSET: usize = 0;
38const INDEX_LON_OFFSET: usize = 4;
39const INDEX_LON_COUNT_OFFSET: usize = 8;
40const INDEX_LAT_COUNT_OFFSET: usize = 12;
41const INDEX_DATA_OFFSET_OFFSET: usize = 16;
42const INDEX_DATA_LEN_OFFSET: usize = 24;
43const INDEX_CHECKSUM_OFFSET: usize = 32;
44const INDEX_MIN_LAT_OFFSET: usize = 40;
45const INDEX_MIN_LON_OFFSET: usize = 48;
46const INDEX_MAX_LAT_OFFSET: usize = 56;
47const INDEX_MAX_LON_OFFSET: usize = 64;
48const INDEX_DATUM_OFFSET: usize = 72;
49const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
50const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
51const EGM96_DAC_REMEDIATION: &str =
52    "obtain the public NGA EGM96 15-arcminute WW15MGH.DAC file and load it with Egm96FifteenMinuteGeoid::from_ww15mgh_dac_path or Egm96FifteenMinuteGeoid::from_ww15mgh_dac_bytes";
53
54/// Vertical datum carried by terrain store tile index records.
55#[derive(Clone, Copy, Debug, PartialEq, Eq)]
56pub enum VerticalDatum {
57    /// Orthometric height `H` in metres above the EGM96 mean sea level geoid.
58    Egm96MslOrthometric,
59}
60
61impl VerticalDatum {
62    fn tag(self) -> u8 {
63        match self {
64            Self::Egm96MslOrthometric => 1,
65        }
66    }
67
68    fn from_tag(tag: u8) -> core::result::Result<Self, TerrainStoreError> {
69        match tag {
70            1 => Ok(Self::Egm96MslOrthometric),
71            other => Err(TerrainStoreError::UnsupportedDatum { tag: other }),
72        }
73    }
74}
75
76/// Orthometric height `H` in metres above the EGM96 mean sea level geoid.
77///
78/// DTED/SRTM terrain postings use this datum. Convert to ellipsoidal height
79/// only through [`Self::to_ellipsoidal_height_deg`] or
80/// [`Self::to_ellipsoidal_height_rad`], which require a pinned geoid tier.
81#[derive(Clone, Copy, Debug, PartialEq)]
82pub struct OrthometricHeightM {
83    /// Orthometric height `H` in metres.
84    pub value_m: f64,
85}
86
87impl OrthometricHeightM {
88    /// Build an orthometric height `H` in metres.
89    #[must_use]
90    pub const fn new(value_m: f64) -> Self {
91        Self { value_m }
92    }
93
94    /// Return the orthometric height `H` in metres.
95    #[must_use]
96    pub const fn metres(self) -> f64 {
97        self.value_m
98    }
99
100    /// Convert this orthometric height to ellipsoidal height `h = H + N`.
101    ///
102    /// Inputs are geodetic `(latitude_deg, longitude_deg)`, matching the geoid
103    /// module's axis order. Terrain lookup APIs use `(longitude_deg,
104    /// latitude_deg)`, so call sites should pass the axes deliberately.
105    ///
106    /// [`TerrainGeoidModel::Egm96OneDegree`] uses the embedded EGM96 1-degree
107    /// grid. It agrees with the full EGM96 15-arcminute grid to about 0.4 m RMS,
108    /// so byte-identical terrain heights do not imply byte-identical
109    /// ellipsoidal heights across geoid tiers.
110    pub fn to_ellipsoidal_height_deg(
111        self,
112        latitude_deg: f64,
113        longitude_deg: f64,
114        geoid: TerrainGeoidModel<'_>,
115    ) -> core::result::Result<EllipsoidalHeightM, TerrainDatumError> {
116        Ok(EllipsoidalHeightM::new(
117            self.value_m + geoid.undulation_deg(latitude_deg, longitude_deg),
118        ))
119    }
120
121    /// Convert this orthometric height to ellipsoidal height `h = H + N`.
122    ///
123    /// Inputs are geodetic `(latitude_rad, longitude_rad)`, matching the geoid
124    /// module's axis order. [`TerrainGeoidModel::Egm96OneDegree`] uses the
125    /// embedded EGM96 1-degree grid. It agrees with the full EGM96
126    /// 15-arcminute grid to about 0.4 m RMS, so byte-identical terrain heights
127    /// do not imply byte-identical ellipsoidal heights across geoid tiers.
128    pub fn to_ellipsoidal_height_rad(
129        self,
130        latitude_rad: f64,
131        longitude_rad: f64,
132        geoid: TerrainGeoidModel<'_>,
133    ) -> core::result::Result<EllipsoidalHeightM, TerrainDatumError> {
134        Ok(EllipsoidalHeightM::new(
135            self.value_m + geoid.undulation_rad(latitude_rad, longitude_rad),
136        ))
137    }
138}
139
140/// Ellipsoidal height `h` in metres above the WGS84 reference ellipsoid.
141#[derive(Clone, Copy, Debug, PartialEq)]
142pub struct EllipsoidalHeightM {
143    /// Ellipsoidal height `h` in metres.
144    pub value_m: f64,
145}
146
147impl EllipsoidalHeightM {
148    /// Build an ellipsoidal height `h` in metres.
149    #[must_use]
150    pub const fn new(value_m: f64) -> Self {
151        Self { value_m }
152    }
153
154    /// Return the ellipsoidal height `h` in metres.
155    #[must_use]
156    pub const fn metres(self) -> f64 {
157        self.value_m
158    }
159}
160
161/// Loaded EGM96 15-arcminute geoid grid for explicit terrain datum conversion.
162///
163/// This type never falls back to the embedded 1-degree grid. A missing
164/// `WW15MGH.DAC` file returns [`TerrainDatumError::MissingEgm96Dac`].
165#[derive(Clone, Debug, PartialEq)]
166pub struct Egm96FifteenMinuteGeoid {
167    grid: GeoidGrid,
168}
169
170impl Egm96FifteenMinuteGeoid {
171    /// Load `WW15MGH.DAC` bytes as an EGM96 15-arcminute geoid grid.
172    pub fn from_ww15mgh_dac_bytes(bytes: &[u8]) -> core::result::Result<Self, TerrainDatumError> {
173        let grid = GeoidGrid::from_egm96_dac(bytes).map_err(TerrainDatumError::Geoid)?;
174        Ok(Self { grid })
175    }
176
177    /// Read and load `WW15MGH.DAC` from disk as an EGM96 15-arcminute geoid
178    /// grid.
179    ///
180    /// If the file is absent, this returns
181    /// [`TerrainDatumError::MissingEgm96Dac`] with a remediation string naming
182    /// the required grid and loader. It does not fall back to the embedded
183    /// EGM96 1-degree grid.
184    pub fn from_ww15mgh_dac_path(
185        path: impl AsRef<Path>,
186    ) -> core::result::Result<Self, TerrainDatumError> {
187        let path = path.as_ref();
188        let bytes = match fs::read(path) {
189            Ok(bytes) => bytes,
190            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
191                return Err(TerrainDatumError::MissingEgm96Dac {
192                    path: path.to_path_buf(),
193                    remediation: EGM96_DAC_REMEDIATION,
194                });
195            }
196            Err(err) => {
197                return Err(TerrainDatumError::Io {
198                    path: path.to_path_buf(),
199                    message: err.to_string(),
200                });
201            }
202        };
203        Self::from_ww15mgh_dac_bytes(&bytes)
204    }
205
206    /// Borrow the loaded EGM96 15-arcminute geoid grid.
207    #[must_use]
208    pub const fn grid(&self) -> &GeoidGrid {
209        &self.grid
210    }
211}
212
213/// Geoid tier used to convert terrain orthometric height `H` to ellipsoidal
214/// height `h`.
215#[derive(Clone, Copy, Debug)]
216pub enum TerrainGeoidModel<'a> {
217    /// Embedded EGM96 1-degree grid, always available in-process.
218    ///
219    /// This tier agrees with the full EGM96 15-arcminute grid to about 0.4 m RMS.
220    /// It is the zero-setup path for `h = H + N` terrain conversion.
221    Egm96OneDegree,
222    /// Caller-supplied EGM96 15-arcminute `WW15MGH.DAC` grid.
223    ///
224    /// Build this with [`Egm96FifteenMinuteGeoid::from_ww15mgh_dac_path`] or
225    /// [`Egm96FifteenMinuteGeoid::from_ww15mgh_dac_bytes`]. Missing files fail
226    /// closed with [`TerrainDatumError::MissingEgm96Dac`].
227    Egm96FifteenMinute(&'a Egm96FifteenMinuteGeoid),
228}
229
230impl TerrainGeoidModel<'_> {
231    fn undulation_deg(self, latitude_deg: f64, longitude_deg: f64) -> f64 {
232        match self {
233            Self::Egm96OneDegree => {
234                egm96_undulation(latitude_deg.to_radians(), longitude_deg.to_radians())
235            }
236            Self::Egm96FifteenMinute(grid) => grid.grid.undulation_deg(latitude_deg, longitude_deg),
237        }
238    }
239
240    fn undulation_rad(self, latitude_rad: f64, longitude_rad: f64) -> f64 {
241        match self {
242            Self::Egm96OneDegree => egm96_undulation(latitude_rad, longitude_rad),
243            Self::Egm96FifteenMinute(grid) => grid.grid.undulation_rad(latitude_rad, longitude_rad),
244        }
245    }
246}
247
248/// Errors from vertical-datum conversion and optional geoid-grid loading.
249#[derive(Debug, Clone, PartialEq)]
250pub enum TerrainDatumError {
251    /// Terrain lookup failed before datum conversion.
252    Terrain(Error),
253    /// A geoid grid could not be parsed.
254    Geoid(GeoidError),
255    /// Reading a geoid grid failed for a reason other than absence.
256    Io {
257        /// Path that could not be read.
258        path: PathBuf,
259        /// I/O error text.
260        message: String,
261    },
262    /// The EGM96 15-arcminute `WW15MGH.DAC` grid was requested but is absent.
263    MissingEgm96Dac {
264        /// Path that was requested.
265        path: PathBuf,
266        /// Remediation text naming the required grid and loader.
267        remediation: &'static str,
268    },
269}
270
271impl core::fmt::Display for TerrainDatumError {
272    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
273        match self {
274            Self::Terrain(err) => write!(f, "terrain lookup failed: {err}"),
275            Self::Geoid(err) => write!(f, "geoid grid failed: {err}"),
276            Self::Io { path, message } => {
277                write!(f, "{} could not be read: {message}", path.display())
278            }
279            Self::MissingEgm96Dac { path, remediation } => {
280                write!(f, "{} is missing; {remediation}", path.display())
281            }
282        }
283    }
284}
285
286impl std::error::Error for TerrainDatumError {}
287
288impl From<Error> for TerrainDatumError {
289    fn from(value: Error) -> Self {
290        Self::Terrain(value)
291    }
292}
293
294/// Metadata for one tile index record in a memory-mappable terrain store.
295#[derive(Clone, Copy, Debug, PartialEq)]
296pub struct TerrainStoreTileIndex {
297    /// Integer latitude tile id, e.g. `36` for a tile covering `36..37` degrees.
298    pub lat_index: i32,
299    /// Integer longitude tile id, e.g. `-107` for a tile covering
300    /// `-107..-106` degrees.
301    pub lon_index: i32,
302    /// Western edge longitude in degrees.
303    pub min_longitude_deg: f64,
304    /// Southern edge latitude in degrees.
305    pub min_latitude_deg: f64,
306    /// Eastern edge longitude in degrees.
307    pub max_longitude_deg: f64,
308    /// Northern edge latitude in degrees.
309    pub max_latitude_deg: f64,
310    /// Number of longitude postings.
311    pub lon_count: u32,
312    /// Number of latitude postings.
313    pub lat_count: u32,
314    /// Byte offset of this tile's posting payload in the store.
315    pub data_offset: u64,
316    /// Byte length of this tile's posting payload in the store.
317    pub data_len: u64,
318    /// FNV-1a checksum of this tile's posting payload bytes.
319    pub checksum64: u64,
320    /// Vertical datum for the tile's posting payload.
321    pub vertical_datum: VerticalDatum,
322}
323
324/// Errors from terrain store conversion, serialization, and parsing.
325#[derive(Debug, Clone, PartialEq, Eq)]
326pub enum TerrainStoreError {
327    /// File or directory I/O failed.
328    Io {
329        /// Path being accessed.
330        path: PathBuf,
331        /// I/O error text.
332        message: String,
333    },
334    /// DTED or terrain store bytes could not be parsed.
335    Parse {
336        /// Human-readable parse reason.
337        reason: String,
338    },
339    /// The terrain store version is not supported.
340    UnsupportedVersion {
341        /// Version tag found in the store header.
342        version: u16,
343    },
344    /// The terrain store datum tag is not supported.
345    UnsupportedDatum {
346        /// Datum tag found in the store header or tile index.
347        tag: u8,
348    },
349    /// Two input DTED files resolved to the same integer tile id.
350    DuplicateTile {
351        /// Latitude tile id.
352        lat_index: i32,
353        /// Longitude tile id.
354        lon_index: i32,
355    },
356    /// A tile payload checksum did not match its index record.
357    Checksum {
358        /// Latitude tile id.
359        lat_index: i32,
360        /// Longitude tile id.
361        lon_index: i32,
362        /// Checksum stored in the index record.
363        expected: u64,
364        /// Checksum computed from the posting payload.
365        found: u64,
366    },
367}
368
369impl core::fmt::Display for TerrainStoreError {
370    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
371        match self {
372            Self::Io { path, message } => write!(f, "{} failed: {message}", path.display()),
373            Self::Parse { reason } => write!(f, "terrain store parse error: {reason}"),
374            Self::UnsupportedVersion { version } => {
375                write!(f, "terrain store version {version} is not supported")
376            }
377            Self::UnsupportedDatum { tag } => {
378                write!(f, "terrain store vertical datum tag {tag} is not supported")
379            }
380            Self::DuplicateTile {
381                lat_index,
382                lon_index,
383            } => write!(f, "duplicate terrain tile ({lat_index},{lon_index})"),
384            Self::Checksum {
385                lat_index,
386                lon_index,
387                expected,
388                found,
389            } => write!(
390                f,
391                "terrain tile ({lat_index},{lon_index}) checksum expected {expected:#x} but found {found:#x}"
392            ),
393        }
394    }
395}
396
397impl std::error::Error for TerrainStoreError {}
398
399#[derive(Clone, Debug)]
400struct MmapTile {
401    index: TerrainStoreTileIndex,
402}
403
404impl MmapTile {
405    fn contains(&self, longitude_deg: f64, latitude_deg: f64) -> bool {
406        latitude_deg >= self.index.min_latitude_deg
407            && latitude_deg <= self.index.max_latitude_deg
408            && longitude_deg >= self.index.min_longitude_deg
409            && longitude_deg <= self.index.max_longitude_deg
410    }
411
412    fn get_elevation(&self, bytes: &[u8], longitude_deg: f64, latitude_deg: f64) -> Result<i16> {
413        if !self.contains(longitude_deg, latitude_deg) {
414            return Err(Error::Parse(format!(
415                "point ({longitude_deg},{latitude_deg}) is outside terrain store tile ({},{})",
416                self.index.min_longitude_deg, self.index.min_latitude_deg
417            )));
418        }
419
420        let lat_count = self.index.lat_count as usize;
421        let lon_count = self.index.lon_count as usize;
422        let latitude_index = terrain::py_round_to_usize(
423            (latitude_deg - self.index.min_latitude_deg) * (lat_count - 1) as f64,
424        )
425        .map_err(Error::Parse)?;
426        let longitude_index = terrain::py_round_to_usize(
427            (longitude_deg - self.index.min_longitude_deg) * (lon_count - 1) as f64,
428        )
429        .map_err(Error::Parse)?;
430        if latitude_index >= lat_count || longitude_index >= lon_count {
431            return Err(Error::Parse(format!(
432                "posting index out of bounds lon={longitude_index} lat={latitude_index}"
433            )));
434        }
435
436        let sample_start =
437            self.index.data_offset as usize + 2 * (longitude_index * lat_count + latitude_index);
438        Ok(i16::from_le_bytes([
439            bytes[sample_start],
440            bytes[sample_start + 1],
441        ]))
442    }
443}
444
445/// Memory-mappable terrain reader backed by a terrain store byte span.
446///
447/// Scalar and batch terrain lookups return orthometric metres, `H`, above the
448/// EGM96 mean sea level geoid. Use [`Self::ellipsoidal_height_m`] or
449/// [`OrthometricHeightM::to_ellipsoidal_height_deg`] for the explicit
450/// `h = H + N` conversion to WGS84 ellipsoidal height.
451#[derive(Clone, Debug)]
452pub struct MmapTerrain<'a> {
453    bytes: Cow<'a, [u8]>,
454    tiles: Vec<MmapTile>,
455    by_grid: HashMap<(i32, i32), usize>,
456    tile_index: Vec<TerrainStoreTileIndex>,
457    vertical_datum: VerticalDatum,
458}
459
460impl MmapTerrain<'static> {
461    /// Parse an owned terrain store byte vector.
462    pub fn from_vec(bytes: Vec<u8>) -> core::result::Result<Self, TerrainStoreError> {
463        Self::from_cow(Cow::Owned(bytes))
464    }
465
466    /// Read and parse a terrain store file.
467    ///
468    /// This reads the file into memory. Applications that already manage an mmap
469    /// should pass the mmap slice to [`MmapTerrain::from_bytes`] to avoid the
470    /// file read copy.
471    pub fn from_path(path: impl AsRef<Path>) -> core::result::Result<Self, TerrainStoreError> {
472        let path = path.as_ref();
473        let bytes = fs::read(path).map_err(|err| TerrainStoreError::Io {
474            path: path.to_path_buf(),
475            message: err.to_string(),
476        })?;
477        Self::from_vec(bytes)
478    }
479}
480
481impl<'a> MmapTerrain<'a> {
482    /// Parse a borrowed terrain store byte span.
483    ///
484    /// The reader keeps the byte span in place and indexes posting payloads by
485    /// offset. Passing an mmap-backed slice gives a zero-copy reader.
486    pub fn from_bytes(bytes: &'a [u8]) -> core::result::Result<Self, TerrainStoreError> {
487        Self::from_cow(Cow::Borrowed(bytes))
488    }
489
490    fn from_cow(bytes: Cow<'a, [u8]>) -> core::result::Result<Self, TerrainStoreError> {
491        let parsed = parse_store(bytes.as_ref())?;
492        Ok(Self {
493            bytes,
494            tiles: parsed.tiles,
495            by_grid: parsed.by_grid,
496            tile_index: parsed.tile_index,
497            vertical_datum: parsed.vertical_datum,
498        })
499    }
500
501    /// Borrow the original terrain store bytes.
502    #[must_use]
503    pub fn as_bytes(&self) -> &[u8] {
504        self.bytes.as_ref()
505    }
506
507    /// Return the store's file-level vertical datum.
508    #[must_use]
509    pub const fn vertical_datum(&self) -> VerticalDatum {
510        self.vertical_datum
511    }
512
513    /// Borrow the parsed tile index records.
514    #[must_use]
515    pub fn tile_index(&self) -> &[TerrainStoreTileIndex] {
516        &self.tile_index
517    }
518
519    /// Return an FNV-1a checksum of the full terrain store byte span.
520    #[must_use]
521    pub fn checksum64(&self) -> u64 {
522        terrain_store_checksum64(self.bytes.as_ref())
523    }
524
525    /// Re-serialize this parsed terrain store into canonical bytes.
526    ///
527    /// A store accepted by [`Self::from_bytes`] is already canonical, so this
528    /// returns bytes identical to [`Self::as_bytes`].
529    #[must_use]
530    pub fn to_bytes(&self) -> Vec<u8> {
531        let pending = self
532            .tiles
533            .iter()
534            .map(|tile| PendingTile {
535                lat_index: tile.index.lat_index,
536                lon_index: tile.index.lon_index,
537                min_latitude_deg: tile.index.min_latitude_deg,
538                min_longitude_deg: tile.index.min_longitude_deg,
539                max_latitude_deg: tile.index.max_latitude_deg,
540                max_longitude_deg: tile.index.max_longitude_deg,
541                lon_count: tile.index.lon_count,
542                lat_count: tile.index.lat_count,
543                data: self.tile_payload(tile).to_vec(),
544                vertical_datum: tile.index.vertical_datum,
545            })
546            .collect();
547        build_store(pending).expect("parsed terrain store can be reserialized")
548    }
549
550    /// Return the bilinearly interpolated orthometric height `H` in metres at a
551    /// longitude-first geodetic position in degrees.
552    pub fn height_m(&mut self, longitude_deg: f64, latitude_deg: f64) -> Result<f64> {
553        self.height_m_with_options(longitude_deg, latitude_deg, DtedLookupOptions::default())
554    }
555
556    /// Return the orthometric height `H` in metres at a longitude-first geodetic
557    /// position in degrees using explicit lookup options.
558    pub fn height_m_with_options(
559        &mut self,
560        longitude_deg: f64,
561        latitude_deg: f64,
562        options: DtedLookupOptions,
563    ) -> Result<f64> {
564        self.orthometric_height_m_with_options(longitude_deg, latitude_deg, options)
565            .map(OrthometricHeightM::metres)
566    }
567
568    /// Return the bilinearly interpolated orthometric height `H` in metres as a
569    /// typed value at a longitude-first geodetic position in degrees.
570    pub fn orthometric_height_m(
571        &self,
572        longitude_deg: f64,
573        latitude_deg: f64,
574    ) -> Result<OrthometricHeightM> {
575        self.orthometric_height_m_with_options(
576            longitude_deg,
577            latitude_deg,
578            DtedLookupOptions::default(),
579        )
580    }
581
582    /// Return the orthometric height `H` in metres as a typed value at a
583    /// longitude-first geodetic position in degrees using explicit lookup
584    /// options.
585    pub fn orthometric_height_m_with_options(
586        &self,
587        longitude_deg: f64,
588        latitude_deg: f64,
589        options: DtedLookupOptions,
590    ) -> Result<OrthometricHeightM> {
591        validate_lookup_coordinates(longitude_deg, latitude_deg)?;
592        let Some(tile_idx) = self.resolve_grid(longitude_deg, latitude_deg) else {
593            return Ok(OrthometricHeightM::new(0.0));
594        };
595        height_from_tile(
596            self.bytes.as_ref(),
597            &self.tiles[tile_idx],
598            longitude_deg,
599            latitude_deg,
600            options,
601        )
602        .map(OrthometricHeightM::new)
603    }
604
605    /// Evaluate `(longitude_deg, latitude_deg)` points in order as orthometric
606    /// heights `H` in metres.
607    ///
608    /// The tuple order is longitude-first, matching [`Self::height_m`]. Each
609    /// output element is independent, so an invalid point or parse failure is
610    /// returned only for that element.
611    pub fn height_batch(
612        &mut self,
613        points: &[(f64, f64)],
614        options: DtedLookupOptions,
615    ) -> Vec<Result<f64>> {
616        self.orthometric_height_batch(points, options)
617            .into_iter()
618            .map(|result| result.map(OrthometricHeightM::metres))
619            .collect()
620    }
621
622    /// Evaluate `(longitude_deg, latitude_deg)` points in order as typed
623    /// orthometric heights `H` in metres.
624    ///
625    /// The tuple order is longitude-first. Each output element is independent,
626    /// so an invalid point or parse failure is returned only for that element.
627    pub fn orthometric_height_batch(
628        &self,
629        points: &[(f64, f64)],
630        options: DtedLookupOptions,
631    ) -> Vec<Result<OrthometricHeightM>> {
632        let mut out = Vec::with_capacity(points.len());
633        let mut current = None;
634
635        for &(longitude_deg, latitude_deg) in points {
636            if let Err(err) = validate_lookup_coordinates(longitude_deg, latitude_deg) {
637                out.push(Err(err));
638                continue;
639            }
640
641            let primary_grid = terrain::terrain_grid(longitude_deg, latitude_deg);
642            if current == Some(primary_grid) {
643                if let Some(&tile_idx) = self.by_grid.get(&primary_grid) {
644                    let tile = &self.tiles[tile_idx];
645                    if tile.contains(longitude_deg, latitude_deg) {
646                        out.push(
647                            height_from_tile(
648                                self.bytes.as_ref(),
649                                tile,
650                                longitude_deg,
651                                latitude_deg,
652                                options,
653                            )
654                            .map(OrthometricHeightM::new),
655                        );
656                        continue;
657                    }
658                }
659            }
660
661            match self.resolve_grid(longitude_deg, latitude_deg) {
662                Some(tile_idx) => {
663                    let tile = &self.tiles[tile_idx];
664                    current = Some((tile.index.lat_index, tile.index.lon_index));
665                    out.push(
666                        height_from_tile(
667                            self.bytes.as_ref(),
668                            tile,
669                            longitude_deg,
670                            latitude_deg,
671                            options,
672                        )
673                        .map(OrthometricHeightM::new),
674                    );
675                }
676                None => {
677                    current = None;
678                    out.push(Ok(OrthometricHeightM::new(0.0)));
679                }
680            }
681        }
682
683        out
684    }
685
686    /// Return ellipsoidal height `h` in metres using the embedded EGM96
687    /// 1-degree grid for `h = H + N`.
688    ///
689    /// The input position is terrain order `(longitude_deg, latitude_deg)`.
690    /// Internally, the geoid call is made with `(latitude_deg, longitude_deg)`.
691    /// The embedded EGM96 1-degree grid agrees with the full EGM96
692    /// 15-arcminute grid to about 0.4 m RMS.
693    pub fn ellipsoidal_height_m(
694        &self,
695        longitude_deg: f64,
696        latitude_deg: f64,
697    ) -> core::result::Result<EllipsoidalHeightM, TerrainDatumError> {
698        self.ellipsoidal_height_m_with_options(
699            longitude_deg,
700            latitude_deg,
701            DtedLookupOptions::default(),
702        )
703    }
704
705    /// Return ellipsoidal height `h` in metres using the embedded EGM96
706    /// 1-degree grid for `h = H + N` and explicit terrain lookup options.
707    ///
708    /// The input position is terrain order `(longitude_deg, latitude_deg)`.
709    /// Internally, the geoid call is made with `(latitude_deg, longitude_deg)`.
710    pub fn ellipsoidal_height_m_with_options(
711        &self,
712        longitude_deg: f64,
713        latitude_deg: f64,
714        options: DtedLookupOptions,
715    ) -> core::result::Result<EllipsoidalHeightM, TerrainDatumError> {
716        self.ellipsoidal_height_m_with_model(
717            longitude_deg,
718            latitude_deg,
719            options,
720            TerrainGeoidModel::Egm96OneDegree,
721        )
722    }
723
724    /// Return ellipsoidal height `h` in metres using an explicit geoid tier for
725    /// `h = H + N`.
726    ///
727    /// The input position is terrain order `(longitude_deg, latitude_deg)`.
728    /// Internally, the geoid call is made with `(latitude_deg, longitude_deg)`.
729    /// Choosing [`TerrainGeoidModel::Egm96FifteenMinute`] requires a loaded
730    /// `WW15MGH.DAC` grid and never falls back to the embedded EGM96 1-degree
731    /// grid.
732    pub fn ellipsoidal_height_m_with_model(
733        &self,
734        longitude_deg: f64,
735        latitude_deg: f64,
736        options: DtedLookupOptions,
737        geoid: TerrainGeoidModel<'_>,
738    ) -> core::result::Result<EllipsoidalHeightM, TerrainDatumError> {
739        let orthometric = self
740            .orthometric_height_m_with_options(longitude_deg, latitude_deg, options)
741            .map_err(TerrainDatumError::Terrain)?;
742        orthometric.to_ellipsoidal_height_deg(latitude_deg, longitude_deg, geoid)
743    }
744
745    fn resolve_grid(&self, longitude_deg: f64, latitude_deg: f64) -> Option<usize> {
746        for grid_idx in terrain_grid_candidates(longitude_deg, latitude_deg) {
747            if let Some(&tile_idx) = self.by_grid.get(&grid_idx) {
748                if self.tiles[tile_idx].contains(longitude_deg, latitude_deg) {
749                    return Some(tile_idx);
750                }
751            }
752        }
753        None
754    }
755
756    fn tile_payload(&self, tile: &MmapTile) -> &[u8] {
757        let start = tile.index.data_offset as usize;
758        let end = start + tile.index.data_len as usize;
759        &self.bytes[start..end]
760    }
761}
762
763/// Convert a DTED tile tree into canonical memory-mappable terrain store bytes.
764///
765/// Input `.dt2` files are discovered recursively below `root` and sorted by
766/// integer tile id. DTED signed-magnitude postings are decoded once into `i16`
767/// orthometric metres. DTED negative zero and SRTM voids already encoded as
768/// zero remain zero, matching the existing lazy DTED reader.
769pub fn dted_tree_to_mmap_store(
770    root: impl AsRef<Path>,
771) -> core::result::Result<Vec<u8>, TerrainStoreError> {
772    let root = root.as_ref();
773    let mut paths = Vec::new();
774    collect_dted_tile_paths(root, &mut paths)?;
775    paths.sort();
776
777    let mut pending = Vec::with_capacity(paths.len());
778    for path in paths {
779        let tile = DtedTile::from_path(&path).map_err(|reason| TerrainStoreError::Parse {
780            reason: format!("{}: {reason}", path.display()),
781        })?;
782        let decoded =
783            tile.decoded_postings_lon_major()
784                .map_err(|reason| TerrainStoreError::Parse {
785                    reason: format!("{}: {reason}", path.display()),
786                })?;
787        let mut data = Vec::with_capacity(decoded.len() * 2);
788        for posting in decoded {
789            data.extend_from_slice(&posting.to_le_bytes());
790        }
791        let lat_index = tile.origin_latitude().floor() as i32;
792        let lon_index = tile.origin_longitude().floor() as i32;
793        pending.push(PendingTile {
794            lat_index,
795            lon_index,
796            min_latitude_deg: tile.origin_latitude(),
797            min_longitude_deg: tile.origin_longitude(),
798            max_latitude_deg: tile.origin_latitude() + 1.0,
799            max_longitude_deg: tile.origin_longitude() + 1.0,
800            lon_count: u32::try_from(tile.lon_count()).map_err(|_| TerrainStoreError::Parse {
801                reason: format!("{} longitude count exceeds u32", path.display()),
802            })?,
803            lat_count: u32::try_from(tile.lat_count()).map_err(|_| TerrainStoreError::Parse {
804                reason: format!("{} latitude count exceeds u32", path.display()),
805            })?,
806            data,
807            vertical_datum: VerticalDatum::Egm96MslOrthometric,
808        });
809    }
810
811    build_store(pending)
812}
813
814/// Convert a DTED tile tree and write canonical memory-mappable terrain store
815/// bytes to `output_path`.
816pub fn write_dted_tree_to_mmap_store(
817    root: impl AsRef<Path>,
818    output_path: impl AsRef<Path>,
819) -> core::result::Result<(), TerrainStoreError> {
820    let bytes = dted_tree_to_mmap_store(root)?;
821    let output_path = output_path.as_ref();
822    fs::write(output_path, &bytes).map_err(|err| TerrainStoreError::Io {
823        path: output_path.to_path_buf(),
824        message: err.to_string(),
825    })
826}
827
828/// Return an FNV-1a checksum for terrain store bytes.
829///
830/// This checksum is for deterministic local verification and is not a
831/// cryptographic digest.
832#[must_use]
833pub fn terrain_store_checksum64(bytes: &[u8]) -> u64 {
834    fnv1a64(bytes)
835}
836
837#[derive(Debug)]
838struct PendingTile {
839    lat_index: i32,
840    lon_index: i32,
841    min_latitude_deg: f64,
842    min_longitude_deg: f64,
843    max_latitude_deg: f64,
844    max_longitude_deg: f64,
845    lon_count: u32,
846    lat_count: u32,
847    data: Vec<u8>,
848    vertical_datum: VerticalDatum,
849}
850
851#[derive(Debug)]
852struct ParsedStore {
853    vertical_datum: VerticalDatum,
854    tiles: Vec<MmapTile>,
855    by_grid: HashMap<(i32, i32), usize>,
856    tile_index: Vec<TerrainStoreTileIndex>,
857}
858
859fn height_from_tile(
860    bytes: &[u8],
861    tile: &MmapTile,
862    longitude_deg: f64,
863    latitude_deg: f64,
864    options: DtedLookupOptions,
865) -> Result<f64> {
866    if options.interpolation == DtedInterpolation::NearestPosting {
867        return tile
868            .get_elevation(bytes, longitude_deg, latitude_deg)
869            .map(|v| v as f64);
870    }
871
872    let postings_per_deg_lon = tile.index.lon_count as usize - 1;
873    let postings_per_deg_lat = tile.index.lat_count as usize - 1;
874
875    let lon_idx = (longitude_deg - tile.index.min_longitude_deg) * postings_per_deg_lon as f64;
876    let lat_idx = (latitude_deg - tile.index.min_latitude_deg) * postings_per_deg_lat as f64;
877    let lon_lo = lon_idx.floor() as i64;
878    let lat_lo = lat_idx.floor() as i64;
879    let fx = lon_idx - lon_lo as f64;
880    let fy = lat_idx - lat_lo as f64;
881
882    let mut z = 0.0;
883    for (di, wx) in [(0i64, 1.0 - fx), (1i64, fx)] {
884        for (dj, wy) in [(0i64, 1.0 - fy), (1i64, fy)] {
885            let w = wx * wy;
886            if w == 0.0 {
887                continue;
888            }
889            let posting_lon =
890                tile.index.min_longitude_deg + (lon_lo + di) as f64 / postings_per_deg_lon as f64;
891            let posting_lat =
892                tile.index.min_latitude_deg + (lat_lo + dj) as f64 / postings_per_deg_lat as f64;
893            z += w * f64::from(tile.get_elevation(bytes, posting_lon, posting_lat)?);
894        }
895    }
896    Ok(z)
897}
898
899fn collect_dted_tile_paths(
900    root: &Path,
901    out: &mut Vec<PathBuf>,
902) -> core::result::Result<(), TerrainStoreError> {
903    let entries = fs::read_dir(root).map_err(|err| TerrainStoreError::Io {
904        path: root.to_path_buf(),
905        message: err.to_string(),
906    })?;
907
908    for entry in entries {
909        let entry = entry.map_err(|err| TerrainStoreError::Io {
910            path: root.to_path_buf(),
911            message: err.to_string(),
912        })?;
913        let path = entry.path();
914        let file_type = entry.file_type().map_err(|err| TerrainStoreError::Io {
915            path: path.clone(),
916            message: err.to_string(),
917        })?;
918        if file_type.is_dir() {
919            collect_dted_tile_paths(&path, out)?;
920        } else if file_type.is_file() && is_dted_tile_path(&path) {
921            out.push(path);
922        }
923    }
924    Ok(())
925}
926
927fn is_dted_tile_path(path: &Path) -> bool {
928    path.file_name()
929        .and_then(|name| name.to_str())
930        .is_some_and(|name| name.ends_with(".dt2"))
931}
932
933fn parse_store(bytes: &[u8]) -> core::result::Result<ParsedStore, TerrainStoreError> {
934    if bytes.len() < STORE_HEADER_LEN {
935        return Err(TerrainStoreError::Parse {
936            reason: format!(
937                "store has {} bytes but needs at least {STORE_HEADER_LEN}",
938                bytes.len()
939            ),
940        });
941    }
942    if &bytes[..STORE_MAGIC.len()] != STORE_MAGIC {
943        return Err(TerrainStoreError::Parse {
944            reason: "missing terrain store magic".to_string(),
945        });
946    }
947    let version = read_u16(bytes, HEADER_VERSION_OFFSET)?;
948    if version != STORE_VERSION {
949        return Err(TerrainStoreError::UnsupportedVersion { version });
950    }
951    ensure_zero(bytes, 11, 12, "header reserved byte")?;
952    ensure_zero(bytes, 40, STORE_HEADER_LEN, "header reserved bytes")?;
953
954    let vertical_datum = VerticalDatum::from_tag(bytes[HEADER_DATUM_OFFSET])?;
955    let tile_count = read_u32(bytes, HEADER_TILE_COUNT_OFFSET)? as usize;
956    let index_offset = read_u64(bytes, HEADER_INDEX_OFFSET_OFFSET)? as usize;
957    let data_offset = read_u64(bytes, HEADER_DATA_OFFSET_OFFSET)? as usize;
958    let total_len = read_u64(bytes, HEADER_TOTAL_LEN_OFFSET)? as usize;
959    if total_len != bytes.len() {
960        return Err(TerrainStoreError::Parse {
961            reason: format!(
962                "header total length {total_len} does not match {}",
963                bytes.len()
964            ),
965        });
966    }
967    if index_offset != STORE_HEADER_LEN {
968        return Err(TerrainStoreError::Parse {
969            reason: format!("index offset must be {STORE_HEADER_LEN}, got {index_offset}"),
970        });
971    }
972
973    let index_len = tile_count
974        .checked_mul(STORE_INDEX_RECORD_LEN)
975        .ok_or_else(|| TerrainStoreError::Parse {
976            reason: "tile index length overflows usize".to_string(),
977        })?;
978    let index_end =
979        index_offset
980            .checked_add(index_len)
981            .ok_or_else(|| TerrainStoreError::Parse {
982                reason: "tile index end overflows usize".to_string(),
983            })?;
984    if index_end > bytes.len() {
985        return Err(TerrainStoreError::Parse {
986            reason: "tile index extends past store length".to_string(),
987        });
988    }
989    let expected_data_offset = align_up(index_end, STORE_ALIGNMENT)?;
990    if data_offset != expected_data_offset {
991        return Err(TerrainStoreError::Parse {
992            reason: format!("data offset must be {expected_data_offset}, got {data_offset}"),
993        });
994    }
995    ensure_zero(bytes, index_end, data_offset, "index padding")?;
996
997    let mut tiles = Vec::with_capacity(tile_count);
998    let mut tile_index = Vec::with_capacity(tile_count);
999    let mut by_grid = HashMap::with_capacity(tile_count);
1000    let mut previous_id = None;
1001    let mut expected_next = data_offset;
1002
1003    for idx in 0..tile_count {
1004        let record_offset = index_offset + idx * STORE_INDEX_RECORD_LEN;
1005        let record = &bytes[record_offset..record_offset + STORE_INDEX_RECORD_LEN];
1006        let lat_index = read_i32(record, INDEX_LAT_OFFSET)?;
1007        let lon_index = read_i32(record, INDEX_LON_OFFSET)?;
1008        let tile_id = (lat_index, lon_index);
1009        if previous_id.is_some_and(|previous| tile_id <= previous) {
1010            return Err(TerrainStoreError::Parse {
1011                reason: "tile index records are not strictly sorted".to_string(),
1012            });
1013        }
1014        previous_id = Some(tile_id);
1015
1016        let lon_count = read_u32(record, INDEX_LON_COUNT_OFFSET)?;
1017        let lat_count = read_u32(record, INDEX_LAT_COUNT_OFFSET)?;
1018        if lon_count < 2 || lat_count < 2 {
1019            return Err(TerrainStoreError::Parse {
1020                reason: format!(
1021                    "tile ({lat_index},{lon_index}) has invalid dimensions lon_count={lon_count} lat_count={lat_count}"
1022                ),
1023            });
1024        }
1025        let offset = read_u64(record, INDEX_DATA_OFFSET_OFFSET)? as usize;
1026        let data_len = read_u64(record, INDEX_DATA_LEN_OFFSET)? as usize;
1027        let expected_len = (lon_count as usize)
1028            .checked_mul(lat_count as usize)
1029            .and_then(|count| count.checked_mul(2))
1030            .ok_or_else(|| TerrainStoreError::Parse {
1031                reason: format!("tile ({lat_index},{lon_index}) data length overflows usize"),
1032            })?;
1033        if data_len != expected_len {
1034            return Err(TerrainStoreError::Parse {
1035                reason: format!(
1036                    "tile ({lat_index},{lon_index}) data length must be {expected_len}, got {data_len}"
1037                ),
1038            });
1039        }
1040
1041        let expected_offset = align_up(expected_next, STORE_ALIGNMENT)?;
1042        ensure_zero(bytes, expected_next, expected_offset, "tile padding")?;
1043        if offset != expected_offset {
1044            return Err(TerrainStoreError::Parse {
1045                reason: format!(
1046                    "tile ({lat_index},{lon_index}) data offset must be {expected_offset}, got {offset}"
1047                ),
1048            });
1049        }
1050        let end = offset
1051            .checked_add(data_len)
1052            .ok_or_else(|| TerrainStoreError::Parse {
1053                reason: format!("tile ({lat_index},{lon_index}) data end overflows usize"),
1054            })?;
1055        if end > bytes.len() {
1056            return Err(TerrainStoreError::Parse {
1057                reason: format!("tile ({lat_index},{lon_index}) data extends past store length"),
1058            });
1059        }
1060
1061        let checksum64 = read_u64(record, INDEX_CHECKSUM_OFFSET)?;
1062        let found = fnv1a64(&bytes[offset..end]);
1063        if found != checksum64 {
1064            return Err(TerrainStoreError::Checksum {
1065                lat_index,
1066                lon_index,
1067                expected: checksum64,
1068                found,
1069            });
1070        }
1071
1072        let min_latitude_deg = read_f64(record, INDEX_MIN_LAT_OFFSET)?;
1073        let min_longitude_deg = read_f64(record, INDEX_MIN_LON_OFFSET)?;
1074        let max_latitude_deg = read_f64(record, INDEX_MAX_LAT_OFFSET)?;
1075        let max_longitude_deg = read_f64(record, INDEX_MAX_LON_OFFSET)?;
1076        for (field, value) in [
1077            ("min_latitude_deg", min_latitude_deg),
1078            ("min_longitude_deg", min_longitude_deg),
1079            ("max_latitude_deg", max_latitude_deg),
1080            ("max_longitude_deg", max_longitude_deg),
1081        ] {
1082            if !value.is_finite() {
1083                return Err(TerrainStoreError::Parse {
1084                    reason: format!("tile ({lat_index},{lon_index}) {field} is not finite"),
1085                });
1086            }
1087        }
1088        let tile_datum = VerticalDatum::from_tag(record[INDEX_DATUM_OFFSET])?;
1089        if tile_datum != vertical_datum {
1090            return Err(TerrainStoreError::Parse {
1091                reason: format!("tile ({lat_index},{lon_index}) datum differs from header"),
1092            });
1093        }
1094        ensure_zero(
1095            record,
1096            INDEX_DATUM_OFFSET + 1,
1097            STORE_INDEX_RECORD_LEN,
1098            "tile index reserved bytes",
1099        )?;
1100
1101        let index = TerrainStoreTileIndex {
1102            lat_index,
1103            lon_index,
1104            min_longitude_deg,
1105            min_latitude_deg,
1106            max_longitude_deg,
1107            max_latitude_deg,
1108            lon_count,
1109            lat_count,
1110            data_offset: offset as u64,
1111            data_len: data_len as u64,
1112            checksum64,
1113            vertical_datum: tile_datum,
1114        };
1115        by_grid.insert(tile_id, tiles.len());
1116        tiles.push(MmapTile { index });
1117        tile_index.push(index);
1118        expected_next = end;
1119    }
1120
1121    if expected_next != bytes.len() {
1122        return Err(TerrainStoreError::Parse {
1123            reason: format!(
1124                "store has trailing bytes: expected length {expected_next}, got {}",
1125                bytes.len()
1126            ),
1127        });
1128    }
1129
1130    Ok(ParsedStore {
1131        vertical_datum,
1132        tiles,
1133        by_grid,
1134        tile_index,
1135    })
1136}
1137
1138fn build_store(mut tiles: Vec<PendingTile>) -> core::result::Result<Vec<u8>, TerrainStoreError> {
1139    tiles.sort_by_key(|tile| (tile.lat_index, tile.lon_index));
1140    for pair in tiles.windows(2) {
1141        if (pair[0].lat_index, pair[0].lon_index) == (pair[1].lat_index, pair[1].lon_index) {
1142            return Err(TerrainStoreError::DuplicateTile {
1143                lat_index: pair[0].lat_index,
1144                lon_index: pair[0].lon_index,
1145            });
1146        }
1147    }
1148
1149    let index_end = STORE_HEADER_LEN
1150        .checked_add(
1151            tiles
1152                .len()
1153                .checked_mul(STORE_INDEX_RECORD_LEN)
1154                .ok_or_else(|| TerrainStoreError::Parse {
1155                    reason: "tile index length overflows usize".to_string(),
1156                })?,
1157        )
1158        .ok_or_else(|| TerrainStoreError::Parse {
1159            reason: "tile index end overflows usize".to_string(),
1160        })?;
1161    let data_offset = align_up(index_end, STORE_ALIGNMENT)?;
1162    let mut offsets = Vec::with_capacity(tiles.len());
1163    let mut cursor = data_offset;
1164    for tile in &tiles {
1165        cursor = align_up(cursor, STORE_ALIGNMENT)?;
1166        offsets.push(cursor);
1167        cursor = cursor
1168            .checked_add(tile.data.len())
1169            .ok_or_else(|| TerrainStoreError::Parse {
1170                reason: "store length overflows usize".to_string(),
1171            })?;
1172    }
1173
1174    let mut out = vec![0u8; cursor];
1175    out[..STORE_MAGIC.len()].copy_from_slice(STORE_MAGIC);
1176    write_u16(&mut out, HEADER_VERSION_OFFSET, STORE_VERSION);
1177    out[HEADER_DATUM_OFFSET] = VerticalDatum::Egm96MslOrthometric.tag();
1178    write_u32(
1179        &mut out,
1180        HEADER_TILE_COUNT_OFFSET,
1181        u32::try_from(tiles.len()).map_err(|_| TerrainStoreError::Parse {
1182            reason: "tile count exceeds u32".to_string(),
1183        })?,
1184    );
1185    write_u64(
1186        &mut out,
1187        HEADER_INDEX_OFFSET_OFFSET,
1188        STORE_HEADER_LEN as u64,
1189    );
1190    write_u64(&mut out, HEADER_DATA_OFFSET_OFFSET, data_offset as u64);
1191    write_u64(&mut out, HEADER_TOTAL_LEN_OFFSET, cursor as u64);
1192
1193    for (idx, tile) in tiles.iter().enumerate() {
1194        let record_offset = STORE_HEADER_LEN + idx * STORE_INDEX_RECORD_LEN;
1195        let offset = offsets[idx];
1196        let data_len = tile.data.len();
1197        let expected_len = (tile.lon_count as usize)
1198            .checked_mul(tile.lat_count as usize)
1199            .and_then(|count| count.checked_mul(2))
1200            .ok_or_else(|| TerrainStoreError::Parse {
1201                reason: format!(
1202                    "tile ({},{}) data length overflows usize",
1203                    tile.lat_index, tile.lon_index
1204                ),
1205            })?;
1206        if data_len != expected_len {
1207            return Err(TerrainStoreError::Parse {
1208                reason: format!(
1209                    "tile ({},{}) data length must be {expected_len}, got {data_len}",
1210                    tile.lat_index, tile.lon_index
1211                ),
1212            });
1213        }
1214
1215        let record = &mut out[record_offset..record_offset + STORE_INDEX_RECORD_LEN];
1216        write_i32(record, INDEX_LAT_OFFSET, tile.lat_index);
1217        write_i32(record, INDEX_LON_OFFSET, tile.lon_index);
1218        write_u32(record, INDEX_LON_COUNT_OFFSET, tile.lon_count);
1219        write_u32(record, INDEX_LAT_COUNT_OFFSET, tile.lat_count);
1220        write_u64(record, INDEX_DATA_OFFSET_OFFSET, offset as u64);
1221        write_u64(record, INDEX_DATA_LEN_OFFSET, data_len as u64);
1222        write_u64(record, INDEX_CHECKSUM_OFFSET, fnv1a64(&tile.data));
1223        write_f64(record, INDEX_MIN_LAT_OFFSET, tile.min_latitude_deg);
1224        write_f64(record, INDEX_MIN_LON_OFFSET, tile.min_longitude_deg);
1225        write_f64(record, INDEX_MAX_LAT_OFFSET, tile.max_latitude_deg);
1226        write_f64(record, INDEX_MAX_LON_OFFSET, tile.max_longitude_deg);
1227        record[INDEX_DATUM_OFFSET] = tile.vertical_datum.tag();
1228        out[offset..offset + data_len].copy_from_slice(&tile.data);
1229    }
1230
1231    Ok(out)
1232}
1233
1234fn align_up(value: usize, alignment: usize) -> core::result::Result<usize, TerrainStoreError> {
1235    let rem = value % alignment;
1236    if rem == 0 {
1237        Ok(value)
1238    } else {
1239        value
1240            .checked_add(alignment - rem)
1241            .ok_or_else(|| TerrainStoreError::Parse {
1242                reason: "aligned offset overflows usize".to_string(),
1243            })
1244    }
1245}
1246
1247fn ensure_zero(
1248    bytes: &[u8],
1249    start: usize,
1250    end: usize,
1251    context: &str,
1252) -> core::result::Result<(), TerrainStoreError> {
1253    if start > end || end > bytes.len() {
1254        return Err(TerrainStoreError::Parse {
1255            reason: format!("{context} range is out of bounds"),
1256        });
1257    }
1258    if bytes[start..end].iter().any(|&byte| byte != 0) {
1259        return Err(TerrainStoreError::Parse {
1260            reason: format!("{context} must be zero-filled"),
1261        });
1262    }
1263    Ok(())
1264}
1265
1266fn fnv1a64(bytes: &[u8]) -> u64 {
1267    bytes.iter().fold(FNV_OFFSET_BASIS, |hash, byte| {
1268        (hash ^ u64::from(*byte)).wrapping_mul(FNV_PRIME)
1269    })
1270}
1271
1272fn read_u16(bytes: &[u8], offset: usize) -> core::result::Result<u16, TerrainStoreError> {
1273    Ok(u16::from_le_bytes(read_array(bytes, offset)?))
1274}
1275
1276fn read_u32(bytes: &[u8], offset: usize) -> core::result::Result<u32, TerrainStoreError> {
1277    Ok(u32::from_le_bytes(read_array(bytes, offset)?))
1278}
1279
1280fn read_i32(bytes: &[u8], offset: usize) -> core::result::Result<i32, TerrainStoreError> {
1281    Ok(i32::from_le_bytes(read_array(bytes, offset)?))
1282}
1283
1284fn read_u64(bytes: &[u8], offset: usize) -> core::result::Result<u64, TerrainStoreError> {
1285    Ok(u64::from_le_bytes(read_array(bytes, offset)?))
1286}
1287
1288fn read_f64(bytes: &[u8], offset: usize) -> core::result::Result<f64, TerrainStoreError> {
1289    Ok(f64::from_le_bytes(read_array(bytes, offset)?))
1290}
1291
1292fn read_array<const N: usize>(
1293    bytes: &[u8],
1294    offset: usize,
1295) -> core::result::Result<[u8; N], TerrainStoreError> {
1296    let end = offset
1297        .checked_add(N)
1298        .ok_or_else(|| TerrainStoreError::Parse {
1299            reason: "numeric field offset overflows usize".to_string(),
1300        })?;
1301    let slice = bytes
1302        .get(offset..end)
1303        .ok_or_else(|| TerrainStoreError::Parse {
1304            reason: "numeric field extends past record".to_string(),
1305        })?;
1306    slice.try_into().map_err(|_| TerrainStoreError::Parse {
1307        reason: "numeric field has wrong length".to_string(),
1308    })
1309}
1310
1311fn write_u16(bytes: &mut [u8], offset: usize, value: u16) {
1312    bytes[offset..offset + 2].copy_from_slice(&value.to_le_bytes());
1313}
1314
1315fn write_u32(bytes: &mut [u8], offset: usize, value: u32) {
1316    bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
1317}
1318
1319fn write_i32(bytes: &mut [u8], offset: usize, value: i32) {
1320    bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
1321}
1322
1323fn write_u64(bytes: &mut [u8], offset: usize, value: u64) {
1324    bytes[offset..offset + 8].copy_from_slice(&value.to_le_bytes());
1325}
1326
1327fn write_f64(bytes: &mut [u8], offset: usize, value: f64) {
1328    bytes[offset..offset + 8].copy_from_slice(&value.to_le_bytes());
1329}