1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
57pub enum VerticalDatum {
58 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#[derive(Clone, Copy, Debug, PartialEq)]
83pub struct OrthometricHeightM {
84 pub value_m: f64,
86}
87
88impl OrthometricHeightM {
89 #[must_use]
91 pub const fn new(value_m: f64) -> Self {
92 Self { value_m }
93 }
94
95 #[must_use]
97 pub const fn metres(self) -> f64 {
98 self.value_m
99 }
100
101 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 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#[derive(Clone, Copy, Debug, PartialEq)]
143pub struct EllipsoidalHeightM {
144 pub value_m: f64,
146}
147
148impl EllipsoidalHeightM {
149 #[must_use]
151 pub const fn new(value_m: f64) -> Self {
152 Self { value_m }
153 }
154
155 #[must_use]
157 pub const fn metres(self) -> f64 {
158 self.value_m
159 }
160}
161
162#[derive(Clone, Debug, PartialEq)]
167pub struct Egm96FifteenMinuteGeoid {
168 grid: GeoidGrid,
169}
170
171impl Egm96FifteenMinuteGeoid {
172 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 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 #[must_use]
209 pub const fn grid(&self) -> &GeoidGrid {
210 &self.grid
211 }
212}
213
214#[derive(Clone, Copy, Debug)]
217pub enum TerrainGeoidModel<'a> {
218 Egm96OneDegree,
223 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#[derive(Debug, Clone, PartialEq)]
251pub enum TerrainDatumError {
252 Terrain(Error),
254 Geoid(GeoidError),
256 Io {
258 path: PathBuf,
260 message: String,
262 },
263 MissingEgm96Dac {
265 path: PathBuf,
267 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#[derive(Clone, Copy, Debug, PartialEq)]
297pub struct TerrainStoreTileIndex {
298 pub lat_index: i32,
300 pub lon_index: i32,
303 pub min_longitude_deg: f64,
305 pub min_latitude_deg: f64,
307 pub max_longitude_deg: f64,
309 pub max_latitude_deg: f64,
311 pub lon_count: u32,
313 pub lat_count: u32,
315 pub data_offset: u64,
317 pub data_len: u64,
319 pub checksum64: u64,
321 pub vertical_datum: VerticalDatum,
323}
324
325#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
327pub struct TerrainTileId {
328 pub lat_index: i32,
330 pub lon_index: i32,
333}
334
335impl TerrainTileId {
336 #[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#[derive(Clone, Debug, PartialEq, Eq)]
348pub struct DtedTileListEntry {
349 pub tile_id: TerrainTileId,
351 pub path: PathBuf,
353}
354
355impl DtedTileListEntry {
356 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq)]
374pub enum TerrainStoreError {
375 Io {
377 path: PathBuf,
379 message: String,
381 },
382 Parse {
384 reason: String,
386 },
387 UnsupportedVersion {
389 version: u16,
391 },
392 UnsupportedDatum {
394 tag: u8,
396 },
397 DuplicateTile {
399 lat_index: i32,
401 lon_index: i32,
403 },
404 TileIdMismatch {
406 path: PathBuf,
408 expected: TerrainTileId,
410 found: TerrainTileId,
412 },
413 Checksum {
415 lat_index: i32,
417 lon_index: i32,
419 expected: u64,
421 found: u64,
423 },
424 AttestedChecksumMismatch {
426 expected: u64,
428 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#[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 pub fn from_vec(bytes: Vec<u8>) -> core::result::Result<Self, TerrainStoreError> {
575 Self::from_backing(ArtifactBytes::Owned(bytes), ChecksumValidation::Verified)
576 }
577
578 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 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 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 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 #[must_use]
698 pub fn as_bytes(&self) -> &[u8] {
699 self.bytes.as_slice()
700 }
701
702 #[must_use]
705 pub fn is_memory_mapped(&self) -> bool {
706 self.bytes.is_memory_mapped()
707 }
708
709 #[must_use]
711 pub const fn vertical_datum(&self) -> VerticalDatum {
712 self.vertical_datum
713 }
714
715 #[must_use]
717 pub fn tile_index(&self) -> &[TerrainStoreTileIndex] {
718 &self.tile_index
719 }
720
721 #[must_use]
723 pub fn tile_count(&self) -> usize {
724 self.tile_ids.len()
725 }
726
727 #[must_use]
729 pub fn tile_ids(&self) -> &[TerrainTileId] {
730 &self.tile_ids
731 }
732
733 #[must_use]
735 pub const fn digest_provenance(&self) -> DigestProvenance {
736 self.digest_provenance
737 }
738
739 #[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 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 #[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 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 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 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 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 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 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 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 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 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
1008pub 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
1026pub 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
1046pub 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
1064pub 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#[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}