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 crate::artifact_bytes::ArtifactBytes;
15use std::collections::{HashMap, HashSet};
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/// Integer terrain tile id used by DTED and terrain-store accessors.
325#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
326pub struct TerrainTileId {
327    /// Integer latitude tile id, e.g. `36` for a tile covering `36..37` degrees.
328    pub lat_index: i32,
329    /// Integer longitude tile id, e.g. `-107` for a tile covering
330    /// `-107..-106` degrees.
331    pub lon_index: i32,
332}
333
334impl TerrainTileId {
335    /// Build an integer terrain tile id.
336    #[must_use]
337    pub const fn new(lat_index: i32, lon_index: i32) -> Self {
338        Self {
339            lat_index,
340            lon_index,
341        }
342    }
343}
344
345/// One explicit DTED tile source for list-based terrain-store conversion.
346#[derive(Clone, Debug, PartialEq, Eq)]
347pub struct DtedTileListEntry {
348    /// Expected integer tile id for `path`.
349    pub tile_id: TerrainTileId,
350    /// Path to the DTED `.dt2` tile bytes.
351    pub path: PathBuf,
352}
353
354impl DtedTileListEntry {
355    /// Build a tile-list entry from a tile id and DTED path.
356    #[must_use]
357    pub fn new(tile_id: TerrainTileId, path: impl Into<PathBuf>) -> Self {
358        Self {
359            tile_id,
360            path: path.into(),
361        }
362    }
363
364    /// Build a tile-list entry from integer tile indices and a DTED path.
365    #[must_use]
366    pub fn from_indices(lat_index: i32, lon_index: i32, path: impl Into<PathBuf>) -> Self {
367        Self::new(TerrainTileId::new(lat_index, lon_index), path)
368    }
369}
370
371/// Errors from terrain store conversion, serialization, and parsing.
372#[derive(Debug, Clone, PartialEq, Eq)]
373pub enum TerrainStoreError {
374    /// File or directory I/O failed.
375    Io {
376        /// Path being accessed.
377        path: PathBuf,
378        /// I/O error text.
379        message: String,
380    },
381    /// DTED or terrain store bytes could not be parsed.
382    Parse {
383        /// Human-readable parse reason.
384        reason: String,
385    },
386    /// The terrain store version is not supported.
387    UnsupportedVersion {
388        /// Version tag found in the store header.
389        version: u16,
390    },
391    /// The terrain store datum tag is not supported.
392    UnsupportedDatum {
393        /// Datum tag found in the store header or tile index.
394        tag: u8,
395    },
396    /// Two input DTED files resolved to the same integer tile id.
397    DuplicateTile {
398        /// Latitude tile id.
399        lat_index: i32,
400        /// Longitude tile id.
401        lon_index: i32,
402    },
403    /// A list-builder entry's supplied id did not match the DTED file origin.
404    TileIdMismatch {
405        /// Path whose parsed DTED origin did not match the supplied id.
406        path: PathBuf,
407        /// Expected tile id supplied by the caller.
408        expected: TerrainTileId,
409        /// Tile id parsed from the DTED file.
410        found: TerrainTileId,
411    },
412    /// A tile payload checksum did not match its index record.
413    Checksum {
414        /// Latitude tile id.
415        lat_index: i32,
416        /// Longitude tile id.
417        lon_index: i32,
418        /// Checksum stored in the index record.
419        expected: u64,
420        /// Checksum computed from the posting payload.
421        found: u64,
422    },
423}
424
425impl core::fmt::Display for TerrainStoreError {
426    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
427        match self {
428            Self::Io { path, message } => write!(f, "{} failed: {message}", path.display()),
429            Self::Parse { reason } => write!(f, "terrain store parse error: {reason}"),
430            Self::UnsupportedVersion { version } => {
431                write!(f, "terrain store version {version} is not supported")
432            }
433            Self::UnsupportedDatum { tag } => {
434                write!(f, "terrain store vertical datum tag {tag} is not supported")
435            }
436            Self::DuplicateTile {
437                lat_index,
438                lon_index,
439            } => write!(f, "duplicate terrain tile ({lat_index},{lon_index})"),
440            Self::TileIdMismatch {
441                path,
442                expected,
443                found,
444            } => write!(
445                f,
446                "{} tile id expected ({},{}) but DTED origin is ({},{})",
447                path.display(),
448                expected.lat_index,
449                expected.lon_index,
450                found.lat_index,
451                found.lon_index
452            ),
453            Self::Checksum {
454                lat_index,
455                lon_index,
456                expected,
457                found,
458            } => write!(
459                f,
460                "terrain tile ({lat_index},{lon_index}) checksum expected {expected:#x} but found {found:#x}"
461            ),
462        }
463    }
464}
465
466impl std::error::Error for TerrainStoreError {}
467
468#[derive(Clone, Debug)]
469struct MmapTile {
470    index: TerrainStoreTileIndex,
471}
472
473impl MmapTile {
474    fn contains(&self, longitude_deg: f64, latitude_deg: f64) -> bool {
475        latitude_deg >= self.index.min_latitude_deg
476            && latitude_deg <= self.index.max_latitude_deg
477            && longitude_deg >= self.index.min_longitude_deg
478            && longitude_deg <= self.index.max_longitude_deg
479    }
480
481    fn get_elevation(&self, bytes: &[u8], longitude_deg: f64, latitude_deg: f64) -> Result<i16> {
482        if !self.contains(longitude_deg, latitude_deg) {
483            return Err(Error::Parse(format!(
484                "point ({longitude_deg},{latitude_deg}) is outside terrain store tile ({},{})",
485                self.index.min_longitude_deg, self.index.min_latitude_deg
486            )));
487        }
488
489        let lat_count = self.index.lat_count as usize;
490        let lon_count = self.index.lon_count as usize;
491        let latitude_index = terrain::py_round_to_usize(
492            (latitude_deg - self.index.min_latitude_deg) * (lat_count - 1) as f64,
493        )
494        .map_err(Error::Parse)?;
495        let longitude_index = terrain::py_round_to_usize(
496            (longitude_deg - self.index.min_longitude_deg) * (lon_count - 1) as f64,
497        )
498        .map_err(Error::Parse)?;
499        if latitude_index >= lat_count || longitude_index >= lon_count {
500            return Err(Error::Parse(format!(
501                "posting index out of bounds lon={longitude_index} lat={latitude_index}"
502            )));
503        }
504
505        let sample_start =
506            self.index.data_offset as usize + 2 * (longitude_index * lat_count + latitude_index);
507        Ok(i16::from_le_bytes([
508            bytes[sample_start],
509            bytes[sample_start + 1],
510        ]))
511    }
512}
513
514/// Memory-mappable terrain reader backed by a terrain store byte span.
515///
516/// Scalar and batch terrain lookups return orthometric metres, `H`, above the
517/// EGM96 mean sea level geoid. Use [`Self::ellipsoidal_height_m`] or
518/// [`OrthometricHeightM::to_ellipsoidal_height_deg`] for the explicit
519/// `h = H + N` conversion to WGS84 ellipsoidal height.
520#[derive(Clone, Debug)]
521pub struct MmapTerrain<'a> {
522    bytes: ArtifactBytes<'a>,
523    tiles: Vec<MmapTile>,
524    by_grid: HashMap<(i32, i32), usize>,
525    tile_index: Vec<TerrainStoreTileIndex>,
526    tile_ids: Vec<TerrainTileId>,
527    vertical_datum: VerticalDatum,
528}
529
530impl MmapTerrain<'static> {
531    /// Parse an owned terrain store byte vector.
532    pub fn from_vec(bytes: Vec<u8>) -> core::result::Result<Self, TerrainStoreError> {
533        Self::from_backing(ArtifactBytes::Owned(bytes))
534    }
535
536    /// Open and parse a terrain store file.
537    ///
538    /// With the `mmap` feature the file is memory-mapped read-only and this
539    /// reader owns the mapping; without it the file is read into memory. The
540    /// entry point is the same either way, so enabling the feature speeds up
541    /// every existing caller rather than asking anyone to migrate.
542    ///
543    /// Mapping is what makes a very large store usable at all: construction
544    /// parses only the header, datum tag, and tile index, and lookups are
545    /// demand-paged, so a reader querying a geographically local region never
546    /// faults in the rest of the file.
547    pub fn from_path(path: impl AsRef<Path>) -> core::result::Result<Self, TerrainStoreError> {
548        let path = path.as_ref();
549
550        #[cfg(feature = "mmap")]
551        {
552            let bytes = crate::artifact_bytes::map_file_read_only(path).map_err(|err| {
553                TerrainStoreError::Io {
554                    path: path.to_path_buf(),
555                    message: err.to_string(),
556                }
557            })?;
558            Self::from_backing(bytes)
559        }
560
561        #[cfg(not(feature = "mmap"))]
562        {
563            let bytes = fs::read(path).map_err(|err| TerrainStoreError::Io {
564                path: path.to_path_buf(),
565                message: err.to_string(),
566            })?;
567            Self::from_vec(bytes)
568        }
569    }
570}
571
572impl<'a> MmapTerrain<'a> {
573    /// Parse a borrowed terrain store byte span.
574    ///
575    /// The reader keeps the byte span in place and indexes posting payloads by
576    /// offset. Passing an mmap-backed slice gives a zero-copy reader.
577    pub fn from_bytes(bytes: &'a [u8]) -> core::result::Result<Self, TerrainStoreError> {
578        Self::from_backing(ArtifactBytes::Borrowed(bytes))
579    }
580
581    fn from_backing(bytes: ArtifactBytes<'a>) -> core::result::Result<Self, TerrainStoreError> {
582        let parsed = parse_store(bytes.as_slice())?;
583        Ok(Self {
584            bytes,
585            tiles: parsed.tiles,
586            by_grid: parsed.by_grid,
587            tile_index: parsed.tile_index,
588            tile_ids: parsed.tile_ids,
589            vertical_datum: parsed.vertical_datum,
590        })
591    }
592
593    /// Borrow the original terrain store bytes.
594    #[must_use]
595    pub fn as_bytes(&self) -> &[u8] {
596        self.bytes.as_slice()
597    }
598
599    /// Whether this reader is backed by a memory map rather than a copy in
600    /// process memory.
601    #[must_use]
602    pub fn is_memory_mapped(&self) -> bool {
603        self.bytes.is_memory_mapped()
604    }
605
606    /// Return the store's file-level vertical datum.
607    #[must_use]
608    pub const fn vertical_datum(&self) -> VerticalDatum {
609        self.vertical_datum
610    }
611
612    /// Borrow the parsed tile index records.
613    #[must_use]
614    pub fn tile_index(&self) -> &[TerrainStoreTileIndex] {
615        &self.tile_index
616    }
617
618    /// Return the number of tiles present in this terrain store.
619    #[must_use]
620    pub fn tile_count(&self) -> usize {
621        self.tile_ids.len()
622    }
623
624    /// Borrow the sorted integer tile ids present in this terrain store.
625    #[must_use]
626    pub fn tile_ids(&self) -> &[TerrainTileId] {
627        &self.tile_ids
628    }
629
630    /// Return an FNV-1a checksum of the full terrain store byte span.
631    #[must_use]
632    pub fn checksum64(&self) -> u64 {
633        terrain_store_checksum64(self.bytes.as_ref())
634    }
635
636    /// Re-serialize this parsed terrain store into canonical bytes.
637    ///
638    /// A store accepted by [`Self::from_bytes`] is already canonical, so this
639    /// returns bytes identical to [`Self::as_bytes`].
640    #[must_use]
641    pub fn to_bytes(&self) -> Vec<u8> {
642        let pending = self
643            .tiles
644            .iter()
645            .map(|tile| PendingTile {
646                lat_index: tile.index.lat_index,
647                lon_index: tile.index.lon_index,
648                min_latitude_deg: tile.index.min_latitude_deg,
649                min_longitude_deg: tile.index.min_longitude_deg,
650                max_latitude_deg: tile.index.max_latitude_deg,
651                max_longitude_deg: tile.index.max_longitude_deg,
652                lon_count: tile.index.lon_count,
653                lat_count: tile.index.lat_count,
654                data: self.tile_payload(tile).to_vec(),
655                vertical_datum: tile.index.vertical_datum,
656            })
657            .collect();
658        build_store(pending).expect("parsed terrain store can be reserialized")
659    }
660
661    /// Return the bilinearly interpolated orthometric height `H` in metres at a
662    /// longitude-first geodetic position in degrees.
663    pub fn height_m(&mut self, longitude_deg: f64, latitude_deg: f64) -> Result<f64> {
664        self.height_m_with_options(longitude_deg, latitude_deg, DtedLookupOptions::default())
665    }
666
667    /// Return the orthometric height `H` in metres at a longitude-first geodetic
668    /// position in degrees using explicit lookup options.
669    pub fn height_m_with_options(
670        &mut self,
671        longitude_deg: f64,
672        latitude_deg: f64,
673        options: DtedLookupOptions,
674    ) -> Result<f64> {
675        self.orthometric_height_m_with_options(longitude_deg, latitude_deg, options)
676            .map(OrthometricHeightM::metres)
677    }
678
679    /// Return the bilinearly interpolated orthometric height `H` in metres as a
680    /// typed value at a longitude-first geodetic position in degrees.
681    pub fn orthometric_height_m(
682        &self,
683        longitude_deg: f64,
684        latitude_deg: f64,
685    ) -> Result<OrthometricHeightM> {
686        self.orthometric_height_m_with_options(
687            longitude_deg,
688            latitude_deg,
689            DtedLookupOptions::default(),
690        )
691    }
692
693    /// Return the orthometric height `H` in metres as a typed value at a
694    /// longitude-first geodetic position in degrees using explicit lookup
695    /// options.
696    pub fn orthometric_height_m_with_options(
697        &self,
698        longitude_deg: f64,
699        latitude_deg: f64,
700        options: DtedLookupOptions,
701    ) -> Result<OrthometricHeightM> {
702        validate_lookup_coordinates(longitude_deg, latitude_deg)?;
703        let Some(tile_idx) = self.resolve_grid(longitude_deg, latitude_deg) else {
704            return Err(missing_terrain_tile(longitude_deg, latitude_deg));
705        };
706        height_from_tile(
707            self.bytes.as_ref(),
708            &self.tiles[tile_idx],
709            longitude_deg,
710            latitude_deg,
711            options,
712        )
713        .map(OrthometricHeightM::new)
714    }
715
716    /// Evaluate `(longitude_deg, latitude_deg)` points in order as orthometric
717    /// heights `H` in metres.
718    ///
719    /// The tuple order is longitude-first, matching [`Self::height_m`]. Each
720    /// output element is independent, so an invalid point or parse failure is
721    /// returned only for that element.
722    pub fn height_batch(
723        &mut self,
724        points: &[(f64, f64)],
725        options: DtedLookupOptions,
726    ) -> Vec<Result<f64>> {
727        self.orthometric_height_batch(points, options)
728            .into_iter()
729            .map(|result| result.map(OrthometricHeightM::metres))
730            .collect()
731    }
732
733    /// Evaluate `(longitude_deg, latitude_deg)` points in order as typed
734    /// orthometric heights `H` in metres.
735    ///
736    /// The tuple order is longitude-first. Each output element is independent,
737    /// so an invalid point or parse failure is returned only for that element.
738    pub fn orthometric_height_batch(
739        &self,
740        points: &[(f64, f64)],
741        options: DtedLookupOptions,
742    ) -> Vec<Result<OrthometricHeightM>> {
743        let mut out = Vec::with_capacity(points.len());
744        let mut current = None;
745
746        for &(longitude_deg, latitude_deg) in points {
747            if let Err(err) = validate_lookup_coordinates(longitude_deg, latitude_deg) {
748                out.push(Err(err));
749                continue;
750            }
751
752            let primary_grid = terrain::terrain_grid(longitude_deg, latitude_deg);
753            if current == Some(primary_grid) {
754                if let Some(&tile_idx) = self.by_grid.get(&primary_grid) {
755                    let tile = &self.tiles[tile_idx];
756                    if tile.contains(longitude_deg, latitude_deg) {
757                        out.push(
758                            height_from_tile(
759                                self.bytes.as_ref(),
760                                tile,
761                                longitude_deg,
762                                latitude_deg,
763                                options,
764                            )
765                            .map(OrthometricHeightM::new),
766                        );
767                        continue;
768                    }
769                }
770            }
771
772            match self.resolve_grid(longitude_deg, latitude_deg) {
773                Some(tile_idx) => {
774                    let tile = &self.tiles[tile_idx];
775                    current = Some((tile.index.lat_index, tile.index.lon_index));
776                    out.push(
777                        height_from_tile(
778                            self.bytes.as_ref(),
779                            tile,
780                            longitude_deg,
781                            latitude_deg,
782                            options,
783                        )
784                        .map(OrthometricHeightM::new),
785                    );
786                }
787                None => {
788                    current = None;
789                    out.push(Err(missing_terrain_tile(longitude_deg, latitude_deg)));
790                }
791            }
792        }
793
794        out
795    }
796
797    /// Return ellipsoidal height `h` in metres using the embedded EGM96
798    /// 1-degree grid for `h = H + N`.
799    ///
800    /// The input position is terrain order `(longitude_deg, latitude_deg)`.
801    /// Internally, the geoid call is made with `(latitude_deg, longitude_deg)`.
802    /// The embedded EGM96 1-degree grid agrees with the full EGM96
803    /// 15-arcminute grid to about 0.4 m RMS.
804    pub fn ellipsoidal_height_m(
805        &self,
806        longitude_deg: f64,
807        latitude_deg: f64,
808    ) -> core::result::Result<EllipsoidalHeightM, TerrainDatumError> {
809        self.ellipsoidal_height_m_with_options(
810            longitude_deg,
811            latitude_deg,
812            DtedLookupOptions::default(),
813        )
814    }
815
816    /// Return ellipsoidal height `h` in metres using the embedded EGM96
817    /// 1-degree grid for `h = H + N` and explicit terrain lookup options.
818    ///
819    /// The input position is terrain order `(longitude_deg, latitude_deg)`.
820    /// Internally, the geoid call is made with `(latitude_deg, longitude_deg)`.
821    pub fn ellipsoidal_height_m_with_options(
822        &self,
823        longitude_deg: f64,
824        latitude_deg: f64,
825        options: DtedLookupOptions,
826    ) -> core::result::Result<EllipsoidalHeightM, TerrainDatumError> {
827        self.ellipsoidal_height_m_with_model(
828            longitude_deg,
829            latitude_deg,
830            options,
831            TerrainGeoidModel::Egm96OneDegree,
832        )
833    }
834
835    /// Return ellipsoidal height `h` in metres using an explicit geoid tier for
836    /// `h = H + N`.
837    ///
838    /// The input position is terrain order `(longitude_deg, latitude_deg)`.
839    /// Internally, the geoid call is made with `(latitude_deg, longitude_deg)`.
840    /// Choosing [`TerrainGeoidModel::Egm96FifteenMinute`] requires a loaded
841    /// `WW15MGH.DAC` grid and never falls back to the embedded EGM96 1-degree
842    /// grid.
843    pub fn ellipsoidal_height_m_with_model(
844        &self,
845        longitude_deg: f64,
846        latitude_deg: f64,
847        options: DtedLookupOptions,
848        geoid: TerrainGeoidModel<'_>,
849    ) -> core::result::Result<EllipsoidalHeightM, TerrainDatumError> {
850        let orthometric = self
851            .orthometric_height_m_with_options(longitude_deg, latitude_deg, options)
852            .map_err(TerrainDatumError::Terrain)?;
853        orthometric.to_ellipsoidal_height_deg(latitude_deg, longitude_deg, geoid)
854    }
855
856    fn resolve_grid(&self, longitude_deg: f64, latitude_deg: f64) -> Option<usize> {
857        for grid_idx in terrain_grid_candidates(longitude_deg, latitude_deg) {
858            if let Some(&tile_idx) = self.by_grid.get(&grid_idx) {
859                if self.tiles[tile_idx].contains(longitude_deg, latitude_deg) {
860                    return Some(tile_idx);
861                }
862            }
863        }
864        None
865    }
866
867    fn tile_payload(&self, tile: &MmapTile) -> &[u8] {
868        let start = tile.index.data_offset as usize;
869        let end = start + tile.index.data_len as usize;
870        &self.bytes.as_slice()[start..end]
871    }
872}
873
874/// Convert a DTED tile tree into canonical memory-mappable terrain store bytes.
875///
876/// Input `.dt2` files are discovered recursively below `root`, following
877/// symlinked directories and symlinked `.dt2` files. A symlinked file is treated
878/// as DTED when either the link name or the resolved target name ends in
879/// `.dt2`. Tiles are then sorted by integer tile id. DTED signed-magnitude
880/// postings are decoded once into `i16` orthometric metres. DTED negative zero
881/// and SRTM voids already encoded as zero remain zero, matching the existing
882/// lazy DTED reader.
883pub fn dted_tree_to_mmap_store(
884    root: impl AsRef<Path>,
885) -> core::result::Result<Vec<u8>, TerrainStoreError> {
886    let root = root.as_ref();
887    let mut paths = Vec::new();
888    collect_dted_tile_paths(root, &mut paths)?;
889    dted_paths_to_mmap_store(paths)
890}
891
892/// Convert an explicit DTED tile list into canonical memory-mappable terrain
893/// store bytes.
894///
895/// Each entry supplies the expected integer tile id and the DTED `.dt2` path.
896/// The converter parses each DTED header and fails if the file origin does not
897/// match the supplied id. The decoded payload, sorting, duplicate detection,
898/// alignment, and checksums are the same as [`dted_tree_to_mmap_store`].
899pub fn dted_tile_list_to_mmap_store(
900    entries: &[DtedTileListEntry],
901) -> core::result::Result<Vec<u8>, TerrainStoreError> {
902    let mut pending = Vec::with_capacity(entries.len());
903    for entry in entries {
904        pending.push(pending_tile_from_dted_path(
905            &entry.path,
906            Some(entry.tile_id),
907        )?);
908    }
909    build_store(pending)
910}
911
912/// Convert a DTED tile tree and write canonical memory-mappable terrain store
913/// bytes to `output_path`.
914///
915/// Symlinked directories and symlinked `.dt2` files below `root` are followed.
916/// A symlinked file is treated as DTED when either the link name or the resolved
917/// target name ends in `.dt2`.
918pub fn write_dted_tree_to_mmap_store(
919    root: impl AsRef<Path>,
920    output_path: impl AsRef<Path>,
921) -> core::result::Result<(), TerrainStoreError> {
922    let bytes = dted_tree_to_mmap_store(root)?;
923    let output_path = output_path.as_ref();
924    fs::write(output_path, &bytes).map_err(|err| TerrainStoreError::Io {
925        path: output_path.to_path_buf(),
926        message: err.to_string(),
927    })
928}
929
930/// Convert an explicit DTED tile list and write canonical memory-mappable
931/// terrain store bytes to `output_path`.
932pub fn write_dted_tile_list_to_mmap_store(
933    entries: &[DtedTileListEntry],
934    output_path: impl AsRef<Path>,
935) -> core::result::Result<(), TerrainStoreError> {
936    let bytes = dted_tile_list_to_mmap_store(entries)?;
937    let output_path = output_path.as_ref();
938    fs::write(output_path, &bytes).map_err(|err| TerrainStoreError::Io {
939        path: output_path.to_path_buf(),
940        message: err.to_string(),
941    })
942}
943
944/// Return an FNV-1a checksum for terrain store bytes.
945///
946/// This checksum is for deterministic local verification and is not a
947/// cryptographic digest.
948#[must_use]
949pub fn terrain_store_checksum64(bytes: &[u8]) -> u64 {
950    fnv1a64(bytes)
951}
952
953#[derive(Debug)]
954struct PendingTile {
955    lat_index: i32,
956    lon_index: i32,
957    min_latitude_deg: f64,
958    min_longitude_deg: f64,
959    max_latitude_deg: f64,
960    max_longitude_deg: f64,
961    lon_count: u32,
962    lat_count: u32,
963    data: Vec<u8>,
964    vertical_datum: VerticalDatum,
965}
966
967#[derive(Debug)]
968struct ParsedStore {
969    vertical_datum: VerticalDatum,
970    tiles: Vec<MmapTile>,
971    by_grid: HashMap<(i32, i32), usize>,
972    tile_index: Vec<TerrainStoreTileIndex>,
973    tile_ids: Vec<TerrainTileId>,
974}
975
976fn height_from_tile(
977    bytes: &[u8],
978    tile: &MmapTile,
979    longitude_deg: f64,
980    latitude_deg: f64,
981    options: DtedLookupOptions,
982) -> Result<f64> {
983    if options.interpolation == DtedInterpolation::NearestPosting {
984        return tile
985            .get_elevation(bytes, longitude_deg, latitude_deg)
986            .map(|v| v as f64);
987    }
988
989    let postings_per_deg_lon = tile.index.lon_count as usize - 1;
990    let postings_per_deg_lat = tile.index.lat_count as usize - 1;
991
992    let lon_idx = (longitude_deg - tile.index.min_longitude_deg) * postings_per_deg_lon as f64;
993    let lat_idx = (latitude_deg - tile.index.min_latitude_deg) * postings_per_deg_lat as f64;
994    let lon_lo = lon_idx.floor() as i64;
995    let lat_lo = lat_idx.floor() as i64;
996    let fx = lon_idx - lon_lo as f64;
997    let fy = lat_idx - lat_lo as f64;
998
999    let mut z = 0.0;
1000    for (di, wx) in [(0i64, 1.0 - fx), (1i64, fx)] {
1001        for (dj, wy) in [(0i64, 1.0 - fy), (1i64, fy)] {
1002            let w = wx * wy;
1003            if w == 0.0 {
1004                continue;
1005            }
1006            let posting_lon =
1007                tile.index.min_longitude_deg + (lon_lo + di) as f64 / postings_per_deg_lon as f64;
1008            let posting_lat =
1009                tile.index.min_latitude_deg + (lat_lo + dj) as f64 / postings_per_deg_lat as f64;
1010            z += w * f64::from(tile.get_elevation(bytes, posting_lon, posting_lat)?);
1011        }
1012    }
1013    Ok(z)
1014}
1015
1016fn dted_paths_to_mmap_store(
1017    mut paths: Vec<PathBuf>,
1018) -> core::result::Result<Vec<u8>, TerrainStoreError> {
1019    paths.sort();
1020
1021    let mut pending = Vec::with_capacity(paths.len());
1022    for path in paths {
1023        pending.push(pending_tile_from_dted_path(&path, None)?);
1024    }
1025    build_store(pending)
1026}
1027
1028fn pending_tile_from_dted_path(
1029    path: &Path,
1030    expected_id: Option<TerrainTileId>,
1031) -> core::result::Result<PendingTile, TerrainStoreError> {
1032    let tile = DtedTile::from_path(path).map_err(|reason| TerrainStoreError::Parse {
1033        reason: format!("{}: {reason}", path.display()),
1034    })?;
1035    let decoded = tile
1036        .decoded_postings_lon_major()
1037        .map_err(|reason| TerrainStoreError::Parse {
1038            reason: format!("{}: {reason}", path.display()),
1039        })?;
1040    let mut data = Vec::with_capacity(decoded.len() * 2);
1041    for posting in decoded {
1042        data.extend_from_slice(&posting.to_le_bytes());
1043    }
1044    let lat_index = tile.origin_latitude().floor() as i32;
1045    let lon_index = tile.origin_longitude().floor() as i32;
1046    let found = TerrainTileId::new(lat_index, lon_index);
1047    if let Some(expected) = expected_id {
1048        if expected != found {
1049            return Err(TerrainStoreError::TileIdMismatch {
1050                path: path.to_path_buf(),
1051                expected,
1052                found,
1053            });
1054        }
1055    }
1056    Ok(PendingTile {
1057        lat_index,
1058        lon_index,
1059        min_latitude_deg: tile.origin_latitude(),
1060        min_longitude_deg: tile.origin_longitude(),
1061        max_latitude_deg: tile.origin_latitude() + 1.0,
1062        max_longitude_deg: tile.origin_longitude() + 1.0,
1063        lon_count: u32::try_from(tile.lon_count()).map_err(|_| TerrainStoreError::Parse {
1064            reason: format!("{} longitude count exceeds u32", path.display()),
1065        })?,
1066        lat_count: u32::try_from(tile.lat_count()).map_err(|_| TerrainStoreError::Parse {
1067            reason: format!("{} latitude count exceeds u32", path.display()),
1068        })?,
1069        data,
1070        vertical_datum: VerticalDatum::Egm96MslOrthometric,
1071    })
1072}
1073
1074fn collect_dted_tile_paths(
1075    root: &Path,
1076    out: &mut Vec<PathBuf>,
1077) -> core::result::Result<(), TerrainStoreError> {
1078    let mut visited_dirs = HashSet::new();
1079    collect_dted_tile_paths_inner(root, out, &mut visited_dirs)
1080}
1081
1082fn collect_dted_tile_paths_inner(
1083    path: &Path,
1084    out: &mut Vec<PathBuf>,
1085    visited_dirs: &mut HashSet<PathBuf>,
1086) -> core::result::Result<(), TerrainStoreError> {
1087    let metadata = fs::metadata(path).map_err(|err| TerrainStoreError::Io {
1088        path: path.to_path_buf(),
1089        message: err.to_string(),
1090    })?;
1091
1092    if metadata.is_dir() {
1093        let canonical = fs::canonicalize(path).map_err(|err| TerrainStoreError::Io {
1094            path: path.to_path_buf(),
1095            message: err.to_string(),
1096        })?;
1097        if !visited_dirs.insert(canonical) {
1098            return Ok(());
1099        }
1100        let entries = fs::read_dir(path).map_err(|err| TerrainStoreError::Io {
1101            path: path.to_path_buf(),
1102            message: err.to_string(),
1103        })?;
1104        for entry in entries {
1105            let entry = entry.map_err(|err| TerrainStoreError::Io {
1106                path: path.to_path_buf(),
1107                message: err.to_string(),
1108            })?;
1109            collect_dted_tile_paths_inner(&entry.path(), out, visited_dirs)?;
1110        }
1111    } else if metadata.is_file() && is_dted_tile_source(path)? {
1112        out.push(path.to_path_buf());
1113    }
1114    Ok(())
1115}
1116
1117fn is_dted_tile_source(path: &Path) -> core::result::Result<bool, TerrainStoreError> {
1118    if is_dted_tile_path(path) {
1119        return Ok(true);
1120    }
1121
1122    let canonical = fs::canonicalize(path).map_err(|err| TerrainStoreError::Io {
1123        path: path.to_path_buf(),
1124        message: err.to_string(),
1125    })?;
1126    Ok(is_dted_tile_path(&canonical))
1127}
1128
1129fn is_dted_tile_path(path: &Path) -> bool {
1130    path.file_name()
1131        .and_then(|name| name.to_str())
1132        .is_some_and(|name| name.ends_with(".dt2"))
1133}
1134
1135fn parse_store(bytes: &[u8]) -> core::result::Result<ParsedStore, TerrainStoreError> {
1136    if bytes.len() < STORE_HEADER_LEN {
1137        return Err(TerrainStoreError::Parse {
1138            reason: format!(
1139                "store has {} bytes but needs at least {STORE_HEADER_LEN}",
1140                bytes.len()
1141            ),
1142        });
1143    }
1144    if &bytes[..STORE_MAGIC.len()] != STORE_MAGIC {
1145        return Err(TerrainStoreError::Parse {
1146            reason: "missing terrain store magic".to_string(),
1147        });
1148    }
1149    let version = read_u16(bytes, HEADER_VERSION_OFFSET)?;
1150    if version != STORE_VERSION {
1151        return Err(TerrainStoreError::UnsupportedVersion { version });
1152    }
1153    ensure_zero(bytes, 11, 12, "header reserved byte")?;
1154    ensure_zero(bytes, 40, STORE_HEADER_LEN, "header reserved bytes")?;
1155
1156    let vertical_datum = VerticalDatum::from_tag(bytes[HEADER_DATUM_OFFSET])?;
1157    let tile_count = read_u32(bytes, HEADER_TILE_COUNT_OFFSET)? as usize;
1158    let index_offset = read_u64(bytes, HEADER_INDEX_OFFSET_OFFSET)? as usize;
1159    let data_offset = read_u64(bytes, HEADER_DATA_OFFSET_OFFSET)? as usize;
1160    let total_len = read_u64(bytes, HEADER_TOTAL_LEN_OFFSET)? as usize;
1161    if total_len != bytes.len() {
1162        return Err(TerrainStoreError::Parse {
1163            reason: format!(
1164                "header total length {total_len} does not match {}",
1165                bytes.len()
1166            ),
1167        });
1168    }
1169    if index_offset != STORE_HEADER_LEN {
1170        return Err(TerrainStoreError::Parse {
1171            reason: format!("index offset must be {STORE_HEADER_LEN}, got {index_offset}"),
1172        });
1173    }
1174
1175    let index_len = tile_count
1176        .checked_mul(STORE_INDEX_RECORD_LEN)
1177        .ok_or_else(|| TerrainStoreError::Parse {
1178            reason: "tile index length overflows usize".to_string(),
1179        })?;
1180    let index_end =
1181        index_offset
1182            .checked_add(index_len)
1183            .ok_or_else(|| TerrainStoreError::Parse {
1184                reason: "tile index end overflows usize".to_string(),
1185            })?;
1186    if index_end > bytes.len() {
1187        return Err(TerrainStoreError::Parse {
1188            reason: "tile index extends past store length".to_string(),
1189        });
1190    }
1191    let expected_data_offset = align_up(index_end, STORE_ALIGNMENT)?;
1192    if data_offset != expected_data_offset {
1193        return Err(TerrainStoreError::Parse {
1194            reason: format!("data offset must be {expected_data_offset}, got {data_offset}"),
1195        });
1196    }
1197    ensure_zero(bytes, index_end, data_offset, "index padding")?;
1198
1199    let mut tiles = Vec::with_capacity(tile_count);
1200    let mut tile_index = Vec::with_capacity(tile_count);
1201    let mut tile_ids = Vec::with_capacity(tile_count);
1202    let mut by_grid = HashMap::with_capacity(tile_count);
1203    let mut previous_id = None;
1204    let mut expected_next = data_offset;
1205
1206    for idx in 0..tile_count {
1207        let record_offset = index_offset + idx * STORE_INDEX_RECORD_LEN;
1208        let record = &bytes[record_offset..record_offset + STORE_INDEX_RECORD_LEN];
1209        let lat_index = read_i32(record, INDEX_LAT_OFFSET)?;
1210        let lon_index = read_i32(record, INDEX_LON_OFFSET)?;
1211        let tile_id = (lat_index, lon_index);
1212        if previous_id.is_some_and(|previous| tile_id <= previous) {
1213            return Err(TerrainStoreError::Parse {
1214                reason: "tile index records are not strictly sorted".to_string(),
1215            });
1216        }
1217        previous_id = Some(tile_id);
1218
1219        let lon_count = read_u32(record, INDEX_LON_COUNT_OFFSET)?;
1220        let lat_count = read_u32(record, INDEX_LAT_COUNT_OFFSET)?;
1221        if lon_count < 2 || lat_count < 2 {
1222            return Err(TerrainStoreError::Parse {
1223                reason: format!(
1224                    "tile ({lat_index},{lon_index}) has invalid dimensions lon_count={lon_count} lat_count={lat_count}"
1225                ),
1226            });
1227        }
1228        let offset = read_u64(record, INDEX_DATA_OFFSET_OFFSET)? as usize;
1229        let data_len = read_u64(record, INDEX_DATA_LEN_OFFSET)? as usize;
1230        let expected_len = (lon_count as usize)
1231            .checked_mul(lat_count as usize)
1232            .and_then(|count| count.checked_mul(2))
1233            .ok_or_else(|| TerrainStoreError::Parse {
1234                reason: format!("tile ({lat_index},{lon_index}) data length overflows usize"),
1235            })?;
1236        if data_len != expected_len {
1237            return Err(TerrainStoreError::Parse {
1238                reason: format!(
1239                    "tile ({lat_index},{lon_index}) data length must be {expected_len}, got {data_len}"
1240                ),
1241            });
1242        }
1243
1244        let expected_offset = align_up(expected_next, STORE_ALIGNMENT)?;
1245        ensure_zero(bytes, expected_next, expected_offset, "tile padding")?;
1246        if offset != expected_offset {
1247            return Err(TerrainStoreError::Parse {
1248                reason: format!(
1249                    "tile ({lat_index},{lon_index}) data offset must be {expected_offset}, got {offset}"
1250                ),
1251            });
1252        }
1253        let end = offset
1254            .checked_add(data_len)
1255            .ok_or_else(|| TerrainStoreError::Parse {
1256                reason: format!("tile ({lat_index},{lon_index}) data end overflows usize"),
1257            })?;
1258        if end > bytes.len() {
1259            return Err(TerrainStoreError::Parse {
1260                reason: format!("tile ({lat_index},{lon_index}) data extends past store length"),
1261            });
1262        }
1263
1264        let checksum64 = read_u64(record, INDEX_CHECKSUM_OFFSET)?;
1265        let found = fnv1a64(&bytes[offset..end]);
1266        if found != checksum64 {
1267            return Err(TerrainStoreError::Checksum {
1268                lat_index,
1269                lon_index,
1270                expected: checksum64,
1271                found,
1272            });
1273        }
1274
1275        let min_latitude_deg = read_f64(record, INDEX_MIN_LAT_OFFSET)?;
1276        let min_longitude_deg = read_f64(record, INDEX_MIN_LON_OFFSET)?;
1277        let max_latitude_deg = read_f64(record, INDEX_MAX_LAT_OFFSET)?;
1278        let max_longitude_deg = read_f64(record, INDEX_MAX_LON_OFFSET)?;
1279        for (field, value) in [
1280            ("min_latitude_deg", min_latitude_deg),
1281            ("min_longitude_deg", min_longitude_deg),
1282            ("max_latitude_deg", max_latitude_deg),
1283            ("max_longitude_deg", max_longitude_deg),
1284        ] {
1285            if !value.is_finite() {
1286                return Err(TerrainStoreError::Parse {
1287                    reason: format!("tile ({lat_index},{lon_index}) {field} is not finite"),
1288                });
1289            }
1290        }
1291        let tile_datum = VerticalDatum::from_tag(record[INDEX_DATUM_OFFSET])?;
1292        if tile_datum != vertical_datum {
1293            return Err(TerrainStoreError::Parse {
1294                reason: format!("tile ({lat_index},{lon_index}) datum differs from header"),
1295            });
1296        }
1297        ensure_zero(
1298            record,
1299            INDEX_DATUM_OFFSET + 1,
1300            STORE_INDEX_RECORD_LEN,
1301            "tile index reserved bytes",
1302        )?;
1303
1304        let index = TerrainStoreTileIndex {
1305            lat_index,
1306            lon_index,
1307            min_longitude_deg,
1308            min_latitude_deg,
1309            max_longitude_deg,
1310            max_latitude_deg,
1311            lon_count,
1312            lat_count,
1313            data_offset: offset as u64,
1314            data_len: data_len as u64,
1315            checksum64,
1316            vertical_datum: tile_datum,
1317        };
1318        by_grid.insert(tile_id, tiles.len());
1319        tiles.push(MmapTile { index });
1320        tile_index.push(index);
1321        tile_ids.push(TerrainTileId::new(lat_index, lon_index));
1322        expected_next = end;
1323    }
1324
1325    if expected_next != bytes.len() {
1326        return Err(TerrainStoreError::Parse {
1327            reason: format!(
1328                "store has trailing bytes: expected length {expected_next}, got {}",
1329                bytes.len()
1330            ),
1331        });
1332    }
1333
1334    Ok(ParsedStore {
1335        vertical_datum,
1336        tiles,
1337        by_grid,
1338        tile_index,
1339        tile_ids,
1340    })
1341}
1342
1343fn missing_terrain_tile(longitude_deg: f64, latitude_deg: f64) -> Error {
1344    let (lat_index, lon_index) = terrain::terrain_grid(longitude_deg, latitude_deg);
1345    Error::MissingTerrainTile {
1346        lat_index,
1347        lon_index,
1348    }
1349}
1350
1351fn build_store(mut tiles: Vec<PendingTile>) -> core::result::Result<Vec<u8>, TerrainStoreError> {
1352    tiles.sort_by_key(|tile| (tile.lat_index, tile.lon_index));
1353    for pair in tiles.windows(2) {
1354        if (pair[0].lat_index, pair[0].lon_index) == (pair[1].lat_index, pair[1].lon_index) {
1355            return Err(TerrainStoreError::DuplicateTile {
1356                lat_index: pair[0].lat_index,
1357                lon_index: pair[0].lon_index,
1358            });
1359        }
1360    }
1361
1362    let index_end = STORE_HEADER_LEN
1363        .checked_add(
1364            tiles
1365                .len()
1366                .checked_mul(STORE_INDEX_RECORD_LEN)
1367                .ok_or_else(|| TerrainStoreError::Parse {
1368                    reason: "tile index length overflows usize".to_string(),
1369                })?,
1370        )
1371        .ok_or_else(|| TerrainStoreError::Parse {
1372            reason: "tile index end overflows usize".to_string(),
1373        })?;
1374    let data_offset = align_up(index_end, STORE_ALIGNMENT)?;
1375    let mut offsets = Vec::with_capacity(tiles.len());
1376    let mut cursor = data_offset;
1377    for tile in &tiles {
1378        cursor = align_up(cursor, STORE_ALIGNMENT)?;
1379        offsets.push(cursor);
1380        cursor = cursor
1381            .checked_add(tile.data.len())
1382            .ok_or_else(|| TerrainStoreError::Parse {
1383                reason: "store length overflows usize".to_string(),
1384            })?;
1385    }
1386
1387    let mut out = vec![0u8; cursor];
1388    out[..STORE_MAGIC.len()].copy_from_slice(STORE_MAGIC);
1389    write_u16(&mut out, HEADER_VERSION_OFFSET, STORE_VERSION);
1390    out[HEADER_DATUM_OFFSET] = VerticalDatum::Egm96MslOrthometric.tag();
1391    write_u32(
1392        &mut out,
1393        HEADER_TILE_COUNT_OFFSET,
1394        u32::try_from(tiles.len()).map_err(|_| TerrainStoreError::Parse {
1395            reason: "tile count exceeds u32".to_string(),
1396        })?,
1397    );
1398    write_u64(
1399        &mut out,
1400        HEADER_INDEX_OFFSET_OFFSET,
1401        STORE_HEADER_LEN as u64,
1402    );
1403    write_u64(&mut out, HEADER_DATA_OFFSET_OFFSET, data_offset as u64);
1404    write_u64(&mut out, HEADER_TOTAL_LEN_OFFSET, cursor as u64);
1405
1406    for (idx, tile) in tiles.iter().enumerate() {
1407        let record_offset = STORE_HEADER_LEN + idx * STORE_INDEX_RECORD_LEN;
1408        let offset = offsets[idx];
1409        let data_len = tile.data.len();
1410        let expected_len = (tile.lon_count as usize)
1411            .checked_mul(tile.lat_count as usize)
1412            .and_then(|count| count.checked_mul(2))
1413            .ok_or_else(|| TerrainStoreError::Parse {
1414                reason: format!(
1415                    "tile ({},{}) data length overflows usize",
1416                    tile.lat_index, tile.lon_index
1417                ),
1418            })?;
1419        if data_len != expected_len {
1420            return Err(TerrainStoreError::Parse {
1421                reason: format!(
1422                    "tile ({},{}) data length must be {expected_len}, got {data_len}",
1423                    tile.lat_index, tile.lon_index
1424                ),
1425            });
1426        }
1427
1428        let record = &mut out[record_offset..record_offset + STORE_INDEX_RECORD_LEN];
1429        write_i32(record, INDEX_LAT_OFFSET, tile.lat_index);
1430        write_i32(record, INDEX_LON_OFFSET, tile.lon_index);
1431        write_u32(record, INDEX_LON_COUNT_OFFSET, tile.lon_count);
1432        write_u32(record, INDEX_LAT_COUNT_OFFSET, tile.lat_count);
1433        write_u64(record, INDEX_DATA_OFFSET_OFFSET, offset as u64);
1434        write_u64(record, INDEX_DATA_LEN_OFFSET, data_len as u64);
1435        write_u64(record, INDEX_CHECKSUM_OFFSET, fnv1a64(&tile.data));
1436        write_f64(record, INDEX_MIN_LAT_OFFSET, tile.min_latitude_deg);
1437        write_f64(record, INDEX_MIN_LON_OFFSET, tile.min_longitude_deg);
1438        write_f64(record, INDEX_MAX_LAT_OFFSET, tile.max_latitude_deg);
1439        write_f64(record, INDEX_MAX_LON_OFFSET, tile.max_longitude_deg);
1440        record[INDEX_DATUM_OFFSET] = tile.vertical_datum.tag();
1441        out[offset..offset + data_len].copy_from_slice(&tile.data);
1442    }
1443
1444    Ok(out)
1445}
1446
1447fn align_up(value: usize, alignment: usize) -> core::result::Result<usize, TerrainStoreError> {
1448    let rem = value % alignment;
1449    if rem == 0 {
1450        Ok(value)
1451    } else {
1452        value
1453            .checked_add(alignment - rem)
1454            .ok_or_else(|| TerrainStoreError::Parse {
1455                reason: "aligned offset overflows usize".to_string(),
1456            })
1457    }
1458}
1459
1460fn ensure_zero(
1461    bytes: &[u8],
1462    start: usize,
1463    end: usize,
1464    context: &str,
1465) -> core::result::Result<(), TerrainStoreError> {
1466    if start > end || end > bytes.len() {
1467        return Err(TerrainStoreError::Parse {
1468            reason: format!("{context} range is out of bounds"),
1469        });
1470    }
1471    if bytes[start..end].iter().any(|&byte| byte != 0) {
1472        return Err(TerrainStoreError::Parse {
1473            reason: format!("{context} must be zero-filled"),
1474        });
1475    }
1476    Ok(())
1477}
1478
1479fn fnv1a64(bytes: &[u8]) -> u64 {
1480    bytes.iter().fold(FNV_OFFSET_BASIS, |hash, byte| {
1481        (hash ^ u64::from(*byte)).wrapping_mul(FNV_PRIME)
1482    })
1483}
1484
1485fn read_u16(bytes: &[u8], offset: usize) -> core::result::Result<u16, TerrainStoreError> {
1486    Ok(u16::from_le_bytes(read_array(bytes, offset)?))
1487}
1488
1489fn read_u32(bytes: &[u8], offset: usize) -> core::result::Result<u32, TerrainStoreError> {
1490    Ok(u32::from_le_bytes(read_array(bytes, offset)?))
1491}
1492
1493fn read_i32(bytes: &[u8], offset: usize) -> core::result::Result<i32, TerrainStoreError> {
1494    Ok(i32::from_le_bytes(read_array(bytes, offset)?))
1495}
1496
1497fn read_u64(bytes: &[u8], offset: usize) -> core::result::Result<u64, TerrainStoreError> {
1498    Ok(u64::from_le_bytes(read_array(bytes, offset)?))
1499}
1500
1501fn read_f64(bytes: &[u8], offset: usize) -> core::result::Result<f64, TerrainStoreError> {
1502    Ok(f64::from_le_bytes(read_array(bytes, offset)?))
1503}
1504
1505fn read_array<const N: usize>(
1506    bytes: &[u8],
1507    offset: usize,
1508) -> core::result::Result<[u8; N], TerrainStoreError> {
1509    let end = offset
1510        .checked_add(N)
1511        .ok_or_else(|| TerrainStoreError::Parse {
1512            reason: "numeric field offset overflows usize".to_string(),
1513        })?;
1514    let slice = bytes
1515        .get(offset..end)
1516        .ok_or_else(|| TerrainStoreError::Parse {
1517            reason: "numeric field extends past record".to_string(),
1518        })?;
1519    slice.try_into().map_err(|_| TerrainStoreError::Parse {
1520        reason: "numeric field has wrong length".to_string(),
1521    })
1522}
1523
1524fn write_u16(bytes: &mut [u8], offset: usize, value: u16) {
1525    bytes[offset..offset + 2].copy_from_slice(&value.to_le_bytes());
1526}
1527
1528fn write_u32(bytes: &mut [u8], offset: usize, value: u32) {
1529    bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
1530}
1531
1532fn write_i32(bytes: &mut [u8], offset: usize, value: i32) {
1533    bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
1534}
1535
1536fn write_u64(bytes: &mut [u8], offset: usize, value: u64) {
1537    bytes[offset..offset + 8].copy_from_slice(&value.to_le_bytes());
1538}
1539
1540fn write_f64(bytes: &mut [u8], offset: usize, value: f64) {
1541    bytes[offset..offset + 8].copy_from_slice(&value.to_le_bytes());
1542}