1use std::borrow::Cow;
15use std::collections::HashMap;
16use std::fs;
17use std::path::{Path, PathBuf};
18
19use crate::geoid::{egm96_undulation, GeoidError, GeoidGrid};
20use crate::terrain::{
21 self, terrain_grid_candidates, validate_lookup_coordinates, DtedInterpolation,
22 DtedLookupOptions, DtedTile,
23};
24use crate::{Error, Result};
25
26const STORE_MAGIC: &[u8; 8] = b"TMMAP001";
27const STORE_VERSION: u16 = 1;
28const STORE_ALIGNMENT: usize = 4096;
29const STORE_HEADER_LEN: usize = 64;
30const STORE_INDEX_RECORD_LEN: usize = 80;
31const HEADER_VERSION_OFFSET: usize = 8;
32const HEADER_DATUM_OFFSET: usize = 10;
33const HEADER_TILE_COUNT_OFFSET: usize = 12;
34const HEADER_INDEX_OFFSET_OFFSET: usize = 16;
35const HEADER_DATA_OFFSET_OFFSET: usize = 24;
36const HEADER_TOTAL_LEN_OFFSET: usize = 32;
37const INDEX_LAT_OFFSET: usize = 0;
38const INDEX_LON_OFFSET: usize = 4;
39const INDEX_LON_COUNT_OFFSET: usize = 8;
40const INDEX_LAT_COUNT_OFFSET: usize = 12;
41const INDEX_DATA_OFFSET_OFFSET: usize = 16;
42const INDEX_DATA_LEN_OFFSET: usize = 24;
43const INDEX_CHECKSUM_OFFSET: usize = 32;
44const INDEX_MIN_LAT_OFFSET: usize = 40;
45const INDEX_MIN_LON_OFFSET: usize = 48;
46const INDEX_MAX_LAT_OFFSET: usize = 56;
47const INDEX_MAX_LON_OFFSET: usize = 64;
48const INDEX_DATUM_OFFSET: usize = 72;
49const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
50const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
51const EGM96_DAC_REMEDIATION: &str =
52 "obtain the public NGA EGM96 15-arcminute WW15MGH.DAC file and load it with Egm96FifteenMinuteGeoid::from_ww15mgh_dac_path or Egm96FifteenMinuteGeoid::from_ww15mgh_dac_bytes";
53
54#[derive(Clone, Copy, Debug, PartialEq, Eq)]
56pub enum VerticalDatum {
57 Egm96MslOrthometric,
59}
60
61impl VerticalDatum {
62 fn tag(self) -> u8 {
63 match self {
64 Self::Egm96MslOrthometric => 1,
65 }
66 }
67
68 fn from_tag(tag: u8) -> core::result::Result<Self, TerrainStoreError> {
69 match tag {
70 1 => Ok(Self::Egm96MslOrthometric),
71 other => Err(TerrainStoreError::UnsupportedDatum { tag: other }),
72 }
73 }
74}
75
76#[derive(Clone, Copy, Debug, PartialEq)]
82pub struct OrthometricHeightM {
83 pub value_m: f64,
85}
86
87impl OrthometricHeightM {
88 #[must_use]
90 pub const fn new(value_m: f64) -> Self {
91 Self { value_m }
92 }
93
94 #[must_use]
96 pub const fn metres(self) -> f64 {
97 self.value_m
98 }
99
100 pub fn to_ellipsoidal_height_deg(
111 self,
112 latitude_deg: f64,
113 longitude_deg: f64,
114 geoid: TerrainGeoidModel<'_>,
115 ) -> core::result::Result<EllipsoidalHeightM, TerrainDatumError> {
116 Ok(EllipsoidalHeightM::new(
117 self.value_m + geoid.undulation_deg(latitude_deg, longitude_deg),
118 ))
119 }
120
121 pub fn to_ellipsoidal_height_rad(
129 self,
130 latitude_rad: f64,
131 longitude_rad: f64,
132 geoid: TerrainGeoidModel<'_>,
133 ) -> core::result::Result<EllipsoidalHeightM, TerrainDatumError> {
134 Ok(EllipsoidalHeightM::new(
135 self.value_m + geoid.undulation_rad(latitude_rad, longitude_rad),
136 ))
137 }
138}
139
140#[derive(Clone, Copy, Debug, PartialEq)]
142pub struct EllipsoidalHeightM {
143 pub value_m: f64,
145}
146
147impl EllipsoidalHeightM {
148 #[must_use]
150 pub const fn new(value_m: f64) -> Self {
151 Self { value_m }
152 }
153
154 #[must_use]
156 pub const fn metres(self) -> f64 {
157 self.value_m
158 }
159}
160
161#[derive(Clone, Debug, PartialEq)]
166pub struct Egm96FifteenMinuteGeoid {
167 grid: GeoidGrid,
168}
169
170impl Egm96FifteenMinuteGeoid {
171 pub fn from_ww15mgh_dac_bytes(bytes: &[u8]) -> core::result::Result<Self, TerrainDatumError> {
173 let grid = GeoidGrid::from_egm96_dac(bytes).map_err(TerrainDatumError::Geoid)?;
174 Ok(Self { grid })
175 }
176
177 pub fn from_ww15mgh_dac_path(
185 path: impl AsRef<Path>,
186 ) -> core::result::Result<Self, TerrainDatumError> {
187 let path = path.as_ref();
188 let bytes = match fs::read(path) {
189 Ok(bytes) => bytes,
190 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
191 return Err(TerrainDatumError::MissingEgm96Dac {
192 path: path.to_path_buf(),
193 remediation: EGM96_DAC_REMEDIATION,
194 });
195 }
196 Err(err) => {
197 return Err(TerrainDatumError::Io {
198 path: path.to_path_buf(),
199 message: err.to_string(),
200 });
201 }
202 };
203 Self::from_ww15mgh_dac_bytes(&bytes)
204 }
205
206 #[must_use]
208 pub const fn grid(&self) -> &GeoidGrid {
209 &self.grid
210 }
211}
212
213#[derive(Clone, Copy, Debug)]
216pub enum TerrainGeoidModel<'a> {
217 Egm96OneDegree,
222 Egm96FifteenMinute(&'a Egm96FifteenMinuteGeoid),
228}
229
230impl TerrainGeoidModel<'_> {
231 fn undulation_deg(self, latitude_deg: f64, longitude_deg: f64) -> f64 {
232 match self {
233 Self::Egm96OneDegree => {
234 egm96_undulation(latitude_deg.to_radians(), longitude_deg.to_radians())
235 }
236 Self::Egm96FifteenMinute(grid) => grid.grid.undulation_deg(latitude_deg, longitude_deg),
237 }
238 }
239
240 fn undulation_rad(self, latitude_rad: f64, longitude_rad: f64) -> f64 {
241 match self {
242 Self::Egm96OneDegree => egm96_undulation(latitude_rad, longitude_rad),
243 Self::Egm96FifteenMinute(grid) => grid.grid.undulation_rad(latitude_rad, longitude_rad),
244 }
245 }
246}
247
248#[derive(Debug, Clone, PartialEq)]
250pub enum TerrainDatumError {
251 Terrain(Error),
253 Geoid(GeoidError),
255 Io {
257 path: PathBuf,
259 message: String,
261 },
262 MissingEgm96Dac {
264 path: PathBuf,
266 remediation: &'static str,
268 },
269}
270
271impl core::fmt::Display for TerrainDatumError {
272 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
273 match self {
274 Self::Terrain(err) => write!(f, "terrain lookup failed: {err}"),
275 Self::Geoid(err) => write!(f, "geoid grid failed: {err}"),
276 Self::Io { path, message } => {
277 write!(f, "{} could not be read: {message}", path.display())
278 }
279 Self::MissingEgm96Dac { path, remediation } => {
280 write!(f, "{} is missing; {remediation}", path.display())
281 }
282 }
283 }
284}
285
286impl std::error::Error for TerrainDatumError {}
287
288impl From<Error> for TerrainDatumError {
289 fn from(value: Error) -> Self {
290 Self::Terrain(value)
291 }
292}
293
294#[derive(Clone, Copy, Debug, PartialEq)]
296pub struct TerrainStoreTileIndex {
297 pub lat_index: i32,
299 pub lon_index: i32,
302 pub min_longitude_deg: f64,
304 pub min_latitude_deg: f64,
306 pub max_longitude_deg: f64,
308 pub max_latitude_deg: f64,
310 pub lon_count: u32,
312 pub lat_count: u32,
314 pub data_offset: u64,
316 pub data_len: u64,
318 pub checksum64: u64,
320 pub vertical_datum: VerticalDatum,
322}
323
324#[derive(Debug, Clone, PartialEq, Eq)]
326pub enum TerrainStoreError {
327 Io {
329 path: PathBuf,
331 message: String,
333 },
334 Parse {
336 reason: String,
338 },
339 UnsupportedVersion {
341 version: u16,
343 },
344 UnsupportedDatum {
346 tag: u8,
348 },
349 DuplicateTile {
351 lat_index: i32,
353 lon_index: i32,
355 },
356 Checksum {
358 lat_index: i32,
360 lon_index: i32,
362 expected: u64,
364 found: u64,
366 },
367}
368
369impl core::fmt::Display for TerrainStoreError {
370 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
371 match self {
372 Self::Io { path, message } => write!(f, "{} failed: {message}", path.display()),
373 Self::Parse { reason } => write!(f, "terrain store parse error: {reason}"),
374 Self::UnsupportedVersion { version } => {
375 write!(f, "terrain store version {version} is not supported")
376 }
377 Self::UnsupportedDatum { tag } => {
378 write!(f, "terrain store vertical datum tag {tag} is not supported")
379 }
380 Self::DuplicateTile {
381 lat_index,
382 lon_index,
383 } => write!(f, "duplicate terrain tile ({lat_index},{lon_index})"),
384 Self::Checksum {
385 lat_index,
386 lon_index,
387 expected,
388 found,
389 } => write!(
390 f,
391 "terrain tile ({lat_index},{lon_index}) checksum expected {expected:#x} but found {found:#x}"
392 ),
393 }
394 }
395}
396
397impl std::error::Error for TerrainStoreError {}
398
399#[derive(Clone, Debug)]
400struct MmapTile {
401 index: TerrainStoreTileIndex,
402}
403
404impl MmapTile {
405 fn contains(&self, longitude_deg: f64, latitude_deg: f64) -> bool {
406 latitude_deg >= self.index.min_latitude_deg
407 && latitude_deg <= self.index.max_latitude_deg
408 && longitude_deg >= self.index.min_longitude_deg
409 && longitude_deg <= self.index.max_longitude_deg
410 }
411
412 fn get_elevation(&self, bytes: &[u8], longitude_deg: f64, latitude_deg: f64) -> Result<i16> {
413 if !self.contains(longitude_deg, latitude_deg) {
414 return Err(Error::Parse(format!(
415 "point ({longitude_deg},{latitude_deg}) is outside terrain store tile ({},{})",
416 self.index.min_longitude_deg, self.index.min_latitude_deg
417 )));
418 }
419
420 let lat_count = self.index.lat_count as usize;
421 let lon_count = self.index.lon_count as usize;
422 let latitude_index = terrain::py_round_to_usize(
423 (latitude_deg - self.index.min_latitude_deg) * (lat_count - 1) as f64,
424 )
425 .map_err(Error::Parse)?;
426 let longitude_index = terrain::py_round_to_usize(
427 (longitude_deg - self.index.min_longitude_deg) * (lon_count - 1) as f64,
428 )
429 .map_err(Error::Parse)?;
430 if latitude_index >= lat_count || longitude_index >= lon_count {
431 return Err(Error::Parse(format!(
432 "posting index out of bounds lon={longitude_index} lat={latitude_index}"
433 )));
434 }
435
436 let sample_start =
437 self.index.data_offset as usize + 2 * (longitude_index * lat_count + latitude_index);
438 Ok(i16::from_le_bytes([
439 bytes[sample_start],
440 bytes[sample_start + 1],
441 ]))
442 }
443}
444
445#[derive(Clone, Debug)]
452pub struct MmapTerrain<'a> {
453 bytes: Cow<'a, [u8]>,
454 tiles: Vec<MmapTile>,
455 by_grid: HashMap<(i32, i32), usize>,
456 tile_index: Vec<TerrainStoreTileIndex>,
457 vertical_datum: VerticalDatum,
458}
459
460impl MmapTerrain<'static> {
461 pub fn from_vec(bytes: Vec<u8>) -> core::result::Result<Self, TerrainStoreError> {
463 Self::from_cow(Cow::Owned(bytes))
464 }
465
466 pub fn from_path(path: impl AsRef<Path>) -> core::result::Result<Self, TerrainStoreError> {
472 let path = path.as_ref();
473 let bytes = fs::read(path).map_err(|err| TerrainStoreError::Io {
474 path: path.to_path_buf(),
475 message: err.to_string(),
476 })?;
477 Self::from_vec(bytes)
478 }
479}
480
481impl<'a> MmapTerrain<'a> {
482 pub fn from_bytes(bytes: &'a [u8]) -> core::result::Result<Self, TerrainStoreError> {
487 Self::from_cow(Cow::Borrowed(bytes))
488 }
489
490 fn from_cow(bytes: Cow<'a, [u8]>) -> core::result::Result<Self, TerrainStoreError> {
491 let parsed = parse_store(bytes.as_ref())?;
492 Ok(Self {
493 bytes,
494 tiles: parsed.tiles,
495 by_grid: parsed.by_grid,
496 tile_index: parsed.tile_index,
497 vertical_datum: parsed.vertical_datum,
498 })
499 }
500
501 #[must_use]
503 pub fn as_bytes(&self) -> &[u8] {
504 self.bytes.as_ref()
505 }
506
507 #[must_use]
509 pub const fn vertical_datum(&self) -> VerticalDatum {
510 self.vertical_datum
511 }
512
513 #[must_use]
515 pub fn tile_index(&self) -> &[TerrainStoreTileIndex] {
516 &self.tile_index
517 }
518
519 #[must_use]
521 pub fn checksum64(&self) -> u64 {
522 terrain_store_checksum64(self.bytes.as_ref())
523 }
524
525 #[must_use]
530 pub fn to_bytes(&self) -> Vec<u8> {
531 let pending = self
532 .tiles
533 .iter()
534 .map(|tile| PendingTile {
535 lat_index: tile.index.lat_index,
536 lon_index: tile.index.lon_index,
537 min_latitude_deg: tile.index.min_latitude_deg,
538 min_longitude_deg: tile.index.min_longitude_deg,
539 max_latitude_deg: tile.index.max_latitude_deg,
540 max_longitude_deg: tile.index.max_longitude_deg,
541 lon_count: tile.index.lon_count,
542 lat_count: tile.index.lat_count,
543 data: self.tile_payload(tile).to_vec(),
544 vertical_datum: tile.index.vertical_datum,
545 })
546 .collect();
547 build_store(pending).expect("parsed terrain store can be reserialized")
548 }
549
550 pub fn height_m(&mut self, longitude_deg: f64, latitude_deg: f64) -> Result<f64> {
553 self.height_m_with_options(longitude_deg, latitude_deg, DtedLookupOptions::default())
554 }
555
556 pub fn height_m_with_options(
559 &mut self,
560 longitude_deg: f64,
561 latitude_deg: f64,
562 options: DtedLookupOptions,
563 ) -> Result<f64> {
564 self.orthometric_height_m_with_options(longitude_deg, latitude_deg, options)
565 .map(OrthometricHeightM::metres)
566 }
567
568 pub fn orthometric_height_m(
571 &self,
572 longitude_deg: f64,
573 latitude_deg: f64,
574 ) -> Result<OrthometricHeightM> {
575 self.orthometric_height_m_with_options(
576 longitude_deg,
577 latitude_deg,
578 DtedLookupOptions::default(),
579 )
580 }
581
582 pub fn orthometric_height_m_with_options(
586 &self,
587 longitude_deg: f64,
588 latitude_deg: f64,
589 options: DtedLookupOptions,
590 ) -> Result<OrthometricHeightM> {
591 validate_lookup_coordinates(longitude_deg, latitude_deg)?;
592 let Some(tile_idx) = self.resolve_grid(longitude_deg, latitude_deg) else {
593 return Ok(OrthometricHeightM::new(0.0));
594 };
595 height_from_tile(
596 self.bytes.as_ref(),
597 &self.tiles[tile_idx],
598 longitude_deg,
599 latitude_deg,
600 options,
601 )
602 .map(OrthometricHeightM::new)
603 }
604
605 pub fn height_batch(
612 &mut self,
613 points: &[(f64, f64)],
614 options: DtedLookupOptions,
615 ) -> Vec<Result<f64>> {
616 self.orthometric_height_batch(points, options)
617 .into_iter()
618 .map(|result| result.map(OrthometricHeightM::metres))
619 .collect()
620 }
621
622 pub fn orthometric_height_batch(
628 &self,
629 points: &[(f64, f64)],
630 options: DtedLookupOptions,
631 ) -> Vec<Result<OrthometricHeightM>> {
632 let mut out = Vec::with_capacity(points.len());
633 let mut current = None;
634
635 for &(longitude_deg, latitude_deg) in points {
636 if let Err(err) = validate_lookup_coordinates(longitude_deg, latitude_deg) {
637 out.push(Err(err));
638 continue;
639 }
640
641 let primary_grid = terrain::terrain_grid(longitude_deg, latitude_deg);
642 if current == Some(primary_grid) {
643 if let Some(&tile_idx) = self.by_grid.get(&primary_grid) {
644 let tile = &self.tiles[tile_idx];
645 if tile.contains(longitude_deg, latitude_deg) {
646 out.push(
647 height_from_tile(
648 self.bytes.as_ref(),
649 tile,
650 longitude_deg,
651 latitude_deg,
652 options,
653 )
654 .map(OrthometricHeightM::new),
655 );
656 continue;
657 }
658 }
659 }
660
661 match self.resolve_grid(longitude_deg, latitude_deg) {
662 Some(tile_idx) => {
663 let tile = &self.tiles[tile_idx];
664 current = Some((tile.index.lat_index, tile.index.lon_index));
665 out.push(
666 height_from_tile(
667 self.bytes.as_ref(),
668 tile,
669 longitude_deg,
670 latitude_deg,
671 options,
672 )
673 .map(OrthometricHeightM::new),
674 );
675 }
676 None => {
677 current = None;
678 out.push(Ok(OrthometricHeightM::new(0.0)));
679 }
680 }
681 }
682
683 out
684 }
685
686 pub fn ellipsoidal_height_m(
694 &self,
695 longitude_deg: f64,
696 latitude_deg: f64,
697 ) -> core::result::Result<EllipsoidalHeightM, TerrainDatumError> {
698 self.ellipsoidal_height_m_with_options(
699 longitude_deg,
700 latitude_deg,
701 DtedLookupOptions::default(),
702 )
703 }
704
705 pub fn ellipsoidal_height_m_with_options(
711 &self,
712 longitude_deg: f64,
713 latitude_deg: f64,
714 options: DtedLookupOptions,
715 ) -> core::result::Result<EllipsoidalHeightM, TerrainDatumError> {
716 self.ellipsoidal_height_m_with_model(
717 longitude_deg,
718 latitude_deg,
719 options,
720 TerrainGeoidModel::Egm96OneDegree,
721 )
722 }
723
724 pub fn ellipsoidal_height_m_with_model(
733 &self,
734 longitude_deg: f64,
735 latitude_deg: f64,
736 options: DtedLookupOptions,
737 geoid: TerrainGeoidModel<'_>,
738 ) -> core::result::Result<EllipsoidalHeightM, TerrainDatumError> {
739 let orthometric = self
740 .orthometric_height_m_with_options(longitude_deg, latitude_deg, options)
741 .map_err(TerrainDatumError::Terrain)?;
742 orthometric.to_ellipsoidal_height_deg(latitude_deg, longitude_deg, geoid)
743 }
744
745 fn resolve_grid(&self, longitude_deg: f64, latitude_deg: f64) -> Option<usize> {
746 for grid_idx in terrain_grid_candidates(longitude_deg, latitude_deg) {
747 if let Some(&tile_idx) = self.by_grid.get(&grid_idx) {
748 if self.tiles[tile_idx].contains(longitude_deg, latitude_deg) {
749 return Some(tile_idx);
750 }
751 }
752 }
753 None
754 }
755
756 fn tile_payload(&self, tile: &MmapTile) -> &[u8] {
757 let start = tile.index.data_offset as usize;
758 let end = start + tile.index.data_len as usize;
759 &self.bytes[start..end]
760 }
761}
762
763pub fn dted_tree_to_mmap_store(
770 root: impl AsRef<Path>,
771) -> core::result::Result<Vec<u8>, TerrainStoreError> {
772 let root = root.as_ref();
773 let mut paths = Vec::new();
774 collect_dted_tile_paths(root, &mut paths)?;
775 paths.sort();
776
777 let mut pending = Vec::with_capacity(paths.len());
778 for path in paths {
779 let tile = DtedTile::from_path(&path).map_err(|reason| TerrainStoreError::Parse {
780 reason: format!("{}: {reason}", path.display()),
781 })?;
782 let decoded =
783 tile.decoded_postings_lon_major()
784 .map_err(|reason| TerrainStoreError::Parse {
785 reason: format!("{}: {reason}", path.display()),
786 })?;
787 let mut data = Vec::with_capacity(decoded.len() * 2);
788 for posting in decoded {
789 data.extend_from_slice(&posting.to_le_bytes());
790 }
791 let lat_index = tile.origin_latitude().floor() as i32;
792 let lon_index = tile.origin_longitude().floor() as i32;
793 pending.push(PendingTile {
794 lat_index,
795 lon_index,
796 min_latitude_deg: tile.origin_latitude(),
797 min_longitude_deg: tile.origin_longitude(),
798 max_latitude_deg: tile.origin_latitude() + 1.0,
799 max_longitude_deg: tile.origin_longitude() + 1.0,
800 lon_count: u32::try_from(tile.lon_count()).map_err(|_| TerrainStoreError::Parse {
801 reason: format!("{} longitude count exceeds u32", path.display()),
802 })?,
803 lat_count: u32::try_from(tile.lat_count()).map_err(|_| TerrainStoreError::Parse {
804 reason: format!("{} latitude count exceeds u32", path.display()),
805 })?,
806 data,
807 vertical_datum: VerticalDatum::Egm96MslOrthometric,
808 });
809 }
810
811 build_store(pending)
812}
813
814pub fn write_dted_tree_to_mmap_store(
817 root: impl AsRef<Path>,
818 output_path: impl AsRef<Path>,
819) -> core::result::Result<(), TerrainStoreError> {
820 let bytes = dted_tree_to_mmap_store(root)?;
821 let output_path = output_path.as_ref();
822 fs::write(output_path, &bytes).map_err(|err| TerrainStoreError::Io {
823 path: output_path.to_path_buf(),
824 message: err.to_string(),
825 })
826}
827
828#[must_use]
833pub fn terrain_store_checksum64(bytes: &[u8]) -> u64 {
834 fnv1a64(bytes)
835}
836
837#[derive(Debug)]
838struct PendingTile {
839 lat_index: i32,
840 lon_index: i32,
841 min_latitude_deg: f64,
842 min_longitude_deg: f64,
843 max_latitude_deg: f64,
844 max_longitude_deg: f64,
845 lon_count: u32,
846 lat_count: u32,
847 data: Vec<u8>,
848 vertical_datum: VerticalDatum,
849}
850
851#[derive(Debug)]
852struct ParsedStore {
853 vertical_datum: VerticalDatum,
854 tiles: Vec<MmapTile>,
855 by_grid: HashMap<(i32, i32), usize>,
856 tile_index: Vec<TerrainStoreTileIndex>,
857}
858
859fn height_from_tile(
860 bytes: &[u8],
861 tile: &MmapTile,
862 longitude_deg: f64,
863 latitude_deg: f64,
864 options: DtedLookupOptions,
865) -> Result<f64> {
866 if options.interpolation == DtedInterpolation::NearestPosting {
867 return tile
868 .get_elevation(bytes, longitude_deg, latitude_deg)
869 .map(|v| v as f64);
870 }
871
872 let postings_per_deg_lon = tile.index.lon_count as usize - 1;
873 let postings_per_deg_lat = tile.index.lat_count as usize - 1;
874
875 let lon_idx = (longitude_deg - tile.index.min_longitude_deg) * postings_per_deg_lon as f64;
876 let lat_idx = (latitude_deg - tile.index.min_latitude_deg) * postings_per_deg_lat as f64;
877 let lon_lo = lon_idx.floor() as i64;
878 let lat_lo = lat_idx.floor() as i64;
879 let fx = lon_idx - lon_lo as f64;
880 let fy = lat_idx - lat_lo as f64;
881
882 let mut z = 0.0;
883 for (di, wx) in [(0i64, 1.0 - fx), (1i64, fx)] {
884 for (dj, wy) in [(0i64, 1.0 - fy), (1i64, fy)] {
885 let w = wx * wy;
886 if w == 0.0 {
887 continue;
888 }
889 let posting_lon =
890 tile.index.min_longitude_deg + (lon_lo + di) as f64 / postings_per_deg_lon as f64;
891 let posting_lat =
892 tile.index.min_latitude_deg + (lat_lo + dj) as f64 / postings_per_deg_lat as f64;
893 z += w * f64::from(tile.get_elevation(bytes, posting_lon, posting_lat)?);
894 }
895 }
896 Ok(z)
897}
898
899fn collect_dted_tile_paths(
900 root: &Path,
901 out: &mut Vec<PathBuf>,
902) -> core::result::Result<(), TerrainStoreError> {
903 let entries = fs::read_dir(root).map_err(|err| TerrainStoreError::Io {
904 path: root.to_path_buf(),
905 message: err.to_string(),
906 })?;
907
908 for entry in entries {
909 let entry = entry.map_err(|err| TerrainStoreError::Io {
910 path: root.to_path_buf(),
911 message: err.to_string(),
912 })?;
913 let path = entry.path();
914 let file_type = entry.file_type().map_err(|err| TerrainStoreError::Io {
915 path: path.clone(),
916 message: err.to_string(),
917 })?;
918 if file_type.is_dir() {
919 collect_dted_tile_paths(&path, out)?;
920 } else if file_type.is_file() && is_dted_tile_path(&path) {
921 out.push(path);
922 }
923 }
924 Ok(())
925}
926
927fn is_dted_tile_path(path: &Path) -> bool {
928 path.file_name()
929 .and_then(|name| name.to_str())
930 .is_some_and(|name| name.ends_with(".dt2"))
931}
932
933fn parse_store(bytes: &[u8]) -> core::result::Result<ParsedStore, TerrainStoreError> {
934 if bytes.len() < STORE_HEADER_LEN {
935 return Err(TerrainStoreError::Parse {
936 reason: format!(
937 "store has {} bytes but needs at least {STORE_HEADER_LEN}",
938 bytes.len()
939 ),
940 });
941 }
942 if &bytes[..STORE_MAGIC.len()] != STORE_MAGIC {
943 return Err(TerrainStoreError::Parse {
944 reason: "missing terrain store magic".to_string(),
945 });
946 }
947 let version = read_u16(bytes, HEADER_VERSION_OFFSET)?;
948 if version != STORE_VERSION {
949 return Err(TerrainStoreError::UnsupportedVersion { version });
950 }
951 ensure_zero(bytes, 11, 12, "header reserved byte")?;
952 ensure_zero(bytes, 40, STORE_HEADER_LEN, "header reserved bytes")?;
953
954 let vertical_datum = VerticalDatum::from_tag(bytes[HEADER_DATUM_OFFSET])?;
955 let tile_count = read_u32(bytes, HEADER_TILE_COUNT_OFFSET)? as usize;
956 let index_offset = read_u64(bytes, HEADER_INDEX_OFFSET_OFFSET)? as usize;
957 let data_offset = read_u64(bytes, HEADER_DATA_OFFSET_OFFSET)? as usize;
958 let total_len = read_u64(bytes, HEADER_TOTAL_LEN_OFFSET)? as usize;
959 if total_len != bytes.len() {
960 return Err(TerrainStoreError::Parse {
961 reason: format!(
962 "header total length {total_len} does not match {}",
963 bytes.len()
964 ),
965 });
966 }
967 if index_offset != STORE_HEADER_LEN {
968 return Err(TerrainStoreError::Parse {
969 reason: format!("index offset must be {STORE_HEADER_LEN}, got {index_offset}"),
970 });
971 }
972
973 let index_len = tile_count
974 .checked_mul(STORE_INDEX_RECORD_LEN)
975 .ok_or_else(|| TerrainStoreError::Parse {
976 reason: "tile index length overflows usize".to_string(),
977 })?;
978 let index_end =
979 index_offset
980 .checked_add(index_len)
981 .ok_or_else(|| TerrainStoreError::Parse {
982 reason: "tile index end overflows usize".to_string(),
983 })?;
984 if index_end > bytes.len() {
985 return Err(TerrainStoreError::Parse {
986 reason: "tile index extends past store length".to_string(),
987 });
988 }
989 let expected_data_offset = align_up(index_end, STORE_ALIGNMENT)?;
990 if data_offset != expected_data_offset {
991 return Err(TerrainStoreError::Parse {
992 reason: format!("data offset must be {expected_data_offset}, got {data_offset}"),
993 });
994 }
995 ensure_zero(bytes, index_end, data_offset, "index padding")?;
996
997 let mut tiles = Vec::with_capacity(tile_count);
998 let mut tile_index = Vec::with_capacity(tile_count);
999 let mut by_grid = HashMap::with_capacity(tile_count);
1000 let mut previous_id = None;
1001 let mut expected_next = data_offset;
1002
1003 for idx in 0..tile_count {
1004 let record_offset = index_offset + idx * STORE_INDEX_RECORD_LEN;
1005 let record = &bytes[record_offset..record_offset + STORE_INDEX_RECORD_LEN];
1006 let lat_index = read_i32(record, INDEX_LAT_OFFSET)?;
1007 let lon_index = read_i32(record, INDEX_LON_OFFSET)?;
1008 let tile_id = (lat_index, lon_index);
1009 if previous_id.is_some_and(|previous| tile_id <= previous) {
1010 return Err(TerrainStoreError::Parse {
1011 reason: "tile index records are not strictly sorted".to_string(),
1012 });
1013 }
1014 previous_id = Some(tile_id);
1015
1016 let lon_count = read_u32(record, INDEX_LON_COUNT_OFFSET)?;
1017 let lat_count = read_u32(record, INDEX_LAT_COUNT_OFFSET)?;
1018 if lon_count < 2 || lat_count < 2 {
1019 return Err(TerrainStoreError::Parse {
1020 reason: format!(
1021 "tile ({lat_index},{lon_index}) has invalid dimensions lon_count={lon_count} lat_count={lat_count}"
1022 ),
1023 });
1024 }
1025 let offset = read_u64(record, INDEX_DATA_OFFSET_OFFSET)? as usize;
1026 let data_len = read_u64(record, INDEX_DATA_LEN_OFFSET)? as usize;
1027 let expected_len = (lon_count as usize)
1028 .checked_mul(lat_count as usize)
1029 .and_then(|count| count.checked_mul(2))
1030 .ok_or_else(|| TerrainStoreError::Parse {
1031 reason: format!("tile ({lat_index},{lon_index}) data length overflows usize"),
1032 })?;
1033 if data_len != expected_len {
1034 return Err(TerrainStoreError::Parse {
1035 reason: format!(
1036 "tile ({lat_index},{lon_index}) data length must be {expected_len}, got {data_len}"
1037 ),
1038 });
1039 }
1040
1041 let expected_offset = align_up(expected_next, STORE_ALIGNMENT)?;
1042 ensure_zero(bytes, expected_next, expected_offset, "tile padding")?;
1043 if offset != expected_offset {
1044 return Err(TerrainStoreError::Parse {
1045 reason: format!(
1046 "tile ({lat_index},{lon_index}) data offset must be {expected_offset}, got {offset}"
1047 ),
1048 });
1049 }
1050 let end = offset
1051 .checked_add(data_len)
1052 .ok_or_else(|| TerrainStoreError::Parse {
1053 reason: format!("tile ({lat_index},{lon_index}) data end overflows usize"),
1054 })?;
1055 if end > bytes.len() {
1056 return Err(TerrainStoreError::Parse {
1057 reason: format!("tile ({lat_index},{lon_index}) data extends past store length"),
1058 });
1059 }
1060
1061 let checksum64 = read_u64(record, INDEX_CHECKSUM_OFFSET)?;
1062 let found = fnv1a64(&bytes[offset..end]);
1063 if found != checksum64 {
1064 return Err(TerrainStoreError::Checksum {
1065 lat_index,
1066 lon_index,
1067 expected: checksum64,
1068 found,
1069 });
1070 }
1071
1072 let min_latitude_deg = read_f64(record, INDEX_MIN_LAT_OFFSET)?;
1073 let min_longitude_deg = read_f64(record, INDEX_MIN_LON_OFFSET)?;
1074 let max_latitude_deg = read_f64(record, INDEX_MAX_LAT_OFFSET)?;
1075 let max_longitude_deg = read_f64(record, INDEX_MAX_LON_OFFSET)?;
1076 for (field, value) in [
1077 ("min_latitude_deg", min_latitude_deg),
1078 ("min_longitude_deg", min_longitude_deg),
1079 ("max_latitude_deg", max_latitude_deg),
1080 ("max_longitude_deg", max_longitude_deg),
1081 ] {
1082 if !value.is_finite() {
1083 return Err(TerrainStoreError::Parse {
1084 reason: format!("tile ({lat_index},{lon_index}) {field} is not finite"),
1085 });
1086 }
1087 }
1088 let tile_datum = VerticalDatum::from_tag(record[INDEX_DATUM_OFFSET])?;
1089 if tile_datum != vertical_datum {
1090 return Err(TerrainStoreError::Parse {
1091 reason: format!("tile ({lat_index},{lon_index}) datum differs from header"),
1092 });
1093 }
1094 ensure_zero(
1095 record,
1096 INDEX_DATUM_OFFSET + 1,
1097 STORE_INDEX_RECORD_LEN,
1098 "tile index reserved bytes",
1099 )?;
1100
1101 let index = TerrainStoreTileIndex {
1102 lat_index,
1103 lon_index,
1104 min_longitude_deg,
1105 min_latitude_deg,
1106 max_longitude_deg,
1107 max_latitude_deg,
1108 lon_count,
1109 lat_count,
1110 data_offset: offset as u64,
1111 data_len: data_len as u64,
1112 checksum64,
1113 vertical_datum: tile_datum,
1114 };
1115 by_grid.insert(tile_id, tiles.len());
1116 tiles.push(MmapTile { index });
1117 tile_index.push(index);
1118 expected_next = end;
1119 }
1120
1121 if expected_next != bytes.len() {
1122 return Err(TerrainStoreError::Parse {
1123 reason: format!(
1124 "store has trailing bytes: expected length {expected_next}, got {}",
1125 bytes.len()
1126 ),
1127 });
1128 }
1129
1130 Ok(ParsedStore {
1131 vertical_datum,
1132 tiles,
1133 by_grid,
1134 tile_index,
1135 })
1136}
1137
1138fn build_store(mut tiles: Vec<PendingTile>) -> core::result::Result<Vec<u8>, TerrainStoreError> {
1139 tiles.sort_by_key(|tile| (tile.lat_index, tile.lon_index));
1140 for pair in tiles.windows(2) {
1141 if (pair[0].lat_index, pair[0].lon_index) == (pair[1].lat_index, pair[1].lon_index) {
1142 return Err(TerrainStoreError::DuplicateTile {
1143 lat_index: pair[0].lat_index,
1144 lon_index: pair[0].lon_index,
1145 });
1146 }
1147 }
1148
1149 let index_end = STORE_HEADER_LEN
1150 .checked_add(
1151 tiles
1152 .len()
1153 .checked_mul(STORE_INDEX_RECORD_LEN)
1154 .ok_or_else(|| TerrainStoreError::Parse {
1155 reason: "tile index length overflows usize".to_string(),
1156 })?,
1157 )
1158 .ok_or_else(|| TerrainStoreError::Parse {
1159 reason: "tile index end overflows usize".to_string(),
1160 })?;
1161 let data_offset = align_up(index_end, STORE_ALIGNMENT)?;
1162 let mut offsets = Vec::with_capacity(tiles.len());
1163 let mut cursor = data_offset;
1164 for tile in &tiles {
1165 cursor = align_up(cursor, STORE_ALIGNMENT)?;
1166 offsets.push(cursor);
1167 cursor = cursor
1168 .checked_add(tile.data.len())
1169 .ok_or_else(|| TerrainStoreError::Parse {
1170 reason: "store length overflows usize".to_string(),
1171 })?;
1172 }
1173
1174 let mut out = vec![0u8; cursor];
1175 out[..STORE_MAGIC.len()].copy_from_slice(STORE_MAGIC);
1176 write_u16(&mut out, HEADER_VERSION_OFFSET, STORE_VERSION);
1177 out[HEADER_DATUM_OFFSET] = VerticalDatum::Egm96MslOrthometric.tag();
1178 write_u32(
1179 &mut out,
1180 HEADER_TILE_COUNT_OFFSET,
1181 u32::try_from(tiles.len()).map_err(|_| TerrainStoreError::Parse {
1182 reason: "tile count exceeds u32".to_string(),
1183 })?,
1184 );
1185 write_u64(
1186 &mut out,
1187 HEADER_INDEX_OFFSET_OFFSET,
1188 STORE_HEADER_LEN as u64,
1189 );
1190 write_u64(&mut out, HEADER_DATA_OFFSET_OFFSET, data_offset as u64);
1191 write_u64(&mut out, HEADER_TOTAL_LEN_OFFSET, cursor as u64);
1192
1193 for (idx, tile) in tiles.iter().enumerate() {
1194 let record_offset = STORE_HEADER_LEN + idx * STORE_INDEX_RECORD_LEN;
1195 let offset = offsets[idx];
1196 let data_len = tile.data.len();
1197 let expected_len = (tile.lon_count as usize)
1198 .checked_mul(tile.lat_count as usize)
1199 .and_then(|count| count.checked_mul(2))
1200 .ok_or_else(|| TerrainStoreError::Parse {
1201 reason: format!(
1202 "tile ({},{}) data length overflows usize",
1203 tile.lat_index, tile.lon_index
1204 ),
1205 })?;
1206 if data_len != expected_len {
1207 return Err(TerrainStoreError::Parse {
1208 reason: format!(
1209 "tile ({},{}) data length must be {expected_len}, got {data_len}",
1210 tile.lat_index, tile.lon_index
1211 ),
1212 });
1213 }
1214
1215 let record = &mut out[record_offset..record_offset + STORE_INDEX_RECORD_LEN];
1216 write_i32(record, INDEX_LAT_OFFSET, tile.lat_index);
1217 write_i32(record, INDEX_LON_OFFSET, tile.lon_index);
1218 write_u32(record, INDEX_LON_COUNT_OFFSET, tile.lon_count);
1219 write_u32(record, INDEX_LAT_COUNT_OFFSET, tile.lat_count);
1220 write_u64(record, INDEX_DATA_OFFSET_OFFSET, offset as u64);
1221 write_u64(record, INDEX_DATA_LEN_OFFSET, data_len as u64);
1222 write_u64(record, INDEX_CHECKSUM_OFFSET, fnv1a64(&tile.data));
1223 write_f64(record, INDEX_MIN_LAT_OFFSET, tile.min_latitude_deg);
1224 write_f64(record, INDEX_MIN_LON_OFFSET, tile.min_longitude_deg);
1225 write_f64(record, INDEX_MAX_LAT_OFFSET, tile.max_latitude_deg);
1226 write_f64(record, INDEX_MAX_LON_OFFSET, tile.max_longitude_deg);
1227 record[INDEX_DATUM_OFFSET] = tile.vertical_datum.tag();
1228 out[offset..offset + data_len].copy_from_slice(&tile.data);
1229 }
1230
1231 Ok(out)
1232}
1233
1234fn align_up(value: usize, alignment: usize) -> core::result::Result<usize, TerrainStoreError> {
1235 let rem = value % alignment;
1236 if rem == 0 {
1237 Ok(value)
1238 } else {
1239 value
1240 .checked_add(alignment - rem)
1241 .ok_or_else(|| TerrainStoreError::Parse {
1242 reason: "aligned offset overflows usize".to_string(),
1243 })
1244 }
1245}
1246
1247fn ensure_zero(
1248 bytes: &[u8],
1249 start: usize,
1250 end: usize,
1251 context: &str,
1252) -> core::result::Result<(), TerrainStoreError> {
1253 if start > end || end > bytes.len() {
1254 return Err(TerrainStoreError::Parse {
1255 reason: format!("{context} range is out of bounds"),
1256 });
1257 }
1258 if bytes[start..end].iter().any(|&byte| byte != 0) {
1259 return Err(TerrainStoreError::Parse {
1260 reason: format!("{context} must be zero-filled"),
1261 });
1262 }
1263 Ok(())
1264}
1265
1266fn fnv1a64(bytes: &[u8]) -> u64 {
1267 bytes.iter().fold(FNV_OFFSET_BASIS, |hash, byte| {
1268 (hash ^ u64::from(*byte)).wrapping_mul(FNV_PRIME)
1269 })
1270}
1271
1272fn read_u16(bytes: &[u8], offset: usize) -> core::result::Result<u16, TerrainStoreError> {
1273 Ok(u16::from_le_bytes(read_array(bytes, offset)?))
1274}
1275
1276fn read_u32(bytes: &[u8], offset: usize) -> core::result::Result<u32, TerrainStoreError> {
1277 Ok(u32::from_le_bytes(read_array(bytes, offset)?))
1278}
1279
1280fn read_i32(bytes: &[u8], offset: usize) -> core::result::Result<i32, TerrainStoreError> {
1281 Ok(i32::from_le_bytes(read_array(bytes, offset)?))
1282}
1283
1284fn read_u64(bytes: &[u8], offset: usize) -> core::result::Result<u64, TerrainStoreError> {
1285 Ok(u64::from_le_bytes(read_array(bytes, offset)?))
1286}
1287
1288fn read_f64(bytes: &[u8], offset: usize) -> core::result::Result<f64, TerrainStoreError> {
1289 Ok(f64::from_le_bytes(read_array(bytes, offset)?))
1290}
1291
1292fn read_array<const N: usize>(
1293 bytes: &[u8],
1294 offset: usize,
1295) -> core::result::Result<[u8; N], TerrainStoreError> {
1296 let end = offset
1297 .checked_add(N)
1298 .ok_or_else(|| TerrainStoreError::Parse {
1299 reason: "numeric field offset overflows usize".to_string(),
1300 })?;
1301 let slice = bytes
1302 .get(offset..end)
1303 .ok_or_else(|| TerrainStoreError::Parse {
1304 reason: "numeric field extends past record".to_string(),
1305 })?;
1306 slice.try_into().map_err(|_| TerrainStoreError::Parse {
1307 reason: "numeric field has wrong length".to_string(),
1308 })
1309}
1310
1311fn write_u16(bytes: &mut [u8], offset: usize, value: u16) {
1312 bytes[offset..offset + 2].copy_from_slice(&value.to_le_bytes());
1313}
1314
1315fn write_u32(bytes: &mut [u8], offset: usize, value: u32) {
1316 bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
1317}
1318
1319fn write_i32(bytes: &mut [u8], offset: usize, value: i32) {
1320 bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
1321}
1322
1323fn write_u64(bytes: &mut [u8], offset: usize, value: u64) {
1324 bytes[offset..offset + 8].copy_from_slice(&value.to_le_bytes());
1325}
1326
1327fn write_f64(bytes: &mut [u8], offset: usize, value: f64) {
1328 bytes[offset..offset + 8].copy_from_slice(&value.to_le_bytes());
1329}