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;
15pub use crate::artifact_bytes::DigestProvenance;
16use std::collections::{HashMap, HashSet};
17use std::fs;
18use std::path::{Path, PathBuf};
19
20use crate::geoid::{egm96_undulation, GeoidError, GeoidGrid};
21use crate::terrain::{
22    self, terrain_grid_candidates, validate_lookup_coordinates, DtedInterpolation,
23    DtedLookupOptions, DtedTile,
24};
25use crate::{Error, Result};
26
27const STORE_MAGIC: &[u8; 8] = b"TMMAP001";
28const STORE_VERSION: u16 = 1;
29const STORE_ALIGNMENT: usize = 4096;
30const STORE_HEADER_LEN: usize = 64;
31const STORE_INDEX_RECORD_LEN: usize = 80;
32const HEADER_VERSION_OFFSET: usize = 8;
33const HEADER_DATUM_OFFSET: usize = 10;
34const HEADER_TILE_COUNT_OFFSET: usize = 12;
35const HEADER_INDEX_OFFSET_OFFSET: usize = 16;
36const HEADER_DATA_OFFSET_OFFSET: usize = 24;
37const HEADER_TOTAL_LEN_OFFSET: usize = 32;
38const INDEX_LAT_OFFSET: usize = 0;
39const INDEX_LON_OFFSET: usize = 4;
40const INDEX_LON_COUNT_OFFSET: usize = 8;
41const INDEX_LAT_COUNT_OFFSET: usize = 12;
42const INDEX_DATA_OFFSET_OFFSET: usize = 16;
43const INDEX_DATA_LEN_OFFSET: usize = 24;
44const INDEX_CHECKSUM_OFFSET: usize = 32;
45const INDEX_MIN_LAT_OFFSET: usize = 40;
46const INDEX_MIN_LON_OFFSET: usize = 48;
47const INDEX_MAX_LAT_OFFSET: usize = 56;
48const INDEX_MAX_LON_OFFSET: usize = 64;
49const INDEX_DATUM_OFFSET: usize = 72;
50const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
51const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
52const EGM96_DAC_REMEDIATION: &str =
53    "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";
54
55/// Vertical datum carried by terrain store tile index records.
56#[derive(Clone, Copy, Debug, PartialEq, Eq)]
57pub enum VerticalDatum {
58    /// Orthometric height `H` in metres above the EGM96 mean sea level geoid.
59    Egm96MslOrthometric,
60}
61
62impl VerticalDatum {
63    fn tag(self) -> u8 {
64        match self {
65            Self::Egm96MslOrthometric => 1,
66        }
67    }
68
69    fn from_tag(tag: u8) -> core::result::Result<Self, TerrainStoreError> {
70        match tag {
71            1 => Ok(Self::Egm96MslOrthometric),
72            other => Err(TerrainStoreError::UnsupportedDatum { tag: other }),
73        }
74    }
75}
76
77/// Orthometric height `H` in metres above the EGM96 mean sea level geoid.
78///
79/// DTED/SRTM terrain postings use this datum. Convert to ellipsoidal height
80/// only through [`Self::to_ellipsoidal_height_deg`] or
81/// [`Self::to_ellipsoidal_height_rad`], which require a pinned geoid tier.
82#[derive(Clone, Copy, Debug, PartialEq)]
83pub struct OrthometricHeightM {
84    /// Orthometric height `H` in metres.
85    pub value_m: f64,
86}
87
88impl OrthometricHeightM {
89    /// Build an orthometric height `H` in metres.
90    #[must_use]
91    pub const fn new(value_m: f64) -> Self {
92        Self { value_m }
93    }
94
95    /// Return the orthometric height `H` in metres.
96    #[must_use]
97    pub const fn metres(self) -> f64 {
98        self.value_m
99    }
100
101    /// Convert this orthometric height to ellipsoidal height `h = H + N`.
102    ///
103    /// Inputs are geodetic `(latitude_deg, longitude_deg)`, matching the geoid
104    /// module's axis order. Terrain lookup APIs use `(longitude_deg,
105    /// latitude_deg)`, so call sites should pass the axes deliberately.
106    ///
107    /// [`TerrainGeoidModel::Egm96OneDegree`] uses the embedded EGM96 1-degree
108    /// grid. It agrees with the full EGM96 15-arcminute grid to about 0.4 m RMS,
109    /// so byte-identical terrain heights do not imply byte-identical
110    /// ellipsoidal heights across geoid tiers.
111    pub fn to_ellipsoidal_height_deg(
112        self,
113        latitude_deg: f64,
114        longitude_deg: f64,
115        geoid: TerrainGeoidModel<'_>,
116    ) -> core::result::Result<EllipsoidalHeightM, TerrainDatumError> {
117        Ok(EllipsoidalHeightM::new(
118            self.value_m + geoid.undulation_deg(latitude_deg, longitude_deg),
119        ))
120    }
121
122    /// Convert this orthometric height to ellipsoidal height `h = H + N`.
123    ///
124    /// Inputs are geodetic `(latitude_rad, longitude_rad)`, matching the geoid
125    /// module's axis order. [`TerrainGeoidModel::Egm96OneDegree`] uses the
126    /// embedded EGM96 1-degree grid. It agrees with the full EGM96
127    /// 15-arcminute grid to about 0.4 m RMS, so byte-identical terrain heights
128    /// do not imply byte-identical ellipsoidal heights across geoid tiers.
129    pub fn to_ellipsoidal_height_rad(
130        self,
131        latitude_rad: f64,
132        longitude_rad: f64,
133        geoid: TerrainGeoidModel<'_>,
134    ) -> core::result::Result<EllipsoidalHeightM, TerrainDatumError> {
135        Ok(EllipsoidalHeightM::new(
136            self.value_m + geoid.undulation_rad(latitude_rad, longitude_rad),
137        ))
138    }
139}
140
141/// Ellipsoidal height `h` in metres above the WGS84 reference ellipsoid.
142#[derive(Clone, Copy, Debug, PartialEq)]
143pub struct EllipsoidalHeightM {
144    /// Ellipsoidal height `h` in metres.
145    pub value_m: f64,
146}
147
148impl EllipsoidalHeightM {
149    /// Build an ellipsoidal height `h` in metres.
150    #[must_use]
151    pub const fn new(value_m: f64) -> Self {
152        Self { value_m }
153    }
154
155    /// Return the ellipsoidal height `h` in metres.
156    #[must_use]
157    pub const fn metres(self) -> f64 {
158        self.value_m
159    }
160}
161
162/// Loaded EGM96 15-arcminute geoid grid for explicit terrain datum conversion.
163///
164/// This type never falls back to the embedded 1-degree grid. A missing
165/// `WW15MGH.DAC` file returns [`TerrainDatumError::MissingEgm96Dac`].
166#[derive(Clone, Debug, PartialEq)]
167pub struct Egm96FifteenMinuteGeoid {
168    grid: GeoidGrid,
169}
170
171impl Egm96FifteenMinuteGeoid {
172    /// Load `WW15MGH.DAC` bytes as an EGM96 15-arcminute geoid grid.
173    pub fn from_ww15mgh_dac_bytes(bytes: &[u8]) -> core::result::Result<Self, TerrainDatumError> {
174        let grid = GeoidGrid::from_egm96_dac(bytes).map_err(TerrainDatumError::Geoid)?;
175        Ok(Self { grid })
176    }
177
178    /// Read and load `WW15MGH.DAC` from disk as an EGM96 15-arcminute geoid
179    /// grid.
180    ///
181    /// If the file is absent, this returns
182    /// [`TerrainDatumError::MissingEgm96Dac`] with a remediation string naming
183    /// the required grid and loader. It does not fall back to the embedded
184    /// EGM96 1-degree grid.
185    pub fn from_ww15mgh_dac_path(
186        path: impl AsRef<Path>,
187    ) -> core::result::Result<Self, TerrainDatumError> {
188        let path = path.as_ref();
189        let bytes = match fs::read(path) {
190            Ok(bytes) => bytes,
191            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
192                return Err(TerrainDatumError::MissingEgm96Dac {
193                    path: path.to_path_buf(),
194                    remediation: EGM96_DAC_REMEDIATION,
195                });
196            }
197            Err(err) => {
198                return Err(TerrainDatumError::Io {
199                    path: path.to_path_buf(),
200                    message: err.to_string(),
201                });
202            }
203        };
204        Self::from_ww15mgh_dac_bytes(&bytes)
205    }
206
207    /// Borrow the loaded EGM96 15-arcminute geoid grid.
208    #[must_use]
209    pub const fn grid(&self) -> &GeoidGrid {
210        &self.grid
211    }
212}
213
214/// Geoid tier used to convert terrain orthometric height `H` to ellipsoidal
215/// height `h`.
216#[derive(Clone, Copy, Debug)]
217pub enum TerrainGeoidModel<'a> {
218    /// Embedded EGM96 1-degree grid, always available in-process.
219    ///
220    /// This tier agrees with the full EGM96 15-arcminute grid to about 0.4 m RMS.
221    /// It is the zero-setup path for `h = H + N` terrain conversion.
222    Egm96OneDegree,
223    /// Caller-supplied EGM96 15-arcminute `WW15MGH.DAC` grid.
224    ///
225    /// Build this with [`Egm96FifteenMinuteGeoid::from_ww15mgh_dac_path`] or
226    /// [`Egm96FifteenMinuteGeoid::from_ww15mgh_dac_bytes`]. Missing files fail
227    /// closed with [`TerrainDatumError::MissingEgm96Dac`].
228    Egm96FifteenMinute(&'a Egm96FifteenMinuteGeoid),
229}
230
231impl TerrainGeoidModel<'_> {
232    fn undulation_deg(self, latitude_deg: f64, longitude_deg: f64) -> f64 {
233        match self {
234            Self::Egm96OneDegree => {
235                egm96_undulation(latitude_deg.to_radians(), longitude_deg.to_radians())
236            }
237            Self::Egm96FifteenMinute(grid) => grid.grid.undulation_deg(latitude_deg, longitude_deg),
238        }
239    }
240
241    fn undulation_rad(self, latitude_rad: f64, longitude_rad: f64) -> f64 {
242        match self {
243            Self::Egm96OneDegree => egm96_undulation(latitude_rad, longitude_rad),
244            Self::Egm96FifteenMinute(grid) => grid.grid.undulation_rad(latitude_rad, longitude_rad),
245        }
246    }
247}
248
249/// Errors from vertical-datum conversion and optional geoid-grid loading.
250#[derive(Debug, Clone, PartialEq)]
251pub enum TerrainDatumError {
252    /// Terrain lookup failed before datum conversion.
253    Terrain(Error),
254    /// A geoid grid could not be parsed.
255    Geoid(GeoidError),
256    /// Reading a geoid grid failed for a reason other than absence.
257    Io {
258        /// Path that could not be read.
259        path: PathBuf,
260        /// I/O error text.
261        message: String,
262    },
263    /// The EGM96 15-arcminute `WW15MGH.DAC` grid was requested but is absent.
264    MissingEgm96Dac {
265        /// Path that was requested.
266        path: PathBuf,
267        /// Remediation text naming the required grid and loader.
268        remediation: &'static str,
269    },
270}
271
272impl core::fmt::Display for TerrainDatumError {
273    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
274        match self {
275            Self::Terrain(err) => write!(f, "terrain lookup failed: {err}"),
276            Self::Geoid(err) => write!(f, "geoid grid failed: {err}"),
277            Self::Io { path, message } => {
278                write!(f, "{} could not be read: {message}", path.display())
279            }
280            Self::MissingEgm96Dac { path, remediation } => {
281                write!(f, "{} is missing; {remediation}", path.display())
282            }
283        }
284    }
285}
286
287impl std::error::Error for TerrainDatumError {}
288
289impl From<Error> for TerrainDatumError {
290    fn from(value: Error) -> Self {
291        Self::Terrain(value)
292    }
293}
294
295/// Metadata for one tile index record in a memory-mappable terrain store.
296#[derive(Clone, Copy, Debug, PartialEq)]
297pub struct TerrainStoreTileIndex {
298    /// Integer latitude tile id, e.g. `36` for a tile covering `36..37` degrees.
299    pub lat_index: i32,
300    /// Integer longitude tile id, e.g. `-107` for a tile covering
301    /// `-107..-106` degrees.
302    pub lon_index: i32,
303    /// Western edge longitude in degrees.
304    pub min_longitude_deg: f64,
305    /// Southern edge latitude in degrees.
306    pub min_latitude_deg: f64,
307    /// Eastern edge longitude in degrees.
308    pub max_longitude_deg: f64,
309    /// Northern edge latitude in degrees.
310    pub max_latitude_deg: f64,
311    /// Number of longitude postings.
312    pub lon_count: u32,
313    /// Number of latitude postings.
314    pub lat_count: u32,
315    /// Byte offset of this tile's posting payload in the store.
316    pub data_offset: u64,
317    /// Byte length of this tile's posting payload in the store.
318    pub data_len: u64,
319    /// FNV-1a checksum of this tile's posting payload bytes.
320    pub checksum64: u64,
321    /// Vertical datum for the tile's posting payload.
322    pub vertical_datum: VerticalDatum,
323}
324
325/// Integer terrain tile id used by DTED and terrain-store accessors.
326#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
327pub struct TerrainTileId {
328    /// Integer latitude tile id, e.g. `36` for a tile covering `36..37` degrees.
329    pub lat_index: i32,
330    /// Integer longitude tile id, e.g. `-107` for a tile covering
331    /// `-107..-106` degrees.
332    pub lon_index: i32,
333}
334
335impl TerrainTileId {
336    /// Build an integer terrain tile id.
337    #[must_use]
338    pub const fn new(lat_index: i32, lon_index: i32) -> Self {
339        Self {
340            lat_index,
341            lon_index,
342        }
343    }
344}
345
346/// One explicit DTED tile source for list-based terrain-store conversion.
347#[derive(Clone, Debug, PartialEq, Eq)]
348pub struct DtedTileListEntry {
349    /// Expected integer tile id for `path`.
350    pub tile_id: TerrainTileId,
351    /// Path to the DTED `.dt2` tile bytes.
352    pub path: PathBuf,
353}
354
355impl DtedTileListEntry {
356    /// Build a tile-list entry from a tile id and DTED path.
357    #[must_use]
358    pub fn new(tile_id: TerrainTileId, path: impl Into<PathBuf>) -> Self {
359        Self {
360            tile_id,
361            path: path.into(),
362        }
363    }
364
365    /// Build a tile-list entry from integer tile indices and a DTED path.
366    #[must_use]
367    pub fn from_indices(lat_index: i32, lon_index: i32, path: impl Into<PathBuf>) -> Self {
368        Self::new(TerrainTileId::new(lat_index, lon_index), path)
369    }
370}
371
372/// Errors from terrain store conversion, serialization, and parsing.
373#[derive(Debug, Clone, PartialEq, Eq)]
374pub enum TerrainStoreError {
375    /// File or directory I/O failed.
376    Io {
377        /// Path being accessed.
378        path: PathBuf,
379        /// I/O error text.
380        message: String,
381    },
382    /// DTED or terrain store bytes could not be parsed.
383    Parse {
384        /// Human-readable parse reason.
385        reason: String,
386    },
387    /// The terrain store version is not supported.
388    UnsupportedVersion {
389        /// Version tag found in the store header.
390        version: u16,
391    },
392    /// The terrain store datum tag is not supported.
393    UnsupportedDatum {
394        /// Datum tag found in the store header or tile index.
395        tag: u8,
396    },
397    /// Two input DTED files resolved to the same integer tile id.
398    DuplicateTile {
399        /// Latitude tile id.
400        lat_index: i32,
401        /// Longitude tile id.
402        lon_index: i32,
403    },
404    /// A list-builder entry's supplied id did not match the DTED file origin.
405    TileIdMismatch {
406        /// Path whose parsed DTED origin did not match the supplied id.
407        path: PathBuf,
408        /// Expected tile id supplied by the caller.
409        expected: TerrainTileId,
410        /// Tile id parsed from the DTED file.
411        found: TerrainTileId,
412    },
413    /// A tile payload checksum did not match its index record.
414    Checksum {
415        /// Latitude tile id.
416        lat_index: i32,
417        /// Longitude tile id.
418        lon_index: i32,
419        /// Checksum stored in the index record.
420        expected: u64,
421        /// Checksum computed from the posting payload.
422        found: u64,
423    },
424    /// An attested full-store checksum did not match the bytes opened.
425    AttestedChecksumMismatch {
426        /// Checksum asserted by the caller.
427        expected: u64,
428        /// Checksum computed from the full store byte span.
429        found: u64,
430    },
431}
432
433impl core::fmt::Display for TerrainStoreError {
434    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
435        match self {
436            Self::Io { path, message } => write!(f, "{} failed: {message}", path.display()),
437            Self::Parse { reason } => write!(f, "terrain store parse error: {reason}"),
438            Self::UnsupportedVersion { version } => {
439                write!(f, "terrain store version {version} is not supported")
440            }
441            Self::UnsupportedDatum { tag } => {
442                write!(f, "terrain store vertical datum tag {tag} is not supported")
443            }
444            Self::DuplicateTile {
445                lat_index,
446                lon_index,
447            } => write!(f, "duplicate terrain tile ({lat_index},{lon_index})"),
448            Self::TileIdMismatch {
449                path,
450                expected,
451                found,
452            } => write!(
453                f,
454                "{} tile id expected ({},{}) but DTED origin is ({},{})",
455                path.display(),
456                expected.lat_index,
457                expected.lon_index,
458                found.lat_index,
459                found.lon_index
460            ),
461            Self::Checksum {
462                lat_index,
463                lon_index,
464                expected,
465                found,
466            } => write!(
467                f,
468                "terrain tile ({lat_index},{lon_index}) checksum expected {expected:#x} but found {found:#x}"
469            ),
470            Self::AttestedChecksumMismatch { expected, found } => write!(
471                f,
472                "attested terrain store checksum expected {expected:#x} but found {found:#x}"
473            ),
474        }
475    }
476}
477
478impl std::error::Error for TerrainStoreError {}
479
480#[derive(Clone, Debug)]
481struct MmapTile {
482    index: TerrainStoreTileIndex,
483}
484
485impl MmapTile {
486    fn contains(&self, longitude_deg: f64, latitude_deg: f64) -> bool {
487        latitude_deg >= self.index.min_latitude_deg
488            && latitude_deg <= self.index.max_latitude_deg
489            && longitude_deg >= self.index.min_longitude_deg
490            && longitude_deg <= self.index.max_longitude_deg
491    }
492
493    fn get_elevation(&self, bytes: &[u8], longitude_deg: f64, latitude_deg: f64) -> Result<i16> {
494        if !self.contains(longitude_deg, latitude_deg) {
495            return Err(Error::Parse(format!(
496                "point ({longitude_deg},{latitude_deg}) is outside terrain store tile ({},{})",
497                self.index.min_longitude_deg, self.index.min_latitude_deg
498            )));
499        }
500
501        let lat_count = self.index.lat_count as usize;
502        let lon_count = self.index.lon_count as usize;
503        let latitude_index = terrain::nearest_posting_index(
504            latitude_deg - self.index.min_latitude_deg,
505            lat_count - 1,
506        )
507        .map_err(Error::Parse)?;
508        let longitude_index = terrain::nearest_posting_index(
509            longitude_deg - self.index.min_longitude_deg,
510            lon_count - 1,
511        )
512        .map_err(Error::Parse)?;
513        if latitude_index >= lat_count || longitude_index >= lon_count {
514            return Err(Error::Parse(format!(
515                "posting index out of bounds lon={longitude_index} lat={latitude_index}"
516            )));
517        }
518
519        let sample_start =
520            self.index.data_offset as usize + 2 * (longitude_index * lat_count + latitude_index);
521        Ok(i16::from_le_bytes([
522            bytes[sample_start],
523            bytes[sample_start + 1],
524        ]))
525    }
526}
527
528/// Memory-mappable terrain reader backed by a terrain store byte span.
529///
530/// Scalar and batch terrain lookups return orthometric metres, `H`, above the
531/// EGM96 mean sea level geoid. Use [`Self::ellipsoidal_height_m`] or
532/// [`OrthometricHeightM::to_ellipsoidal_height_deg`] for the explicit
533/// `h = H + N` conversion to WGS84 ellipsoidal height.
534#[derive(Clone, Debug)]
535pub struct MmapTerrain<'a> {
536    bytes: ArtifactBytes<'a>,
537    tiles: Vec<MmapTile>,
538    by_grid: HashMap<(i32, i32), usize>,
539    tile_index: Vec<TerrainStoreTileIndex>,
540    tile_ids: Vec<TerrainTileId>,
541    vertical_datum: VerticalDatum,
542    digest_provenance: DigestProvenance,
543    attested_checksum64: Option<u64>,
544}
545
546#[derive(Clone, Copy)]
547enum ChecksumValidation {
548    Verified,
549    Attested(u64),
550}
551
552impl ChecksumValidation {
553    const fn digest_provenance(self) -> DigestProvenance {
554        match self {
555            Self::Verified => DigestProvenance::Verified,
556            Self::Attested(_) => DigestProvenance::Attested,
557        }
558    }
559
560    const fn attested_checksum64(self) -> Option<u64> {
561        match self {
562            Self::Verified => None,
563            Self::Attested(checksum64) => Some(checksum64),
564        }
565    }
566
567    const fn verifies_payloads(self) -> bool {
568        matches!(self, Self::Verified)
569    }
570}
571
572impl MmapTerrain<'static> {
573    /// Parse an owned terrain store byte vector.
574    pub fn from_vec(bytes: Vec<u8>) -> core::result::Result<Self, TerrainStoreError> {
575        Self::from_backing(ArtifactBytes::Owned(bytes), ChecksumValidation::Verified)
576    }
577
578    /// Parse owned terrain store bytes using a caller-attested checksum.
579    ///
580    /// The byte-based counterpart of [`Self::from_path_attested`], for callers
581    /// that already hold the store bytes (an interface layer, an object-store
582    /// read) alongside a trustworthy content measurement. Same contract:
583    /// structural checks run unconditionally, the per-tile payload hashing is
584    /// replaced by the claim, and the handle reports
585    /// [`DigestProvenance::Attested`] until [`Self::verify`] succeeds.
586    pub fn from_vec_attested(
587        bytes: Vec<u8>,
588        claimed_checksum64: u64,
589    ) -> core::result::Result<Self, TerrainStoreError> {
590        Self::from_backing(
591            ArtifactBytes::Owned(bytes),
592            ChecksumValidation::Attested(claimed_checksum64),
593        )
594    }
595
596    /// Open and parse a terrain store file.
597    ///
598    /// With the `mmap` feature the file is memory-mapped read-only and this
599    /// reader owns the mapping; without it the file is read into memory. The
600    /// entry point is the same either way, so enabling the feature speeds up
601    /// every existing caller rather than asking anyone to migrate.
602    ///
603    /// Mapping is what makes a very large store usable at all: construction
604    /// parses only the header, datum tag, and tile index, and lookups are
605    /// demand-paged, so a reader querying a geographically local region never
606    /// faults in the rest of the file.
607    pub fn from_path(path: impl AsRef<Path>) -> core::result::Result<Self, TerrainStoreError> {
608        let path = path.as_ref();
609
610        #[cfg(feature = "mmap")]
611        {
612            let bytes = crate::artifact_bytes::map_file_read_only(path).map_err(|err| {
613                TerrainStoreError::Io {
614                    path: path.to_path_buf(),
615                    message: err.to_string(),
616                }
617            })?;
618            Self::from_backing(bytes, ChecksumValidation::Verified)
619        }
620
621        #[cfg(not(feature = "mmap"))]
622        {
623            let bytes = fs::read(path).map_err(|err| TerrainStoreError::Io {
624                path: path.to_path_buf(),
625                message: err.to_string(),
626            })?;
627            Self::from_vec(bytes)
628        }
629    }
630
631    /// Open a terrain store using a caller-attested full-store checksum.
632    ///
633    /// This performs the same header, index, dimension, length, and tile-bound
634    /// validation as [`Self::from_path`] without hashing tile payloads. Terrain
635    /// headers do not carry a full-store checksum, so the claim is recorded as
636    /// supplied and can be checked later with [`Self::verify`]. With the `mmap`
637    /// feature the file is memory-mapped read-only; without it the file is read
638    /// into memory.
639    pub fn from_path_attested(
640        path: impl AsRef<Path>,
641        claimed_checksum64: u64,
642    ) -> core::result::Result<Self, TerrainStoreError> {
643        let path = path.as_ref();
644
645        #[cfg(feature = "mmap")]
646        {
647            let bytes = crate::artifact_bytes::map_file_read_only(path).map_err(|err| {
648                TerrainStoreError::Io {
649                    path: path.to_path_buf(),
650                    message: err.to_string(),
651                }
652            })?;
653            Self::from_backing(bytes, ChecksumValidation::Attested(claimed_checksum64))
654        }
655
656        #[cfg(not(feature = "mmap"))]
657        {
658            let bytes = fs::read(path).map_err(|err| TerrainStoreError::Io {
659                path: path.to_path_buf(),
660                message: err.to_string(),
661            })?;
662            Self::from_backing(
663                ArtifactBytes::Owned(bytes),
664                ChecksumValidation::Attested(claimed_checksum64),
665            )
666        }
667    }
668}
669
670impl<'a> MmapTerrain<'a> {
671    /// Parse a borrowed terrain store byte span.
672    ///
673    /// The reader keeps the byte span in place and indexes posting payloads by
674    /// offset. Passing an mmap-backed slice gives a zero-copy reader.
675    pub fn from_bytes(bytes: &'a [u8]) -> core::result::Result<Self, TerrainStoreError> {
676        Self::from_backing(ArtifactBytes::Borrowed(bytes), ChecksumValidation::Verified)
677    }
678
679    fn from_backing(
680        bytes: ArtifactBytes<'a>,
681        checksum_validation: ChecksumValidation,
682    ) -> core::result::Result<Self, TerrainStoreError> {
683        let parsed = parse_store(bytes.as_slice(), checksum_validation)?;
684        Ok(Self {
685            bytes,
686            tiles: parsed.tiles,
687            by_grid: parsed.by_grid,
688            tile_index: parsed.tile_index,
689            tile_ids: parsed.tile_ids,
690            vertical_datum: parsed.vertical_datum,
691            digest_provenance: checksum_validation.digest_provenance(),
692            attested_checksum64: checksum_validation.attested_checksum64(),
693        })
694    }
695
696    /// Borrow the original terrain store bytes.
697    #[must_use]
698    pub fn as_bytes(&self) -> &[u8] {
699        self.bytes.as_slice()
700    }
701
702    /// Whether this reader is backed by a memory map rather than a copy in
703    /// process memory.
704    #[must_use]
705    pub fn is_memory_mapped(&self) -> bool {
706        self.bytes.is_memory_mapped()
707    }
708
709    /// Return the store's file-level vertical datum.
710    #[must_use]
711    pub const fn vertical_datum(&self) -> VerticalDatum {
712        self.vertical_datum
713    }
714
715    /// Borrow the parsed tile index records.
716    #[must_use]
717    pub fn tile_index(&self) -> &[TerrainStoreTileIndex] {
718        &self.tile_index
719    }
720
721    /// Return the number of tiles present in this terrain store.
722    #[must_use]
723    pub fn tile_count(&self) -> usize {
724        self.tile_ids.len()
725    }
726
727    /// Borrow the sorted integer tile ids present in this terrain store.
728    #[must_use]
729    pub fn tile_ids(&self) -> &[TerrainTileId] {
730        &self.tile_ids
731    }
732
733    /// Return who computed the checksum carried by this reader.
734    #[must_use]
735    pub const fn digest_provenance(&self) -> DigestProvenance {
736        self.digest_provenance
737    }
738
739    /// Return the full-store checksum carried by this reader.
740    ///
741    /// Verified readers compute the FNV-1a checksum on demand. Attested readers
742    /// return the caller's claim without hashing the byte span.
743    #[must_use]
744    pub fn checksum64(&self) -> u64 {
745        match self.digest_provenance {
746            DigestProvenance::Verified => terrain_store_checksum64(self.bytes.as_ref()),
747            DigestProvenance::Attested => self
748                .attested_checksum64
749                .expect("attested terrain reader carries a checksum"),
750        }
751    }
752
753    /// Re-verify tile payloads and any caller-attested full-store checksum.
754    ///
755    /// Successful verification changes the digest provenance to
756    /// [`DigestProvenance::Verified`].
757    pub fn verify(&mut self) -> core::result::Result<(), TerrainStoreError> {
758        parse_store(self.bytes.as_ref(), ChecksumValidation::Verified)?;
759        if let Some(expected) = self.attested_checksum64 {
760            let found = terrain_store_checksum64(self.bytes.as_ref());
761            if expected != found {
762                return Err(TerrainStoreError::AttestedChecksumMismatch { expected, found });
763            }
764        }
765        self.digest_provenance = DigestProvenance::Verified;
766        self.attested_checksum64 = None;
767        Ok(())
768    }
769
770    /// Re-serialize this parsed terrain store into canonical bytes.
771    ///
772    /// A store accepted by [`Self::from_bytes`] is already canonical, so this
773    /// returns bytes identical to [`Self::as_bytes`].
774    #[must_use]
775    pub fn to_bytes(&self) -> Vec<u8> {
776        let pending = self
777            .tiles
778            .iter()
779            .map(|tile| PendingTile {
780                lat_index: tile.index.lat_index,
781                lon_index: tile.index.lon_index,
782                min_latitude_deg: tile.index.min_latitude_deg,
783                min_longitude_deg: tile.index.min_longitude_deg,
784                max_latitude_deg: tile.index.max_latitude_deg,
785                max_longitude_deg: tile.index.max_longitude_deg,
786                lon_count: tile.index.lon_count,
787                lat_count: tile.index.lat_count,
788                data: self.tile_payload(tile).to_vec(),
789                vertical_datum: tile.index.vertical_datum,
790            })
791            .collect();
792        build_store(pending).expect("parsed terrain store can be reserialized")
793    }
794
795    /// Return the bilinearly interpolated orthometric height `H` in metres at a
796    /// longitude-first geodetic position in degrees.
797    pub fn height_m(&mut self, longitude_deg: f64, latitude_deg: f64) -> Result<f64> {
798        self.height_m_with_options(longitude_deg, latitude_deg, DtedLookupOptions::default())
799    }
800
801    /// Return the orthometric height `H` in metres at a longitude-first geodetic
802    /// position in degrees using explicit lookup options.
803    pub fn height_m_with_options(
804        &mut self,
805        longitude_deg: f64,
806        latitude_deg: f64,
807        options: DtedLookupOptions,
808    ) -> Result<f64> {
809        self.orthometric_height_m_with_options(longitude_deg, latitude_deg, options)
810            .map(OrthometricHeightM::metres)
811    }
812
813    /// Return the bilinearly interpolated orthometric height `H` in metres as a
814    /// typed value at a longitude-first geodetic position in degrees.
815    pub fn orthometric_height_m(
816        &self,
817        longitude_deg: f64,
818        latitude_deg: f64,
819    ) -> Result<OrthometricHeightM> {
820        self.orthometric_height_m_with_options(
821            longitude_deg,
822            latitude_deg,
823            DtedLookupOptions::default(),
824        )
825    }
826
827    /// Return the orthometric height `H` in metres as a typed value at a
828    /// longitude-first geodetic position in degrees using explicit lookup
829    /// options.
830    pub fn orthometric_height_m_with_options(
831        &self,
832        longitude_deg: f64,
833        latitude_deg: f64,
834        options: DtedLookupOptions,
835    ) -> Result<OrthometricHeightM> {
836        validate_lookup_coordinates(longitude_deg, latitude_deg)?;
837        let Some(tile_idx) = self.resolve_grid(longitude_deg, latitude_deg) else {
838            return Err(missing_terrain_tile(longitude_deg, latitude_deg));
839        };
840        height_from_tile(
841            self.bytes.as_ref(),
842            &self.tiles[tile_idx],
843            longitude_deg,
844            latitude_deg,
845            options,
846        )
847        .map(OrthometricHeightM::new)
848    }
849
850    /// Evaluate `(longitude_deg, latitude_deg)` points in order as orthometric
851    /// heights `H` in metres.
852    ///
853    /// The tuple order is longitude-first, matching [`Self::height_m`]. Each
854    /// output element is independent, so an invalid point or parse failure is
855    /// returned only for that element.
856    pub fn height_batch(
857        &mut self,
858        points: &[(f64, f64)],
859        options: DtedLookupOptions,
860    ) -> Vec<Result<f64>> {
861        self.orthometric_height_batch(points, options)
862            .into_iter()
863            .map(|result| result.map(OrthometricHeightM::metres))
864            .collect()
865    }
866
867    /// Evaluate `(longitude_deg, latitude_deg)` points in order as typed
868    /// orthometric heights `H` in metres.
869    ///
870    /// The tuple order is longitude-first. Each output element is independent,
871    /// so an invalid point or parse failure is returned only for that element.
872    pub fn orthometric_height_batch(
873        &self,
874        points: &[(f64, f64)],
875        options: DtedLookupOptions,
876    ) -> Vec<Result<OrthometricHeightM>> {
877        let mut out = Vec::with_capacity(points.len());
878        let mut current = None;
879
880        for &(longitude_deg, latitude_deg) in points {
881            if let Err(err) = validate_lookup_coordinates(longitude_deg, latitude_deg) {
882                out.push(Err(err));
883                continue;
884            }
885
886            let primary_grid = terrain::terrain_grid(longitude_deg, latitude_deg);
887            if current == Some(primary_grid) {
888                if let Some(&tile_idx) = self.by_grid.get(&primary_grid) {
889                    let tile = &self.tiles[tile_idx];
890                    if tile.contains(longitude_deg, latitude_deg) {
891                        out.push(
892                            height_from_tile(
893                                self.bytes.as_ref(),
894                                tile,
895                                longitude_deg,
896                                latitude_deg,
897                                options,
898                            )
899                            .map(OrthometricHeightM::new),
900                        );
901                        continue;
902                    }
903                }
904            }
905
906            match self.resolve_grid(longitude_deg, latitude_deg) {
907                Some(tile_idx) => {
908                    let tile = &self.tiles[tile_idx];
909                    current = Some((tile.index.lat_index, tile.index.lon_index));
910                    out.push(
911                        height_from_tile(
912                            self.bytes.as_ref(),
913                            tile,
914                            longitude_deg,
915                            latitude_deg,
916                            options,
917                        )
918                        .map(OrthometricHeightM::new),
919                    );
920                }
921                None => {
922                    current = None;
923                    out.push(Err(missing_terrain_tile(longitude_deg, latitude_deg)));
924                }
925            }
926        }
927
928        out
929    }
930
931    /// Return ellipsoidal height `h` in metres using the embedded EGM96
932    /// 1-degree grid for `h = H + N`.
933    ///
934    /// The input position is terrain order `(longitude_deg, latitude_deg)`.
935    /// Internally, the geoid call is made with `(latitude_deg, longitude_deg)`.
936    /// The embedded EGM96 1-degree grid agrees with the full EGM96
937    /// 15-arcminute grid to about 0.4 m RMS.
938    pub fn ellipsoidal_height_m(
939        &self,
940        longitude_deg: f64,
941        latitude_deg: f64,
942    ) -> core::result::Result<EllipsoidalHeightM, TerrainDatumError> {
943        self.ellipsoidal_height_m_with_options(
944            longitude_deg,
945            latitude_deg,
946            DtedLookupOptions::default(),
947        )
948    }
949
950    /// Return ellipsoidal height `h` in metres using the embedded EGM96
951    /// 1-degree grid for `h = H + N` and explicit terrain lookup options.
952    ///
953    /// The input position is terrain order `(longitude_deg, latitude_deg)`.
954    /// Internally, the geoid call is made with `(latitude_deg, longitude_deg)`.
955    pub fn ellipsoidal_height_m_with_options(
956        &self,
957        longitude_deg: f64,
958        latitude_deg: f64,
959        options: DtedLookupOptions,
960    ) -> core::result::Result<EllipsoidalHeightM, TerrainDatumError> {
961        self.ellipsoidal_height_m_with_model(
962            longitude_deg,
963            latitude_deg,
964            options,
965            TerrainGeoidModel::Egm96OneDegree,
966        )
967    }
968
969    /// Return ellipsoidal height `h` in metres using an explicit geoid tier for
970    /// `h = H + N`.
971    ///
972    /// The input position is terrain order `(longitude_deg, latitude_deg)`.
973    /// Internally, the geoid call is made with `(latitude_deg, longitude_deg)`.
974    /// Choosing [`TerrainGeoidModel::Egm96FifteenMinute`] requires a loaded
975    /// `WW15MGH.DAC` grid and never falls back to the embedded EGM96 1-degree
976    /// grid.
977    pub fn ellipsoidal_height_m_with_model(
978        &self,
979        longitude_deg: f64,
980        latitude_deg: f64,
981        options: DtedLookupOptions,
982        geoid: TerrainGeoidModel<'_>,
983    ) -> core::result::Result<EllipsoidalHeightM, TerrainDatumError> {
984        let orthometric = self
985            .orthometric_height_m_with_options(longitude_deg, latitude_deg, options)
986            .map_err(TerrainDatumError::Terrain)?;
987        orthometric.to_ellipsoidal_height_deg(latitude_deg, longitude_deg, geoid)
988    }
989
990    fn resolve_grid(&self, longitude_deg: f64, latitude_deg: f64) -> Option<usize> {
991        for grid_idx in terrain_grid_candidates(longitude_deg, latitude_deg) {
992            if let Some(&tile_idx) = self.by_grid.get(&grid_idx) {
993                if self.tiles[tile_idx].contains(longitude_deg, latitude_deg) {
994                    return Some(tile_idx);
995                }
996            }
997        }
998        None
999    }
1000
1001    fn tile_payload(&self, tile: &MmapTile) -> &[u8] {
1002        let start = tile.index.data_offset as usize;
1003        let end = start + tile.index.data_len as usize;
1004        &self.bytes.as_slice()[start..end]
1005    }
1006}
1007
1008/// Convert a DTED tile tree into canonical memory-mappable terrain store bytes.
1009///
1010/// Input `.dt2` files are discovered recursively below `root`, following
1011/// symlinked directories and symlinked `.dt2` files. A symlinked file is treated
1012/// as DTED when either the link name or the resolved target name ends in
1013/// `.dt2`. Tiles are then sorted by integer tile id. DTED signed-magnitude
1014/// postings are decoded once into `i16` orthometric metres. DTED negative zero
1015/// and SRTM voids already encoded as zero remain zero, matching the existing
1016/// lazy DTED reader.
1017pub fn dted_tree_to_mmap_store(
1018    root: impl AsRef<Path>,
1019) -> core::result::Result<Vec<u8>, TerrainStoreError> {
1020    let root = root.as_ref();
1021    let mut paths = Vec::new();
1022    collect_dted_tile_paths(root, &mut paths)?;
1023    dted_paths_to_mmap_store(paths)
1024}
1025
1026/// Convert an explicit DTED tile list into canonical memory-mappable terrain
1027/// store bytes.
1028///
1029/// Each entry supplies the expected integer tile id and the DTED `.dt2` path.
1030/// The converter parses each DTED header and fails if the file origin does not
1031/// match the supplied id. The decoded payload, sorting, duplicate detection,
1032/// alignment, and checksums are the same as [`dted_tree_to_mmap_store`].
1033pub fn dted_tile_list_to_mmap_store(
1034    entries: &[DtedTileListEntry],
1035) -> core::result::Result<Vec<u8>, TerrainStoreError> {
1036    let mut pending = Vec::with_capacity(entries.len());
1037    for entry in entries {
1038        pending.push(pending_tile_from_dted_path(
1039            &entry.path,
1040            Some(entry.tile_id),
1041        )?);
1042    }
1043    build_store(pending)
1044}
1045
1046/// Convert a DTED tile tree and write canonical memory-mappable terrain store
1047/// bytes to `output_path`.
1048///
1049/// Symlinked directories and symlinked `.dt2` files below `root` are followed.
1050/// A symlinked file is treated as DTED when either the link name or the resolved
1051/// target name ends in `.dt2`.
1052pub fn write_dted_tree_to_mmap_store(
1053    root: impl AsRef<Path>,
1054    output_path: impl AsRef<Path>,
1055) -> core::result::Result<(), TerrainStoreError> {
1056    let bytes = dted_tree_to_mmap_store(root)?;
1057    let output_path = output_path.as_ref();
1058    fs::write(output_path, &bytes).map_err(|err| TerrainStoreError::Io {
1059        path: output_path.to_path_buf(),
1060        message: err.to_string(),
1061    })
1062}
1063
1064/// Convert an explicit DTED tile list and write canonical memory-mappable
1065/// terrain store bytes to `output_path`.
1066pub fn write_dted_tile_list_to_mmap_store(
1067    entries: &[DtedTileListEntry],
1068    output_path: impl AsRef<Path>,
1069) -> core::result::Result<(), TerrainStoreError> {
1070    let bytes = dted_tile_list_to_mmap_store(entries)?;
1071    let output_path = output_path.as_ref();
1072    fs::write(output_path, &bytes).map_err(|err| TerrainStoreError::Io {
1073        path: output_path.to_path_buf(),
1074        message: err.to_string(),
1075    })
1076}
1077
1078/// Return an FNV-1a checksum for terrain store bytes.
1079///
1080/// This checksum is for deterministic local verification and is not a
1081/// cryptographic digest.
1082#[must_use]
1083pub fn terrain_store_checksum64(bytes: &[u8]) -> u64 {
1084    fnv1a64(bytes)
1085}
1086
1087#[derive(Debug)]
1088struct PendingTile {
1089    lat_index: i32,
1090    lon_index: i32,
1091    min_latitude_deg: f64,
1092    min_longitude_deg: f64,
1093    max_latitude_deg: f64,
1094    max_longitude_deg: f64,
1095    lon_count: u32,
1096    lat_count: u32,
1097    data: Vec<u8>,
1098    vertical_datum: VerticalDatum,
1099}
1100
1101#[derive(Debug)]
1102struct ParsedStore {
1103    vertical_datum: VerticalDatum,
1104    tiles: Vec<MmapTile>,
1105    by_grid: HashMap<(i32, i32), usize>,
1106    tile_index: Vec<TerrainStoreTileIndex>,
1107    tile_ids: Vec<TerrainTileId>,
1108}
1109
1110fn height_from_tile(
1111    bytes: &[u8],
1112    tile: &MmapTile,
1113    longitude_deg: f64,
1114    latitude_deg: f64,
1115    options: DtedLookupOptions,
1116) -> Result<f64> {
1117    if options.interpolation == DtedInterpolation::NearestPosting {
1118        return tile
1119            .get_elevation(bytes, longitude_deg, latitude_deg)
1120            .map(|v| v as f64);
1121    }
1122
1123    let postings_per_deg_lon = tile.index.lon_count as usize - 1;
1124    let postings_per_deg_lat = tile.index.lat_count as usize - 1;
1125
1126    let lon = terrain::scaled_cell_fraction(
1127        longitude_deg - tile.index.min_longitude_deg,
1128        postings_per_deg_lon,
1129    );
1130    let lat = terrain::scaled_cell_fraction(
1131        latitude_deg - tile.index.min_latitude_deg,
1132        postings_per_deg_lat,
1133    );
1134    let lon_lo = lon.cell;
1135    let lat_lo = lat.cell;
1136    let fx = lon.fraction;
1137    let fy = lat.fraction;
1138
1139    let mut z = 0.0;
1140    for (di, wx) in [(0i64, 1.0 - fx), (1i64, fx)] {
1141        for (dj, wy) in [(0i64, 1.0 - fy), (1i64, fy)] {
1142            let w = wx * wy;
1143            if w == 0.0 {
1144                continue;
1145            }
1146            let posting_lon =
1147                tile.index.min_longitude_deg + (lon_lo + di) as f64 / postings_per_deg_lon as f64;
1148            let posting_lat =
1149                tile.index.min_latitude_deg + (lat_lo + dj) as f64 / postings_per_deg_lat as f64;
1150            z += w * f64::from(tile.get_elevation(bytes, posting_lon, posting_lat)?);
1151        }
1152    }
1153    Ok(z)
1154}
1155
1156fn dted_paths_to_mmap_store(
1157    mut paths: Vec<PathBuf>,
1158) -> core::result::Result<Vec<u8>, TerrainStoreError> {
1159    paths.sort();
1160
1161    let mut pending = Vec::with_capacity(paths.len());
1162    for path in paths {
1163        pending.push(pending_tile_from_dted_path(&path, None)?);
1164    }
1165    build_store(pending)
1166}
1167
1168fn pending_tile_from_dted_path(
1169    path: &Path,
1170    expected_id: Option<TerrainTileId>,
1171) -> core::result::Result<PendingTile, TerrainStoreError> {
1172    let tile = DtedTile::from_path(path).map_err(|reason| TerrainStoreError::Parse {
1173        reason: format!("{}: {reason}", path.display()),
1174    })?;
1175    let decoded = tile
1176        .decoded_postings_lon_major()
1177        .map_err(|reason| TerrainStoreError::Parse {
1178            reason: format!("{}: {reason}", path.display()),
1179        })?;
1180    let mut data = Vec::with_capacity(decoded.len() * 2);
1181    for posting in decoded {
1182        data.extend_from_slice(&posting.to_le_bytes());
1183    }
1184    let lat_index = tile.origin_latitude().floor() as i32;
1185    let lon_index = tile.origin_longitude().floor() as i32;
1186    let found = TerrainTileId::new(lat_index, lon_index);
1187    if let Some(expected) = expected_id {
1188        if expected != found {
1189            return Err(TerrainStoreError::TileIdMismatch {
1190                path: path.to_path_buf(),
1191                expected,
1192                found,
1193            });
1194        }
1195    }
1196    Ok(PendingTile {
1197        lat_index,
1198        lon_index,
1199        min_latitude_deg: tile.origin_latitude(),
1200        min_longitude_deg: tile.origin_longitude(),
1201        max_latitude_deg: tile.origin_latitude() + 1.0,
1202        max_longitude_deg: tile.origin_longitude() + 1.0,
1203        lon_count: u32::try_from(tile.lon_count()).map_err(|_| TerrainStoreError::Parse {
1204            reason: format!("{} longitude count exceeds u32", path.display()),
1205        })?,
1206        lat_count: u32::try_from(tile.lat_count()).map_err(|_| TerrainStoreError::Parse {
1207            reason: format!("{} latitude count exceeds u32", path.display()),
1208        })?,
1209        data,
1210        vertical_datum: VerticalDatum::Egm96MslOrthometric,
1211    })
1212}
1213
1214fn collect_dted_tile_paths(
1215    root: &Path,
1216    out: &mut Vec<PathBuf>,
1217) -> core::result::Result<(), TerrainStoreError> {
1218    let mut visited_dirs = HashSet::new();
1219    collect_dted_tile_paths_inner(root, out, &mut visited_dirs)
1220}
1221
1222fn collect_dted_tile_paths_inner(
1223    path: &Path,
1224    out: &mut Vec<PathBuf>,
1225    visited_dirs: &mut HashSet<PathBuf>,
1226) -> core::result::Result<(), TerrainStoreError> {
1227    let metadata = fs::metadata(path).map_err(|err| TerrainStoreError::Io {
1228        path: path.to_path_buf(),
1229        message: err.to_string(),
1230    })?;
1231
1232    if metadata.is_dir() {
1233        let canonical = fs::canonicalize(path).map_err(|err| TerrainStoreError::Io {
1234            path: path.to_path_buf(),
1235            message: err.to_string(),
1236        })?;
1237        if !visited_dirs.insert(canonical) {
1238            return Ok(());
1239        }
1240        let entries = fs::read_dir(path).map_err(|err| TerrainStoreError::Io {
1241            path: path.to_path_buf(),
1242            message: err.to_string(),
1243        })?;
1244        for entry in entries {
1245            let entry = entry.map_err(|err| TerrainStoreError::Io {
1246                path: path.to_path_buf(),
1247                message: err.to_string(),
1248            })?;
1249            collect_dted_tile_paths_inner(&entry.path(), out, visited_dirs)?;
1250        }
1251    } else if metadata.is_file() && is_dted_tile_source(path)? {
1252        out.push(path.to_path_buf());
1253    }
1254    Ok(())
1255}
1256
1257fn is_dted_tile_source(path: &Path) -> core::result::Result<bool, TerrainStoreError> {
1258    if is_dted_tile_path(path) {
1259        return Ok(true);
1260    }
1261
1262    let canonical = fs::canonicalize(path).map_err(|err| TerrainStoreError::Io {
1263        path: path.to_path_buf(),
1264        message: err.to_string(),
1265    })?;
1266    Ok(is_dted_tile_path(&canonical))
1267}
1268
1269fn is_dted_tile_path(path: &Path) -> bool {
1270    path.file_name()
1271        .and_then(|name| name.to_str())
1272        .is_some_and(|name| name.ends_with(".dt2"))
1273}
1274
1275fn parse_store(
1276    bytes: &[u8],
1277    checksum_validation: ChecksumValidation,
1278) -> core::result::Result<ParsedStore, TerrainStoreError> {
1279    if bytes.len() < STORE_HEADER_LEN {
1280        return Err(TerrainStoreError::Parse {
1281            reason: format!(
1282                "store has {} bytes but needs at least {STORE_HEADER_LEN}",
1283                bytes.len()
1284            ),
1285        });
1286    }
1287    if &bytes[..STORE_MAGIC.len()] != STORE_MAGIC {
1288        return Err(TerrainStoreError::Parse {
1289            reason: "missing terrain store magic".to_string(),
1290        });
1291    }
1292    let version = read_u16(bytes, HEADER_VERSION_OFFSET)?;
1293    if version != STORE_VERSION {
1294        return Err(TerrainStoreError::UnsupportedVersion { version });
1295    }
1296    ensure_zero(bytes, 11, 12, "header reserved byte")?;
1297    ensure_zero(bytes, 40, STORE_HEADER_LEN, "header reserved bytes")?;
1298
1299    let vertical_datum = VerticalDatum::from_tag(bytes[HEADER_DATUM_OFFSET])?;
1300    let tile_count = read_u32(bytes, HEADER_TILE_COUNT_OFFSET)? as usize;
1301    let index_offset = read_u64(bytes, HEADER_INDEX_OFFSET_OFFSET)? as usize;
1302    let data_offset = read_u64(bytes, HEADER_DATA_OFFSET_OFFSET)? as usize;
1303    let total_len = read_u64(bytes, HEADER_TOTAL_LEN_OFFSET)? as usize;
1304    if total_len != bytes.len() {
1305        return Err(TerrainStoreError::Parse {
1306            reason: format!(
1307                "header total length {total_len} does not match {}",
1308                bytes.len()
1309            ),
1310        });
1311    }
1312    if index_offset != STORE_HEADER_LEN {
1313        return Err(TerrainStoreError::Parse {
1314            reason: format!("index offset must be {STORE_HEADER_LEN}, got {index_offset}"),
1315        });
1316    }
1317
1318    let index_len = tile_count
1319        .checked_mul(STORE_INDEX_RECORD_LEN)
1320        .ok_or_else(|| TerrainStoreError::Parse {
1321            reason: "tile index length overflows usize".to_string(),
1322        })?;
1323    let index_end =
1324        index_offset
1325            .checked_add(index_len)
1326            .ok_or_else(|| TerrainStoreError::Parse {
1327                reason: "tile index end overflows usize".to_string(),
1328            })?;
1329    if index_end > bytes.len() {
1330        return Err(TerrainStoreError::Parse {
1331            reason: "tile index extends past store length".to_string(),
1332        });
1333    }
1334    let expected_data_offset = align_up(index_end, STORE_ALIGNMENT)?;
1335    if data_offset != expected_data_offset {
1336        return Err(TerrainStoreError::Parse {
1337            reason: format!("data offset must be {expected_data_offset}, got {data_offset}"),
1338        });
1339    }
1340    ensure_zero(bytes, index_end, data_offset, "index padding")?;
1341
1342    let mut tiles = Vec::with_capacity(tile_count);
1343    let mut tile_index = Vec::with_capacity(tile_count);
1344    let mut tile_ids = Vec::with_capacity(tile_count);
1345    let mut by_grid = HashMap::with_capacity(tile_count);
1346    let mut previous_id = None;
1347    let mut expected_next = data_offset;
1348
1349    for idx in 0..tile_count {
1350        let record_offset = index_offset + idx * STORE_INDEX_RECORD_LEN;
1351        let record = &bytes[record_offset..record_offset + STORE_INDEX_RECORD_LEN];
1352        let lat_index = read_i32(record, INDEX_LAT_OFFSET)?;
1353        let lon_index = read_i32(record, INDEX_LON_OFFSET)?;
1354        let tile_id = (lat_index, lon_index);
1355        if previous_id.is_some_and(|previous| tile_id <= previous) {
1356            return Err(TerrainStoreError::Parse {
1357                reason: "tile index records are not strictly sorted".to_string(),
1358            });
1359        }
1360        previous_id = Some(tile_id);
1361
1362        let lon_count = read_u32(record, INDEX_LON_COUNT_OFFSET)?;
1363        let lat_count = read_u32(record, INDEX_LAT_COUNT_OFFSET)?;
1364        if lon_count < 2 || lat_count < 2 {
1365            return Err(TerrainStoreError::Parse {
1366                reason: format!(
1367                    "tile ({lat_index},{lon_index}) has invalid dimensions lon_count={lon_count} lat_count={lat_count}"
1368                ),
1369            });
1370        }
1371        let offset = read_u64(record, INDEX_DATA_OFFSET_OFFSET)? as usize;
1372        let data_len = read_u64(record, INDEX_DATA_LEN_OFFSET)? as usize;
1373        let expected_len = (lon_count as usize)
1374            .checked_mul(lat_count as usize)
1375            .and_then(|count| count.checked_mul(2))
1376            .ok_or_else(|| TerrainStoreError::Parse {
1377                reason: format!("tile ({lat_index},{lon_index}) data length overflows usize"),
1378            })?;
1379        if data_len != expected_len {
1380            return Err(TerrainStoreError::Parse {
1381                reason: format!(
1382                    "tile ({lat_index},{lon_index}) data length must be {expected_len}, got {data_len}"
1383                ),
1384            });
1385        }
1386
1387        let expected_offset = align_up(expected_next, STORE_ALIGNMENT)?;
1388        ensure_zero(bytes, expected_next, expected_offset, "tile padding")?;
1389        if offset != expected_offset {
1390            return Err(TerrainStoreError::Parse {
1391                reason: format!(
1392                    "tile ({lat_index},{lon_index}) data offset must be {expected_offset}, got {offset}"
1393                ),
1394            });
1395        }
1396        let end = offset
1397            .checked_add(data_len)
1398            .ok_or_else(|| TerrainStoreError::Parse {
1399                reason: format!("tile ({lat_index},{lon_index}) data end overflows usize"),
1400            })?;
1401        if end > bytes.len() {
1402            return Err(TerrainStoreError::Parse {
1403                reason: format!("tile ({lat_index},{lon_index}) data extends past store length"),
1404            });
1405        }
1406
1407        let checksum64 = read_u64(record, INDEX_CHECKSUM_OFFSET)?;
1408        if checksum_validation.verifies_payloads() {
1409            let found = fnv1a64(&bytes[offset..end]);
1410            if found != checksum64 {
1411                return Err(TerrainStoreError::Checksum {
1412                    lat_index,
1413                    lon_index,
1414                    expected: checksum64,
1415                    found,
1416                });
1417            }
1418        }
1419
1420        let min_latitude_deg = read_f64(record, INDEX_MIN_LAT_OFFSET)?;
1421        let min_longitude_deg = read_f64(record, INDEX_MIN_LON_OFFSET)?;
1422        let max_latitude_deg = read_f64(record, INDEX_MAX_LAT_OFFSET)?;
1423        let max_longitude_deg = read_f64(record, INDEX_MAX_LON_OFFSET)?;
1424        for (field, value) in [
1425            ("min_latitude_deg", min_latitude_deg),
1426            ("min_longitude_deg", min_longitude_deg),
1427            ("max_latitude_deg", max_latitude_deg),
1428            ("max_longitude_deg", max_longitude_deg),
1429        ] {
1430            if !value.is_finite() {
1431                return Err(TerrainStoreError::Parse {
1432                    reason: format!("tile ({lat_index},{lon_index}) {field} is not finite"),
1433                });
1434            }
1435        }
1436        let tile_datum = VerticalDatum::from_tag(record[INDEX_DATUM_OFFSET])?;
1437        if tile_datum != vertical_datum {
1438            return Err(TerrainStoreError::Parse {
1439                reason: format!("tile ({lat_index},{lon_index}) datum differs from header"),
1440            });
1441        }
1442        ensure_zero(
1443            record,
1444            INDEX_DATUM_OFFSET + 1,
1445            STORE_INDEX_RECORD_LEN,
1446            "tile index reserved bytes",
1447        )?;
1448
1449        let index = TerrainStoreTileIndex {
1450            lat_index,
1451            lon_index,
1452            min_longitude_deg,
1453            min_latitude_deg,
1454            max_longitude_deg,
1455            max_latitude_deg,
1456            lon_count,
1457            lat_count,
1458            data_offset: offset as u64,
1459            data_len: data_len as u64,
1460            checksum64,
1461            vertical_datum: tile_datum,
1462        };
1463        by_grid.insert(tile_id, tiles.len());
1464        tiles.push(MmapTile { index });
1465        tile_index.push(index);
1466        tile_ids.push(TerrainTileId::new(lat_index, lon_index));
1467        expected_next = end;
1468    }
1469
1470    if expected_next != bytes.len() {
1471        return Err(TerrainStoreError::Parse {
1472            reason: format!(
1473                "store has trailing bytes: expected length {expected_next}, got {}",
1474                bytes.len()
1475            ),
1476        });
1477    }
1478
1479    Ok(ParsedStore {
1480        vertical_datum,
1481        tiles,
1482        by_grid,
1483        tile_index,
1484        tile_ids,
1485    })
1486}
1487
1488fn missing_terrain_tile(longitude_deg: f64, latitude_deg: f64) -> Error {
1489    let (lat_index, lon_index) = terrain::terrain_grid(longitude_deg, latitude_deg);
1490    Error::MissingTerrainTile {
1491        lat_index,
1492        lon_index,
1493    }
1494}
1495
1496fn build_store(mut tiles: Vec<PendingTile>) -> core::result::Result<Vec<u8>, TerrainStoreError> {
1497    tiles.sort_by_key(|tile| (tile.lat_index, tile.lon_index));
1498    for pair in tiles.windows(2) {
1499        if (pair[0].lat_index, pair[0].lon_index) == (pair[1].lat_index, pair[1].lon_index) {
1500            return Err(TerrainStoreError::DuplicateTile {
1501                lat_index: pair[0].lat_index,
1502                lon_index: pair[0].lon_index,
1503            });
1504        }
1505    }
1506
1507    let index_end = STORE_HEADER_LEN
1508        .checked_add(
1509            tiles
1510                .len()
1511                .checked_mul(STORE_INDEX_RECORD_LEN)
1512                .ok_or_else(|| TerrainStoreError::Parse {
1513                    reason: "tile index length overflows usize".to_string(),
1514                })?,
1515        )
1516        .ok_or_else(|| TerrainStoreError::Parse {
1517            reason: "tile index end overflows usize".to_string(),
1518        })?;
1519    let data_offset = align_up(index_end, STORE_ALIGNMENT)?;
1520    let mut offsets = Vec::with_capacity(tiles.len());
1521    let mut cursor = data_offset;
1522    for tile in &tiles {
1523        cursor = align_up(cursor, STORE_ALIGNMENT)?;
1524        offsets.push(cursor);
1525        cursor = cursor
1526            .checked_add(tile.data.len())
1527            .ok_or_else(|| TerrainStoreError::Parse {
1528                reason: "store length overflows usize".to_string(),
1529            })?;
1530    }
1531
1532    let mut out = vec![0u8; cursor];
1533    out[..STORE_MAGIC.len()].copy_from_slice(STORE_MAGIC);
1534    write_u16(&mut out, HEADER_VERSION_OFFSET, STORE_VERSION);
1535    out[HEADER_DATUM_OFFSET] = VerticalDatum::Egm96MslOrthometric.tag();
1536    write_u32(
1537        &mut out,
1538        HEADER_TILE_COUNT_OFFSET,
1539        u32::try_from(tiles.len()).map_err(|_| TerrainStoreError::Parse {
1540            reason: "tile count exceeds u32".to_string(),
1541        })?,
1542    );
1543    write_u64(
1544        &mut out,
1545        HEADER_INDEX_OFFSET_OFFSET,
1546        STORE_HEADER_LEN as u64,
1547    );
1548    write_u64(&mut out, HEADER_DATA_OFFSET_OFFSET, data_offset as u64);
1549    write_u64(&mut out, HEADER_TOTAL_LEN_OFFSET, cursor as u64);
1550
1551    for (idx, tile) in tiles.iter().enumerate() {
1552        let record_offset = STORE_HEADER_LEN + idx * STORE_INDEX_RECORD_LEN;
1553        let offset = offsets[idx];
1554        let data_len = tile.data.len();
1555        let expected_len = (tile.lon_count as usize)
1556            .checked_mul(tile.lat_count as usize)
1557            .and_then(|count| count.checked_mul(2))
1558            .ok_or_else(|| TerrainStoreError::Parse {
1559                reason: format!(
1560                    "tile ({},{}) data length overflows usize",
1561                    tile.lat_index, tile.lon_index
1562                ),
1563            })?;
1564        if data_len != expected_len {
1565            return Err(TerrainStoreError::Parse {
1566                reason: format!(
1567                    "tile ({},{}) data length must be {expected_len}, got {data_len}",
1568                    tile.lat_index, tile.lon_index
1569                ),
1570            });
1571        }
1572
1573        let record = &mut out[record_offset..record_offset + STORE_INDEX_RECORD_LEN];
1574        write_i32(record, INDEX_LAT_OFFSET, tile.lat_index);
1575        write_i32(record, INDEX_LON_OFFSET, tile.lon_index);
1576        write_u32(record, INDEX_LON_COUNT_OFFSET, tile.lon_count);
1577        write_u32(record, INDEX_LAT_COUNT_OFFSET, tile.lat_count);
1578        write_u64(record, INDEX_DATA_OFFSET_OFFSET, offset as u64);
1579        write_u64(record, INDEX_DATA_LEN_OFFSET, data_len as u64);
1580        write_u64(record, INDEX_CHECKSUM_OFFSET, fnv1a64(&tile.data));
1581        write_f64(record, INDEX_MIN_LAT_OFFSET, tile.min_latitude_deg);
1582        write_f64(record, INDEX_MIN_LON_OFFSET, tile.min_longitude_deg);
1583        write_f64(record, INDEX_MAX_LAT_OFFSET, tile.max_latitude_deg);
1584        write_f64(record, INDEX_MAX_LON_OFFSET, tile.max_longitude_deg);
1585        record[INDEX_DATUM_OFFSET] = tile.vertical_datum.tag();
1586        out[offset..offset + data_len].copy_from_slice(&tile.data);
1587    }
1588
1589    Ok(out)
1590}
1591
1592fn align_up(value: usize, alignment: usize) -> core::result::Result<usize, TerrainStoreError> {
1593    let rem = value % alignment;
1594    if rem == 0 {
1595        Ok(value)
1596    } else {
1597        value
1598            .checked_add(alignment - rem)
1599            .ok_or_else(|| TerrainStoreError::Parse {
1600                reason: "aligned offset overflows usize".to_string(),
1601            })
1602    }
1603}
1604
1605fn ensure_zero(
1606    bytes: &[u8],
1607    start: usize,
1608    end: usize,
1609    context: &str,
1610) -> core::result::Result<(), TerrainStoreError> {
1611    if start > end || end > bytes.len() {
1612        return Err(TerrainStoreError::Parse {
1613            reason: format!("{context} range is out of bounds"),
1614        });
1615    }
1616    if bytes[start..end].iter().any(|&byte| byte != 0) {
1617        return Err(TerrainStoreError::Parse {
1618            reason: format!("{context} must be zero-filled"),
1619        });
1620    }
1621    Ok(())
1622}
1623
1624fn fnv1a64(bytes: &[u8]) -> u64 {
1625    bytes.iter().fold(FNV_OFFSET_BASIS, |hash, byte| {
1626        (hash ^ u64::from(*byte)).wrapping_mul(FNV_PRIME)
1627    })
1628}
1629
1630fn read_u16(bytes: &[u8], offset: usize) -> core::result::Result<u16, TerrainStoreError> {
1631    Ok(u16::from_le_bytes(read_array(bytes, offset)?))
1632}
1633
1634fn read_u32(bytes: &[u8], offset: usize) -> core::result::Result<u32, TerrainStoreError> {
1635    Ok(u32::from_le_bytes(read_array(bytes, offset)?))
1636}
1637
1638fn read_i32(bytes: &[u8], offset: usize) -> core::result::Result<i32, TerrainStoreError> {
1639    Ok(i32::from_le_bytes(read_array(bytes, offset)?))
1640}
1641
1642fn read_u64(bytes: &[u8], offset: usize) -> core::result::Result<u64, TerrainStoreError> {
1643    Ok(u64::from_le_bytes(read_array(bytes, offset)?))
1644}
1645
1646fn read_f64(bytes: &[u8], offset: usize) -> core::result::Result<f64, TerrainStoreError> {
1647    Ok(f64::from_le_bytes(read_array(bytes, offset)?))
1648}
1649
1650fn read_array<const N: usize>(
1651    bytes: &[u8],
1652    offset: usize,
1653) -> core::result::Result<[u8; N], TerrainStoreError> {
1654    let end = offset
1655        .checked_add(N)
1656        .ok_or_else(|| TerrainStoreError::Parse {
1657            reason: "numeric field offset overflows usize".to_string(),
1658        })?;
1659    let slice = bytes
1660        .get(offset..end)
1661        .ok_or_else(|| TerrainStoreError::Parse {
1662            reason: "numeric field extends past record".to_string(),
1663        })?;
1664    slice.try_into().map_err(|_| TerrainStoreError::Parse {
1665        reason: "numeric field has wrong length".to_string(),
1666    })
1667}
1668
1669fn write_u16(bytes: &mut [u8], offset: usize, value: u16) {
1670    bytes[offset..offset + 2].copy_from_slice(&value.to_le_bytes());
1671}
1672
1673fn write_u32(bytes: &mut [u8], offset: usize, value: u32) {
1674    bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
1675}
1676
1677fn write_i32(bytes: &mut [u8], offset: usize, value: i32) {
1678    bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
1679}
1680
1681fn write_u64(bytes: &mut [u8], offset: usize, value: u64) {
1682    bytes[offset..offset + 8].copy_from_slice(&value.to_le_bytes());
1683}
1684
1685fn write_f64(bytes: &mut [u8], offset: usize, value: f64) {
1686    bytes[offset..offset + 8].copy_from_slice(&value.to_le_bytes());
1687}