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::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#[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 pub fn from_vec(bytes: Vec<u8>) -> core::result::Result<Self, TerrainStoreError> {
573 Self::from_backing(ArtifactBytes::Owned(bytes), ChecksumValidation::Verified)
574 }
575
576 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 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 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 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 #[must_use]
696 pub fn as_bytes(&self) -> &[u8] {
697 self.bytes.as_slice()
698 }
699
700 #[must_use]
703 pub fn is_memory_mapped(&self) -> bool {
704 self.bytes.is_memory_mapped()
705 }
706
707 #[must_use]
709 pub const fn vertical_datum(&self) -> VerticalDatum {
710 self.vertical_datum
711 }
712
713 #[must_use]
715 pub fn tile_index(&self) -> &[TerrainStoreTileIndex] {
716 &self.tile_index
717 }
718
719 #[must_use]
721 pub fn tile_count(&self) -> usize {
722 self.tile_ids.len()
723 }
724
725 #[must_use]
727 pub fn tile_ids(&self) -> &[TerrainTileId] {
728 &self.tile_ids
729 }
730
731 #[must_use]
733 pub const fn digest_provenance(&self) -> DigestProvenance {
734 self.digest_provenance
735 }
736
737 #[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 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 #[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 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 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 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 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 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 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 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 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 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
1006pub 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
1024pub 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
1044pub 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
1062pub 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#[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}