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::py_round_to_usize(
504            (latitude_deg - self.index.min_latitude_deg) * (lat_count - 1) as f64,
505        )
506        .map_err(Error::Parse)?;
507        let longitude_index = terrain::py_round_to_usize(
508            (longitude_deg - self.index.min_longitude_deg) * (lon_count - 1) as f64,
509        )
510        .map_err(Error::Parse)?;
511        if latitude_index >= lat_count || longitude_index >= lon_count {
512            return Err(Error::Parse(format!(
513                "posting index out of bounds lon={longitude_index} lat={latitude_index}"
514            )));
515        }
516
517        let sample_start =
518            self.index.data_offset as usize + 2 * (longitude_index * lat_count + latitude_index);
519        Ok(i16::from_le_bytes([
520            bytes[sample_start],
521            bytes[sample_start + 1],
522        ]))
523    }
524}
525
526/// Memory-mappable terrain reader backed by a terrain store byte span.
527///
528/// Scalar and batch terrain lookups return orthometric metres, `H`, above the
529/// EGM96 mean sea level geoid. Use [`Self::ellipsoidal_height_m`] or
530/// [`OrthometricHeightM::to_ellipsoidal_height_deg`] for the explicit
531/// `h = H + N` conversion to WGS84 ellipsoidal height.
532#[derive(Clone, Debug)]
533pub struct MmapTerrain<'a> {
534    bytes: ArtifactBytes<'a>,
535    tiles: Vec<MmapTile>,
536    by_grid: HashMap<(i32, i32), usize>,
537    tile_index: Vec<TerrainStoreTileIndex>,
538    tile_ids: Vec<TerrainTileId>,
539    vertical_datum: VerticalDatum,
540    digest_provenance: DigestProvenance,
541    attested_checksum64: Option<u64>,
542}
543
544#[derive(Clone, Copy)]
545enum ChecksumValidation {
546    Verified,
547    Attested(u64),
548}
549
550impl ChecksumValidation {
551    const fn digest_provenance(self) -> DigestProvenance {
552        match self {
553            Self::Verified => DigestProvenance::Verified,
554            Self::Attested(_) => DigestProvenance::Attested,
555        }
556    }
557
558    const fn attested_checksum64(self) -> Option<u64> {
559        match self {
560            Self::Verified => None,
561            Self::Attested(checksum64) => Some(checksum64),
562        }
563    }
564
565    const fn verifies_payloads(self) -> bool {
566        matches!(self, Self::Verified)
567    }
568}
569
570impl MmapTerrain<'static> {
571    /// Parse an owned terrain store byte vector.
572    pub fn from_vec(bytes: Vec<u8>) -> core::result::Result<Self, TerrainStoreError> {
573        Self::from_backing(ArtifactBytes::Owned(bytes), ChecksumValidation::Verified)
574    }
575
576    /// Parse owned terrain store bytes using a caller-attested checksum.
577    ///
578    /// The byte-based counterpart of [`Self::from_path_attested`], for callers
579    /// that already hold the store bytes (an interface layer, an object-store
580    /// read) alongside a trustworthy content measurement. Same contract:
581    /// structural checks run unconditionally, the per-tile payload hashing is
582    /// replaced by the claim, and the handle reports
583    /// [`DigestProvenance::Attested`] until [`Self::verify`] succeeds.
584    pub fn from_vec_attested(
585        bytes: Vec<u8>,
586        claimed_checksum64: u64,
587    ) -> core::result::Result<Self, TerrainStoreError> {
588        Self::from_backing(
589            ArtifactBytes::Owned(bytes),
590            ChecksumValidation::Attested(claimed_checksum64),
591        )
592    }
593
594    /// Open and parse a terrain store file.
595    ///
596    /// With the `mmap` feature the file is memory-mapped read-only and this
597    /// reader owns the mapping; without it the file is read into memory. The
598    /// entry point is the same either way, so enabling the feature speeds up
599    /// every existing caller rather than asking anyone to migrate.
600    ///
601    /// Mapping is what makes a very large store usable at all: construction
602    /// parses only the header, datum tag, and tile index, and lookups are
603    /// demand-paged, so a reader querying a geographically local region never
604    /// faults in the rest of the file.
605    pub fn from_path(path: impl AsRef<Path>) -> core::result::Result<Self, TerrainStoreError> {
606        let path = path.as_ref();
607
608        #[cfg(feature = "mmap")]
609        {
610            let bytes = crate::artifact_bytes::map_file_read_only(path).map_err(|err| {
611                TerrainStoreError::Io {
612                    path: path.to_path_buf(),
613                    message: err.to_string(),
614                }
615            })?;
616            Self::from_backing(bytes, ChecksumValidation::Verified)
617        }
618
619        #[cfg(not(feature = "mmap"))]
620        {
621            let bytes = fs::read(path).map_err(|err| TerrainStoreError::Io {
622                path: path.to_path_buf(),
623                message: err.to_string(),
624            })?;
625            Self::from_vec(bytes)
626        }
627    }
628
629    /// Open a terrain store using a caller-attested full-store checksum.
630    ///
631    /// This performs the same header, index, dimension, length, and tile-bound
632    /// validation as [`Self::from_path`] without hashing tile payloads. Terrain
633    /// headers do not carry a full-store checksum, so the claim is recorded as
634    /// supplied and can be checked later with [`Self::verify`]. With the `mmap`
635    /// feature the file is memory-mapped read-only; without it the file is read
636    /// into memory.
637    pub fn from_path_attested(
638        path: impl AsRef<Path>,
639        claimed_checksum64: u64,
640    ) -> core::result::Result<Self, TerrainStoreError> {
641        let path = path.as_ref();
642
643        #[cfg(feature = "mmap")]
644        {
645            let bytes = crate::artifact_bytes::map_file_read_only(path).map_err(|err| {
646                TerrainStoreError::Io {
647                    path: path.to_path_buf(),
648                    message: err.to_string(),
649                }
650            })?;
651            Self::from_backing(bytes, ChecksumValidation::Attested(claimed_checksum64))
652        }
653
654        #[cfg(not(feature = "mmap"))]
655        {
656            let bytes = fs::read(path).map_err(|err| TerrainStoreError::Io {
657                path: path.to_path_buf(),
658                message: err.to_string(),
659            })?;
660            Self::from_backing(
661                ArtifactBytes::Owned(bytes),
662                ChecksumValidation::Attested(claimed_checksum64),
663            )
664        }
665    }
666}
667
668impl<'a> MmapTerrain<'a> {
669    /// Parse a borrowed terrain store byte span.
670    ///
671    /// The reader keeps the byte span in place and indexes posting payloads by
672    /// offset. Passing an mmap-backed slice gives a zero-copy reader.
673    pub fn from_bytes(bytes: &'a [u8]) -> core::result::Result<Self, TerrainStoreError> {
674        Self::from_backing(ArtifactBytes::Borrowed(bytes), ChecksumValidation::Verified)
675    }
676
677    fn from_backing(
678        bytes: ArtifactBytes<'a>,
679        checksum_validation: ChecksumValidation,
680    ) -> core::result::Result<Self, TerrainStoreError> {
681        let parsed = parse_store(bytes.as_slice(), checksum_validation)?;
682        Ok(Self {
683            bytes,
684            tiles: parsed.tiles,
685            by_grid: parsed.by_grid,
686            tile_index: parsed.tile_index,
687            tile_ids: parsed.tile_ids,
688            vertical_datum: parsed.vertical_datum,
689            digest_provenance: checksum_validation.digest_provenance(),
690            attested_checksum64: checksum_validation.attested_checksum64(),
691        })
692    }
693
694    /// Borrow the original terrain store bytes.
695    #[must_use]
696    pub fn as_bytes(&self) -> &[u8] {
697        self.bytes.as_slice()
698    }
699
700    /// Whether this reader is backed by a memory map rather than a copy in
701    /// process memory.
702    #[must_use]
703    pub fn is_memory_mapped(&self) -> bool {
704        self.bytes.is_memory_mapped()
705    }
706
707    /// Return the store's file-level vertical datum.
708    #[must_use]
709    pub const fn vertical_datum(&self) -> VerticalDatum {
710        self.vertical_datum
711    }
712
713    /// Borrow the parsed tile index records.
714    #[must_use]
715    pub fn tile_index(&self) -> &[TerrainStoreTileIndex] {
716        &self.tile_index
717    }
718
719    /// Return the number of tiles present in this terrain store.
720    #[must_use]
721    pub fn tile_count(&self) -> usize {
722        self.tile_ids.len()
723    }
724
725    /// Borrow the sorted integer tile ids present in this terrain store.
726    #[must_use]
727    pub fn tile_ids(&self) -> &[TerrainTileId] {
728        &self.tile_ids
729    }
730
731    /// Return who computed the checksum carried by this reader.
732    #[must_use]
733    pub const fn digest_provenance(&self) -> DigestProvenance {
734        self.digest_provenance
735    }
736
737    /// Return the full-store checksum carried by this reader.
738    ///
739    /// Verified readers compute the FNV-1a checksum on demand. Attested readers
740    /// return the caller's claim without hashing the byte span.
741    #[must_use]
742    pub fn checksum64(&self) -> u64 {
743        match self.digest_provenance {
744            DigestProvenance::Verified => terrain_store_checksum64(self.bytes.as_ref()),
745            DigestProvenance::Attested => self
746                .attested_checksum64
747                .expect("attested terrain reader carries a checksum"),
748        }
749    }
750
751    /// Re-verify tile payloads and any caller-attested full-store checksum.
752    ///
753    /// Successful verification changes the digest provenance to
754    /// [`DigestProvenance::Verified`].
755    pub fn verify(&mut self) -> core::result::Result<(), TerrainStoreError> {
756        parse_store(self.bytes.as_ref(), ChecksumValidation::Verified)?;
757        if let Some(expected) = self.attested_checksum64 {
758            let found = terrain_store_checksum64(self.bytes.as_ref());
759            if expected != found {
760                return Err(TerrainStoreError::AttestedChecksumMismatch { expected, found });
761            }
762        }
763        self.digest_provenance = DigestProvenance::Verified;
764        self.attested_checksum64 = None;
765        Ok(())
766    }
767
768    /// Re-serialize this parsed terrain store into canonical bytes.
769    ///
770    /// A store accepted by [`Self::from_bytes`] is already canonical, so this
771    /// returns bytes identical to [`Self::as_bytes`].
772    #[must_use]
773    pub fn to_bytes(&self) -> Vec<u8> {
774        let pending = self
775            .tiles
776            .iter()
777            .map(|tile| PendingTile {
778                lat_index: tile.index.lat_index,
779                lon_index: tile.index.lon_index,
780                min_latitude_deg: tile.index.min_latitude_deg,
781                min_longitude_deg: tile.index.min_longitude_deg,
782                max_latitude_deg: tile.index.max_latitude_deg,
783                max_longitude_deg: tile.index.max_longitude_deg,
784                lon_count: tile.index.lon_count,
785                lat_count: tile.index.lat_count,
786                data: self.tile_payload(tile).to_vec(),
787                vertical_datum: tile.index.vertical_datum,
788            })
789            .collect();
790        build_store(pending).expect("parsed terrain store can be reserialized")
791    }
792
793    /// Return the bilinearly interpolated orthometric height `H` in metres at a
794    /// longitude-first geodetic position in degrees.
795    pub fn height_m(&mut self, longitude_deg: f64, latitude_deg: f64) -> Result<f64> {
796        self.height_m_with_options(longitude_deg, latitude_deg, DtedLookupOptions::default())
797    }
798
799    /// Return the orthometric height `H` in metres at a longitude-first geodetic
800    /// position in degrees using explicit lookup options.
801    pub fn height_m_with_options(
802        &mut self,
803        longitude_deg: f64,
804        latitude_deg: f64,
805        options: DtedLookupOptions,
806    ) -> Result<f64> {
807        self.orthometric_height_m_with_options(longitude_deg, latitude_deg, options)
808            .map(OrthometricHeightM::metres)
809    }
810
811    /// Return the bilinearly interpolated orthometric height `H` in metres as a
812    /// typed value at a longitude-first geodetic position in degrees.
813    pub fn orthometric_height_m(
814        &self,
815        longitude_deg: f64,
816        latitude_deg: f64,
817    ) -> Result<OrthometricHeightM> {
818        self.orthometric_height_m_with_options(
819            longitude_deg,
820            latitude_deg,
821            DtedLookupOptions::default(),
822        )
823    }
824
825    /// Return the orthometric height `H` in metres as a typed value at a
826    /// longitude-first geodetic position in degrees using explicit lookup
827    /// options.
828    pub fn orthometric_height_m_with_options(
829        &self,
830        longitude_deg: f64,
831        latitude_deg: f64,
832        options: DtedLookupOptions,
833    ) -> Result<OrthometricHeightM> {
834        validate_lookup_coordinates(longitude_deg, latitude_deg)?;
835        let Some(tile_idx) = self.resolve_grid(longitude_deg, latitude_deg) else {
836            return Err(missing_terrain_tile(longitude_deg, latitude_deg));
837        };
838        height_from_tile(
839            self.bytes.as_ref(),
840            &self.tiles[tile_idx],
841            longitude_deg,
842            latitude_deg,
843            options,
844        )
845        .map(OrthometricHeightM::new)
846    }
847
848    /// Evaluate `(longitude_deg, latitude_deg)` points in order as orthometric
849    /// heights `H` in metres.
850    ///
851    /// The tuple order is longitude-first, matching [`Self::height_m`]. Each
852    /// output element is independent, so an invalid point or parse failure is
853    /// returned only for that element.
854    pub fn height_batch(
855        &mut self,
856        points: &[(f64, f64)],
857        options: DtedLookupOptions,
858    ) -> Vec<Result<f64>> {
859        self.orthometric_height_batch(points, options)
860            .into_iter()
861            .map(|result| result.map(OrthometricHeightM::metres))
862            .collect()
863    }
864
865    /// Evaluate `(longitude_deg, latitude_deg)` points in order as typed
866    /// orthometric heights `H` in metres.
867    ///
868    /// The tuple order is longitude-first. Each output element is independent,
869    /// so an invalid point or parse failure is returned only for that element.
870    pub fn orthometric_height_batch(
871        &self,
872        points: &[(f64, f64)],
873        options: DtedLookupOptions,
874    ) -> Vec<Result<OrthometricHeightM>> {
875        let mut out = Vec::with_capacity(points.len());
876        let mut current = None;
877
878        for &(longitude_deg, latitude_deg) in points {
879            if let Err(err) = validate_lookup_coordinates(longitude_deg, latitude_deg) {
880                out.push(Err(err));
881                continue;
882            }
883
884            let primary_grid = terrain::terrain_grid(longitude_deg, latitude_deg);
885            if current == Some(primary_grid) {
886                if let Some(&tile_idx) = self.by_grid.get(&primary_grid) {
887                    let tile = &self.tiles[tile_idx];
888                    if tile.contains(longitude_deg, latitude_deg) {
889                        out.push(
890                            height_from_tile(
891                                self.bytes.as_ref(),
892                                tile,
893                                longitude_deg,
894                                latitude_deg,
895                                options,
896                            )
897                            .map(OrthometricHeightM::new),
898                        );
899                        continue;
900                    }
901                }
902            }
903
904            match self.resolve_grid(longitude_deg, latitude_deg) {
905                Some(tile_idx) => {
906                    let tile = &self.tiles[tile_idx];
907                    current = Some((tile.index.lat_index, tile.index.lon_index));
908                    out.push(
909                        height_from_tile(
910                            self.bytes.as_ref(),
911                            tile,
912                            longitude_deg,
913                            latitude_deg,
914                            options,
915                        )
916                        .map(OrthometricHeightM::new),
917                    );
918                }
919                None => {
920                    current = None;
921                    out.push(Err(missing_terrain_tile(longitude_deg, latitude_deg)));
922                }
923            }
924        }
925
926        out
927    }
928
929    /// Return ellipsoidal height `h` in metres using the embedded EGM96
930    /// 1-degree grid for `h = H + N`.
931    ///
932    /// The input position is terrain order `(longitude_deg, latitude_deg)`.
933    /// Internally, the geoid call is made with `(latitude_deg, longitude_deg)`.
934    /// The embedded EGM96 1-degree grid agrees with the full EGM96
935    /// 15-arcminute grid to about 0.4 m RMS.
936    pub fn ellipsoidal_height_m(
937        &self,
938        longitude_deg: f64,
939        latitude_deg: f64,
940    ) -> core::result::Result<EllipsoidalHeightM, TerrainDatumError> {
941        self.ellipsoidal_height_m_with_options(
942            longitude_deg,
943            latitude_deg,
944            DtedLookupOptions::default(),
945        )
946    }
947
948    /// Return ellipsoidal height `h` in metres using the embedded EGM96
949    /// 1-degree grid for `h = H + N` and explicit terrain lookup options.
950    ///
951    /// The input position is terrain order `(longitude_deg, latitude_deg)`.
952    /// Internally, the geoid call is made with `(latitude_deg, longitude_deg)`.
953    pub fn ellipsoidal_height_m_with_options(
954        &self,
955        longitude_deg: f64,
956        latitude_deg: f64,
957        options: DtedLookupOptions,
958    ) -> core::result::Result<EllipsoidalHeightM, TerrainDatumError> {
959        self.ellipsoidal_height_m_with_model(
960            longitude_deg,
961            latitude_deg,
962            options,
963            TerrainGeoidModel::Egm96OneDegree,
964        )
965    }
966
967    /// Return ellipsoidal height `h` in metres using an explicit geoid tier for
968    /// `h = H + N`.
969    ///
970    /// The input position is terrain order `(longitude_deg, latitude_deg)`.
971    /// Internally, the geoid call is made with `(latitude_deg, longitude_deg)`.
972    /// Choosing [`TerrainGeoidModel::Egm96FifteenMinute`] requires a loaded
973    /// `WW15MGH.DAC` grid and never falls back to the embedded EGM96 1-degree
974    /// grid.
975    pub fn ellipsoidal_height_m_with_model(
976        &self,
977        longitude_deg: f64,
978        latitude_deg: f64,
979        options: DtedLookupOptions,
980        geoid: TerrainGeoidModel<'_>,
981    ) -> core::result::Result<EllipsoidalHeightM, TerrainDatumError> {
982        let orthometric = self
983            .orthometric_height_m_with_options(longitude_deg, latitude_deg, options)
984            .map_err(TerrainDatumError::Terrain)?;
985        orthometric.to_ellipsoidal_height_deg(latitude_deg, longitude_deg, geoid)
986    }
987
988    fn resolve_grid(&self, longitude_deg: f64, latitude_deg: f64) -> Option<usize> {
989        for grid_idx in terrain_grid_candidates(longitude_deg, latitude_deg) {
990            if let Some(&tile_idx) = self.by_grid.get(&grid_idx) {
991                if self.tiles[tile_idx].contains(longitude_deg, latitude_deg) {
992                    return Some(tile_idx);
993                }
994            }
995        }
996        None
997    }
998
999    fn tile_payload(&self, tile: &MmapTile) -> &[u8] {
1000        let start = tile.index.data_offset as usize;
1001        let end = start + tile.index.data_len as usize;
1002        &self.bytes.as_slice()[start..end]
1003    }
1004}
1005
1006/// Convert a DTED tile tree into canonical memory-mappable terrain store bytes.
1007///
1008/// Input `.dt2` files are discovered recursively below `root`, following
1009/// symlinked directories and symlinked `.dt2` files. A symlinked file is treated
1010/// as DTED when either the link name or the resolved target name ends in
1011/// `.dt2`. Tiles are then sorted by integer tile id. DTED signed-magnitude
1012/// postings are decoded once into `i16` orthometric metres. DTED negative zero
1013/// and SRTM voids already encoded as zero remain zero, matching the existing
1014/// lazy DTED reader.
1015pub fn dted_tree_to_mmap_store(
1016    root: impl AsRef<Path>,
1017) -> core::result::Result<Vec<u8>, TerrainStoreError> {
1018    let root = root.as_ref();
1019    let mut paths = Vec::new();
1020    collect_dted_tile_paths(root, &mut paths)?;
1021    dted_paths_to_mmap_store(paths)
1022}
1023
1024/// Convert an explicit DTED tile list into canonical memory-mappable terrain
1025/// store bytes.
1026///
1027/// Each entry supplies the expected integer tile id and the DTED `.dt2` path.
1028/// The converter parses each DTED header and fails if the file origin does not
1029/// match the supplied id. The decoded payload, sorting, duplicate detection,
1030/// alignment, and checksums are the same as [`dted_tree_to_mmap_store`].
1031pub fn dted_tile_list_to_mmap_store(
1032    entries: &[DtedTileListEntry],
1033) -> core::result::Result<Vec<u8>, TerrainStoreError> {
1034    let mut pending = Vec::with_capacity(entries.len());
1035    for entry in entries {
1036        pending.push(pending_tile_from_dted_path(
1037            &entry.path,
1038            Some(entry.tile_id),
1039        )?);
1040    }
1041    build_store(pending)
1042}
1043
1044/// Convert a DTED tile tree and write canonical memory-mappable terrain store
1045/// bytes to `output_path`.
1046///
1047/// Symlinked directories and symlinked `.dt2` files below `root` are followed.
1048/// A symlinked file is treated as DTED when either the link name or the resolved
1049/// target name ends in `.dt2`.
1050pub fn write_dted_tree_to_mmap_store(
1051    root: impl AsRef<Path>,
1052    output_path: impl AsRef<Path>,
1053) -> core::result::Result<(), TerrainStoreError> {
1054    let bytes = dted_tree_to_mmap_store(root)?;
1055    let output_path = output_path.as_ref();
1056    fs::write(output_path, &bytes).map_err(|err| TerrainStoreError::Io {
1057        path: output_path.to_path_buf(),
1058        message: err.to_string(),
1059    })
1060}
1061
1062/// Convert an explicit DTED tile list and write canonical memory-mappable
1063/// terrain store bytes to `output_path`.
1064pub fn write_dted_tile_list_to_mmap_store(
1065    entries: &[DtedTileListEntry],
1066    output_path: impl AsRef<Path>,
1067) -> core::result::Result<(), TerrainStoreError> {
1068    let bytes = dted_tile_list_to_mmap_store(entries)?;
1069    let output_path = output_path.as_ref();
1070    fs::write(output_path, &bytes).map_err(|err| TerrainStoreError::Io {
1071        path: output_path.to_path_buf(),
1072        message: err.to_string(),
1073    })
1074}
1075
1076/// Return an FNV-1a checksum for terrain store bytes.
1077///
1078/// This checksum is for deterministic local verification and is not a
1079/// cryptographic digest.
1080#[must_use]
1081pub fn terrain_store_checksum64(bytes: &[u8]) -> u64 {
1082    fnv1a64(bytes)
1083}
1084
1085#[derive(Debug)]
1086struct PendingTile {
1087    lat_index: i32,
1088    lon_index: i32,
1089    min_latitude_deg: f64,
1090    min_longitude_deg: f64,
1091    max_latitude_deg: f64,
1092    max_longitude_deg: f64,
1093    lon_count: u32,
1094    lat_count: u32,
1095    data: Vec<u8>,
1096    vertical_datum: VerticalDatum,
1097}
1098
1099#[derive(Debug)]
1100struct ParsedStore {
1101    vertical_datum: VerticalDatum,
1102    tiles: Vec<MmapTile>,
1103    by_grid: HashMap<(i32, i32), usize>,
1104    tile_index: Vec<TerrainStoreTileIndex>,
1105    tile_ids: Vec<TerrainTileId>,
1106}
1107
1108fn height_from_tile(
1109    bytes: &[u8],
1110    tile: &MmapTile,
1111    longitude_deg: f64,
1112    latitude_deg: f64,
1113    options: DtedLookupOptions,
1114) -> Result<f64> {
1115    if options.interpolation == DtedInterpolation::NearestPosting {
1116        return tile
1117            .get_elevation(bytes, longitude_deg, latitude_deg)
1118            .map(|v| v as f64);
1119    }
1120
1121    let postings_per_deg_lon = tile.index.lon_count as usize - 1;
1122    let postings_per_deg_lat = tile.index.lat_count as usize - 1;
1123
1124    let lon_idx = (longitude_deg - tile.index.min_longitude_deg) * postings_per_deg_lon as f64;
1125    let lat_idx = (latitude_deg - tile.index.min_latitude_deg) * postings_per_deg_lat as f64;
1126    let lon_lo = lon_idx.floor() as i64;
1127    let lat_lo = lat_idx.floor() as i64;
1128    let fx = lon_idx - lon_lo as f64;
1129    let fy = lat_idx - lat_lo as f64;
1130
1131    let mut z = 0.0;
1132    for (di, wx) in [(0i64, 1.0 - fx), (1i64, fx)] {
1133        for (dj, wy) in [(0i64, 1.0 - fy), (1i64, fy)] {
1134            let w = wx * wy;
1135            if w == 0.0 {
1136                continue;
1137            }
1138            let posting_lon =
1139                tile.index.min_longitude_deg + (lon_lo + di) as f64 / postings_per_deg_lon as f64;
1140            let posting_lat =
1141                tile.index.min_latitude_deg + (lat_lo + dj) as f64 / postings_per_deg_lat as f64;
1142            z += w * f64::from(tile.get_elevation(bytes, posting_lon, posting_lat)?);
1143        }
1144    }
1145    Ok(z)
1146}
1147
1148fn dted_paths_to_mmap_store(
1149    mut paths: Vec<PathBuf>,
1150) -> core::result::Result<Vec<u8>, TerrainStoreError> {
1151    paths.sort();
1152
1153    let mut pending = Vec::with_capacity(paths.len());
1154    for path in paths {
1155        pending.push(pending_tile_from_dted_path(&path, None)?);
1156    }
1157    build_store(pending)
1158}
1159
1160fn pending_tile_from_dted_path(
1161    path: &Path,
1162    expected_id: Option<TerrainTileId>,
1163) -> core::result::Result<PendingTile, TerrainStoreError> {
1164    let tile = DtedTile::from_path(path).map_err(|reason| TerrainStoreError::Parse {
1165        reason: format!("{}: {reason}", path.display()),
1166    })?;
1167    let decoded = tile
1168        .decoded_postings_lon_major()
1169        .map_err(|reason| TerrainStoreError::Parse {
1170            reason: format!("{}: {reason}", path.display()),
1171        })?;
1172    let mut data = Vec::with_capacity(decoded.len() * 2);
1173    for posting in decoded {
1174        data.extend_from_slice(&posting.to_le_bytes());
1175    }
1176    let lat_index = tile.origin_latitude().floor() as i32;
1177    let lon_index = tile.origin_longitude().floor() as i32;
1178    let found = TerrainTileId::new(lat_index, lon_index);
1179    if let Some(expected) = expected_id {
1180        if expected != found {
1181            return Err(TerrainStoreError::TileIdMismatch {
1182                path: path.to_path_buf(),
1183                expected,
1184                found,
1185            });
1186        }
1187    }
1188    Ok(PendingTile {
1189        lat_index,
1190        lon_index,
1191        min_latitude_deg: tile.origin_latitude(),
1192        min_longitude_deg: tile.origin_longitude(),
1193        max_latitude_deg: tile.origin_latitude() + 1.0,
1194        max_longitude_deg: tile.origin_longitude() + 1.0,
1195        lon_count: u32::try_from(tile.lon_count()).map_err(|_| TerrainStoreError::Parse {
1196            reason: format!("{} longitude count exceeds u32", path.display()),
1197        })?,
1198        lat_count: u32::try_from(tile.lat_count()).map_err(|_| TerrainStoreError::Parse {
1199            reason: format!("{} latitude count exceeds u32", path.display()),
1200        })?,
1201        data,
1202        vertical_datum: VerticalDatum::Egm96MslOrthometric,
1203    })
1204}
1205
1206fn collect_dted_tile_paths(
1207    root: &Path,
1208    out: &mut Vec<PathBuf>,
1209) -> core::result::Result<(), TerrainStoreError> {
1210    let mut visited_dirs = HashSet::new();
1211    collect_dted_tile_paths_inner(root, out, &mut visited_dirs)
1212}
1213
1214fn collect_dted_tile_paths_inner(
1215    path: &Path,
1216    out: &mut Vec<PathBuf>,
1217    visited_dirs: &mut HashSet<PathBuf>,
1218) -> core::result::Result<(), TerrainStoreError> {
1219    let metadata = fs::metadata(path).map_err(|err| TerrainStoreError::Io {
1220        path: path.to_path_buf(),
1221        message: err.to_string(),
1222    })?;
1223
1224    if metadata.is_dir() {
1225        let canonical = fs::canonicalize(path).map_err(|err| TerrainStoreError::Io {
1226            path: path.to_path_buf(),
1227            message: err.to_string(),
1228        })?;
1229        if !visited_dirs.insert(canonical) {
1230            return Ok(());
1231        }
1232        let entries = fs::read_dir(path).map_err(|err| TerrainStoreError::Io {
1233            path: path.to_path_buf(),
1234            message: err.to_string(),
1235        })?;
1236        for entry in entries {
1237            let entry = entry.map_err(|err| TerrainStoreError::Io {
1238                path: path.to_path_buf(),
1239                message: err.to_string(),
1240            })?;
1241            collect_dted_tile_paths_inner(&entry.path(), out, visited_dirs)?;
1242        }
1243    } else if metadata.is_file() && is_dted_tile_source(path)? {
1244        out.push(path.to_path_buf());
1245    }
1246    Ok(())
1247}
1248
1249fn is_dted_tile_source(path: &Path) -> core::result::Result<bool, TerrainStoreError> {
1250    if is_dted_tile_path(path) {
1251        return Ok(true);
1252    }
1253
1254    let canonical = fs::canonicalize(path).map_err(|err| TerrainStoreError::Io {
1255        path: path.to_path_buf(),
1256        message: err.to_string(),
1257    })?;
1258    Ok(is_dted_tile_path(&canonical))
1259}
1260
1261fn is_dted_tile_path(path: &Path) -> bool {
1262    path.file_name()
1263        .and_then(|name| name.to_str())
1264        .is_some_and(|name| name.ends_with(".dt2"))
1265}
1266
1267fn parse_store(
1268    bytes: &[u8],
1269    checksum_validation: ChecksumValidation,
1270) -> core::result::Result<ParsedStore, TerrainStoreError> {
1271    if bytes.len() < STORE_HEADER_LEN {
1272        return Err(TerrainStoreError::Parse {
1273            reason: format!(
1274                "store has {} bytes but needs at least {STORE_HEADER_LEN}",
1275                bytes.len()
1276            ),
1277        });
1278    }
1279    if &bytes[..STORE_MAGIC.len()] != STORE_MAGIC {
1280        return Err(TerrainStoreError::Parse {
1281            reason: "missing terrain store magic".to_string(),
1282        });
1283    }
1284    let version = read_u16(bytes, HEADER_VERSION_OFFSET)?;
1285    if version != STORE_VERSION {
1286        return Err(TerrainStoreError::UnsupportedVersion { version });
1287    }
1288    ensure_zero(bytes, 11, 12, "header reserved byte")?;
1289    ensure_zero(bytes, 40, STORE_HEADER_LEN, "header reserved bytes")?;
1290
1291    let vertical_datum = VerticalDatum::from_tag(bytes[HEADER_DATUM_OFFSET])?;
1292    let tile_count = read_u32(bytes, HEADER_TILE_COUNT_OFFSET)? as usize;
1293    let index_offset = read_u64(bytes, HEADER_INDEX_OFFSET_OFFSET)? as usize;
1294    let data_offset = read_u64(bytes, HEADER_DATA_OFFSET_OFFSET)? as usize;
1295    let total_len = read_u64(bytes, HEADER_TOTAL_LEN_OFFSET)? as usize;
1296    if total_len != bytes.len() {
1297        return Err(TerrainStoreError::Parse {
1298            reason: format!(
1299                "header total length {total_len} does not match {}",
1300                bytes.len()
1301            ),
1302        });
1303    }
1304    if index_offset != STORE_HEADER_LEN {
1305        return Err(TerrainStoreError::Parse {
1306            reason: format!("index offset must be {STORE_HEADER_LEN}, got {index_offset}"),
1307        });
1308    }
1309
1310    let index_len = tile_count
1311        .checked_mul(STORE_INDEX_RECORD_LEN)
1312        .ok_or_else(|| TerrainStoreError::Parse {
1313            reason: "tile index length overflows usize".to_string(),
1314        })?;
1315    let index_end =
1316        index_offset
1317            .checked_add(index_len)
1318            .ok_or_else(|| TerrainStoreError::Parse {
1319                reason: "tile index end overflows usize".to_string(),
1320            })?;
1321    if index_end > bytes.len() {
1322        return Err(TerrainStoreError::Parse {
1323            reason: "tile index extends past store length".to_string(),
1324        });
1325    }
1326    let expected_data_offset = align_up(index_end, STORE_ALIGNMENT)?;
1327    if data_offset != expected_data_offset {
1328        return Err(TerrainStoreError::Parse {
1329            reason: format!("data offset must be {expected_data_offset}, got {data_offset}"),
1330        });
1331    }
1332    ensure_zero(bytes, index_end, data_offset, "index padding")?;
1333
1334    let mut tiles = Vec::with_capacity(tile_count);
1335    let mut tile_index = Vec::with_capacity(tile_count);
1336    let mut tile_ids = Vec::with_capacity(tile_count);
1337    let mut by_grid = HashMap::with_capacity(tile_count);
1338    let mut previous_id = None;
1339    let mut expected_next = data_offset;
1340
1341    for idx in 0..tile_count {
1342        let record_offset = index_offset + idx * STORE_INDEX_RECORD_LEN;
1343        let record = &bytes[record_offset..record_offset + STORE_INDEX_RECORD_LEN];
1344        let lat_index = read_i32(record, INDEX_LAT_OFFSET)?;
1345        let lon_index = read_i32(record, INDEX_LON_OFFSET)?;
1346        let tile_id = (lat_index, lon_index);
1347        if previous_id.is_some_and(|previous| tile_id <= previous) {
1348            return Err(TerrainStoreError::Parse {
1349                reason: "tile index records are not strictly sorted".to_string(),
1350            });
1351        }
1352        previous_id = Some(tile_id);
1353
1354        let lon_count = read_u32(record, INDEX_LON_COUNT_OFFSET)?;
1355        let lat_count = read_u32(record, INDEX_LAT_COUNT_OFFSET)?;
1356        if lon_count < 2 || lat_count < 2 {
1357            return Err(TerrainStoreError::Parse {
1358                reason: format!(
1359                    "tile ({lat_index},{lon_index}) has invalid dimensions lon_count={lon_count} lat_count={lat_count}"
1360                ),
1361            });
1362        }
1363        let offset = read_u64(record, INDEX_DATA_OFFSET_OFFSET)? as usize;
1364        let data_len = read_u64(record, INDEX_DATA_LEN_OFFSET)? as usize;
1365        let expected_len = (lon_count as usize)
1366            .checked_mul(lat_count as usize)
1367            .and_then(|count| count.checked_mul(2))
1368            .ok_or_else(|| TerrainStoreError::Parse {
1369                reason: format!("tile ({lat_index},{lon_index}) data length overflows usize"),
1370            })?;
1371        if data_len != expected_len {
1372            return Err(TerrainStoreError::Parse {
1373                reason: format!(
1374                    "tile ({lat_index},{lon_index}) data length must be {expected_len}, got {data_len}"
1375                ),
1376            });
1377        }
1378
1379        let expected_offset = align_up(expected_next, STORE_ALIGNMENT)?;
1380        ensure_zero(bytes, expected_next, expected_offset, "tile padding")?;
1381        if offset != expected_offset {
1382            return Err(TerrainStoreError::Parse {
1383                reason: format!(
1384                    "tile ({lat_index},{lon_index}) data offset must be {expected_offset}, got {offset}"
1385                ),
1386            });
1387        }
1388        let end = offset
1389            .checked_add(data_len)
1390            .ok_or_else(|| TerrainStoreError::Parse {
1391                reason: format!("tile ({lat_index},{lon_index}) data end overflows usize"),
1392            })?;
1393        if end > bytes.len() {
1394            return Err(TerrainStoreError::Parse {
1395                reason: format!("tile ({lat_index},{lon_index}) data extends past store length"),
1396            });
1397        }
1398
1399        let checksum64 = read_u64(record, INDEX_CHECKSUM_OFFSET)?;
1400        if checksum_validation.verifies_payloads() {
1401            let found = fnv1a64(&bytes[offset..end]);
1402            if found != checksum64 {
1403                return Err(TerrainStoreError::Checksum {
1404                    lat_index,
1405                    lon_index,
1406                    expected: checksum64,
1407                    found,
1408                });
1409            }
1410        }
1411
1412        let min_latitude_deg = read_f64(record, INDEX_MIN_LAT_OFFSET)?;
1413        let min_longitude_deg = read_f64(record, INDEX_MIN_LON_OFFSET)?;
1414        let max_latitude_deg = read_f64(record, INDEX_MAX_LAT_OFFSET)?;
1415        let max_longitude_deg = read_f64(record, INDEX_MAX_LON_OFFSET)?;
1416        for (field, value) in [
1417            ("min_latitude_deg", min_latitude_deg),
1418            ("min_longitude_deg", min_longitude_deg),
1419            ("max_latitude_deg", max_latitude_deg),
1420            ("max_longitude_deg", max_longitude_deg),
1421        ] {
1422            if !value.is_finite() {
1423                return Err(TerrainStoreError::Parse {
1424                    reason: format!("tile ({lat_index},{lon_index}) {field} is not finite"),
1425                });
1426            }
1427        }
1428        let tile_datum = VerticalDatum::from_tag(record[INDEX_DATUM_OFFSET])?;
1429        if tile_datum != vertical_datum {
1430            return Err(TerrainStoreError::Parse {
1431                reason: format!("tile ({lat_index},{lon_index}) datum differs from header"),
1432            });
1433        }
1434        ensure_zero(
1435            record,
1436            INDEX_DATUM_OFFSET + 1,
1437            STORE_INDEX_RECORD_LEN,
1438            "tile index reserved bytes",
1439        )?;
1440
1441        let index = TerrainStoreTileIndex {
1442            lat_index,
1443            lon_index,
1444            min_longitude_deg,
1445            min_latitude_deg,
1446            max_longitude_deg,
1447            max_latitude_deg,
1448            lon_count,
1449            lat_count,
1450            data_offset: offset as u64,
1451            data_len: data_len as u64,
1452            checksum64,
1453            vertical_datum: tile_datum,
1454        };
1455        by_grid.insert(tile_id, tiles.len());
1456        tiles.push(MmapTile { index });
1457        tile_index.push(index);
1458        tile_ids.push(TerrainTileId::new(lat_index, lon_index));
1459        expected_next = end;
1460    }
1461
1462    if expected_next != bytes.len() {
1463        return Err(TerrainStoreError::Parse {
1464            reason: format!(
1465                "store has trailing bytes: expected length {expected_next}, got {}",
1466                bytes.len()
1467            ),
1468        });
1469    }
1470
1471    Ok(ParsedStore {
1472        vertical_datum,
1473        tiles,
1474        by_grid,
1475        tile_index,
1476        tile_ids,
1477    })
1478}
1479
1480fn missing_terrain_tile(longitude_deg: f64, latitude_deg: f64) -> Error {
1481    let (lat_index, lon_index) = terrain::terrain_grid(longitude_deg, latitude_deg);
1482    Error::MissingTerrainTile {
1483        lat_index,
1484        lon_index,
1485    }
1486}
1487
1488fn build_store(mut tiles: Vec<PendingTile>) -> core::result::Result<Vec<u8>, TerrainStoreError> {
1489    tiles.sort_by_key(|tile| (tile.lat_index, tile.lon_index));
1490    for pair in tiles.windows(2) {
1491        if (pair[0].lat_index, pair[0].lon_index) == (pair[1].lat_index, pair[1].lon_index) {
1492            return Err(TerrainStoreError::DuplicateTile {
1493                lat_index: pair[0].lat_index,
1494                lon_index: pair[0].lon_index,
1495            });
1496        }
1497    }
1498
1499    let index_end = STORE_HEADER_LEN
1500        .checked_add(
1501            tiles
1502                .len()
1503                .checked_mul(STORE_INDEX_RECORD_LEN)
1504                .ok_or_else(|| TerrainStoreError::Parse {
1505                    reason: "tile index length overflows usize".to_string(),
1506                })?,
1507        )
1508        .ok_or_else(|| TerrainStoreError::Parse {
1509            reason: "tile index end overflows usize".to_string(),
1510        })?;
1511    let data_offset = align_up(index_end, STORE_ALIGNMENT)?;
1512    let mut offsets = Vec::with_capacity(tiles.len());
1513    let mut cursor = data_offset;
1514    for tile in &tiles {
1515        cursor = align_up(cursor, STORE_ALIGNMENT)?;
1516        offsets.push(cursor);
1517        cursor = cursor
1518            .checked_add(tile.data.len())
1519            .ok_or_else(|| TerrainStoreError::Parse {
1520                reason: "store length overflows usize".to_string(),
1521            })?;
1522    }
1523
1524    let mut out = vec![0u8; cursor];
1525    out[..STORE_MAGIC.len()].copy_from_slice(STORE_MAGIC);
1526    write_u16(&mut out, HEADER_VERSION_OFFSET, STORE_VERSION);
1527    out[HEADER_DATUM_OFFSET] = VerticalDatum::Egm96MslOrthometric.tag();
1528    write_u32(
1529        &mut out,
1530        HEADER_TILE_COUNT_OFFSET,
1531        u32::try_from(tiles.len()).map_err(|_| TerrainStoreError::Parse {
1532            reason: "tile count exceeds u32".to_string(),
1533        })?,
1534    );
1535    write_u64(
1536        &mut out,
1537        HEADER_INDEX_OFFSET_OFFSET,
1538        STORE_HEADER_LEN as u64,
1539    );
1540    write_u64(&mut out, HEADER_DATA_OFFSET_OFFSET, data_offset as u64);
1541    write_u64(&mut out, HEADER_TOTAL_LEN_OFFSET, cursor as u64);
1542
1543    for (idx, tile) in tiles.iter().enumerate() {
1544        let record_offset = STORE_HEADER_LEN + idx * STORE_INDEX_RECORD_LEN;
1545        let offset = offsets[idx];
1546        let data_len = tile.data.len();
1547        let expected_len = (tile.lon_count as usize)
1548            .checked_mul(tile.lat_count as usize)
1549            .and_then(|count| count.checked_mul(2))
1550            .ok_or_else(|| TerrainStoreError::Parse {
1551                reason: format!(
1552                    "tile ({},{}) data length overflows usize",
1553                    tile.lat_index, tile.lon_index
1554                ),
1555            })?;
1556        if data_len != expected_len {
1557            return Err(TerrainStoreError::Parse {
1558                reason: format!(
1559                    "tile ({},{}) data length must be {expected_len}, got {data_len}",
1560                    tile.lat_index, tile.lon_index
1561                ),
1562            });
1563        }
1564
1565        let record = &mut out[record_offset..record_offset + STORE_INDEX_RECORD_LEN];
1566        write_i32(record, INDEX_LAT_OFFSET, tile.lat_index);
1567        write_i32(record, INDEX_LON_OFFSET, tile.lon_index);
1568        write_u32(record, INDEX_LON_COUNT_OFFSET, tile.lon_count);
1569        write_u32(record, INDEX_LAT_COUNT_OFFSET, tile.lat_count);
1570        write_u64(record, INDEX_DATA_OFFSET_OFFSET, offset as u64);
1571        write_u64(record, INDEX_DATA_LEN_OFFSET, data_len as u64);
1572        write_u64(record, INDEX_CHECKSUM_OFFSET, fnv1a64(&tile.data));
1573        write_f64(record, INDEX_MIN_LAT_OFFSET, tile.min_latitude_deg);
1574        write_f64(record, INDEX_MIN_LON_OFFSET, tile.min_longitude_deg);
1575        write_f64(record, INDEX_MAX_LAT_OFFSET, tile.max_latitude_deg);
1576        write_f64(record, INDEX_MAX_LON_OFFSET, tile.max_longitude_deg);
1577        record[INDEX_DATUM_OFFSET] = tile.vertical_datum.tag();
1578        out[offset..offset + data_len].copy_from_slice(&tile.data);
1579    }
1580
1581    Ok(out)
1582}
1583
1584fn align_up(value: usize, alignment: usize) -> core::result::Result<usize, TerrainStoreError> {
1585    let rem = value % alignment;
1586    if rem == 0 {
1587        Ok(value)
1588    } else {
1589        value
1590            .checked_add(alignment - rem)
1591            .ok_or_else(|| TerrainStoreError::Parse {
1592                reason: "aligned offset overflows usize".to_string(),
1593            })
1594    }
1595}
1596
1597fn ensure_zero(
1598    bytes: &[u8],
1599    start: usize,
1600    end: usize,
1601    context: &str,
1602) -> core::result::Result<(), TerrainStoreError> {
1603    if start > end || end > bytes.len() {
1604        return Err(TerrainStoreError::Parse {
1605            reason: format!("{context} range is out of bounds"),
1606        });
1607    }
1608    if bytes[start..end].iter().any(|&byte| byte != 0) {
1609        return Err(TerrainStoreError::Parse {
1610            reason: format!("{context} must be zero-filled"),
1611        });
1612    }
1613    Ok(())
1614}
1615
1616fn fnv1a64(bytes: &[u8]) -> u64 {
1617    bytes.iter().fold(FNV_OFFSET_BASIS, |hash, byte| {
1618        (hash ^ u64::from(*byte)).wrapping_mul(FNV_PRIME)
1619    })
1620}
1621
1622fn read_u16(bytes: &[u8], offset: usize) -> core::result::Result<u16, TerrainStoreError> {
1623    Ok(u16::from_le_bytes(read_array(bytes, offset)?))
1624}
1625
1626fn read_u32(bytes: &[u8], offset: usize) -> core::result::Result<u32, TerrainStoreError> {
1627    Ok(u32::from_le_bytes(read_array(bytes, offset)?))
1628}
1629
1630fn read_i32(bytes: &[u8], offset: usize) -> core::result::Result<i32, TerrainStoreError> {
1631    Ok(i32::from_le_bytes(read_array(bytes, offset)?))
1632}
1633
1634fn read_u64(bytes: &[u8], offset: usize) -> core::result::Result<u64, TerrainStoreError> {
1635    Ok(u64::from_le_bytes(read_array(bytes, offset)?))
1636}
1637
1638fn read_f64(bytes: &[u8], offset: usize) -> core::result::Result<f64, TerrainStoreError> {
1639    Ok(f64::from_le_bytes(read_array(bytes, offset)?))
1640}
1641
1642fn read_array<const N: usize>(
1643    bytes: &[u8],
1644    offset: usize,
1645) -> core::result::Result<[u8; N], TerrainStoreError> {
1646    let end = offset
1647        .checked_add(N)
1648        .ok_or_else(|| TerrainStoreError::Parse {
1649            reason: "numeric field offset overflows usize".to_string(),
1650        })?;
1651    let slice = bytes
1652        .get(offset..end)
1653        .ok_or_else(|| TerrainStoreError::Parse {
1654            reason: "numeric field extends past record".to_string(),
1655        })?;
1656    slice.try_into().map_err(|_| TerrainStoreError::Parse {
1657        reason: "numeric field has wrong length".to_string(),
1658    })
1659}
1660
1661fn write_u16(bytes: &mut [u8], offset: usize, value: u16) {
1662    bytes[offset..offset + 2].copy_from_slice(&value.to_le_bytes());
1663}
1664
1665fn write_u32(bytes: &mut [u8], offset: usize, value: u32) {
1666    bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
1667}
1668
1669fn write_i32(bytes: &mut [u8], offset: usize, value: i32) {
1670    bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
1671}
1672
1673fn write_u64(bytes: &mut [u8], offset: usize, value: u64) {
1674    bytes[offset..offset + 8].copy_from_slice(&value.to_le_bytes());
1675}
1676
1677fn write_f64(bytes: &mut [u8], offset: usize, value: f64) {
1678    bytes[offset..offset + 8].copy_from_slice(&value.to_le_bytes());
1679}