From 8cab4ce345fda7c1810073fe7f4ef9857f5d9531 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 15 May 2026 20:54:09 -0400 Subject: [PATCH] feat: add hgt terrain importer --- Cargo.toml | 1 + README.md | 15 +++++ src/cli.rs | 29 +++++++++- src/import.rs | 155 ++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 199 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index e93e795..805c9fb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ edition = "2024" [features] default = [] app = ["dep:eframe"] +hgt = [] [dependencies] clap = { version = "4.6.1", features = ["derive"] } diff --git a/README.md b/README.md index 37240b8..ee6c759 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ This repository currently contains: - A first-pass knowledgebase under `docs/knowledgebase/`. - An implementation roadmap under `docs/plans/`. - Legal and reference-material hygiene notes under `docs/legal/` and `docs/research/`. +- A clean-room terrain import boundary with project-owned `ovp-text` fixtures and an SRTM/HGT byte importer behind the `hgt` Cargo feature. ## Development @@ -29,6 +30,20 @@ cargo run --features app --bin openvistapro_app The optional app shell is gated behind the `app` feature so default CLI builds stay GPU-free. It opens an `eframe`/`egui` window titled `OpenVistaPro` with scene controls and a CPU-rendered terrain preview. +Importer status: + +- `ovp-text`: project-owned plain-text heightfield fixture format used for tests. +- `hgt`: enabled by the optional `hgt` Cargo feature; parses SRTM HGT payloads as square grids of big-endian signed 16-bit metre samples. The implementation and tests use open specifications and synthetic/tiny fixtures only. + +To verify the importer feature surface: + +```bash +cargo test hgt +cargo test hgt --features hgt +cargo run --features hgt --bin openvistapro -- info +cargo test --no-default-features +``` + The default `render` mode writes a deterministic top-down elevation preview. Passing `--camera-demo` switches to the current CPU perspective renderer spike: a simple pinhole-camera raymarcher with bilinear height sampling, fixed step diff --git a/src/cli.rs b/src/cli.rs index 552cae8..8ca872a 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -121,7 +121,14 @@ pub fn supported_presets() -> &'static [&'static str] { } pub fn supported_importers() -> &'static [&'static str] { - &[] + #[cfg(feature = "hgt")] + { + &["hgt"] + } + #[cfg(not(feature = "hgt"))] + { + &[] + } } pub fn info_text() -> String { @@ -257,10 +264,30 @@ mod tests { } #[test] + #[cfg(not(feature = "hgt"))] fn supported_importers_is_empty_for_now() { assert!(supported_importers().is_empty()); } + #[test] + #[cfg(not(feature = "hgt"))] + fn hgt_importer_is_hidden_when_feature_is_disabled() { + assert!(!supported_importers().contains(&"hgt")); + } + + #[test] + #[cfg(feature = "hgt")] + fn supported_importers_lists_hgt_when_feature_is_enabled() { + assert!(supported_importers().contains(&"hgt")); + } + + #[test] + #[cfg(feature = "hgt")] + fn info_text_lists_hgt_importer_when_feature_is_enabled() { + let text = info_text(); + assert!(text.contains("hgt"), "got: {text:?}"); + } + #[test] fn info_text_contains_program_name_and_version() { let text = info_text(); diff --git a/src/import.rs b/src/import.rs index 7ee1d74..13c1943 100644 --- a/src/import.rs +++ b/src/import.rs @@ -31,6 +31,12 @@ //! elevation rows. `unit` is `meters` or `feet`. After the headers, the body //! holds exactly `width * height` whitespace-separated decimal samples in //! row-major order. Blank lines and `#` comments are ignored anywhere. +//! +//! # The `hgt` format +//! +//! With the default `hgt` Cargo feature enabled, [`import_hgt`] reads SRTM HGT +//! payloads: square grids of big-endian signed 16-bit elevation samples in +//! metres. Tests use tiny synthetic byte arrays rather than real terrain tiles. use std::fmt; @@ -201,6 +207,10 @@ impl From for ImportError { /// Format identifier recorded for terrains parsed by [`import_ovp_text`]. pub const OVP_TEXT_FORMAT: &str = "ovp-text"; +/// Format identifier recorded for SRTM `.hgt` terrains parsed by [`import_hgt`]. +#[cfg(feature = "hgt")] +pub const HGT_FORMAT: &str = "hgt"; + /// Import a terrain from the project-owned `ovp-text` plain-text heightfield /// format described in the module documentation. /// @@ -274,6 +284,56 @@ pub fn import_ovp_text(source: &str) -> Result { Ok(ImportedTerrain::new(grid, metadata)) } +/// Import a terrain from an SRTM `.hgt` payload. +/// +/// SRTM HGT tiles are square grids of big-endian signed 16-bit elevation +/// samples in metres. This importer operates on caller-provided bytes only, so +/// tests can use tiny synthetic fixtures rather than real geodata or any +/// proprietary reference material. +#[cfg(feature = "hgt")] +pub fn import_hgt(source: &[u8]) -> Result { + if source.is_empty() { + return Err(ImportError::InvalidDimensions { + width: 0, + height: 0, + }); + } + + let complete_samples = source.len() / 2; + let has_partial_sample = source.len() % 2 != 0; + let root = square_root_floor(complete_samples); + + if has_partial_sample { + return Err(ImportError::SampleCountMismatch { + expected: complete_samples + 1, + actual: complete_samples, + }); + } + + if root * root != complete_samples { + let next_side = root + 1; + return Err(ImportError::SampleCountMismatch { + expected: next_side * next_side, + actual: complete_samples, + }); + } + + let mut samples = Vec::with_capacity(complete_samples); + for bytes in source.chunks_exact(2) { + samples.push(i16::from_be_bytes([bytes[0], bytes[1]]) as f32); + } + + let side = root as u32; + let grid = HeightGrid::new(side, side, samples)?; + let metadata = TerrainSourceMetadata::new(HGT_FORMAT, side, side, ElevationUnit::Meters); + Ok(ImportedTerrain::new(grid, metadata)) +} + +#[cfg(feature = "hgt")] +fn square_root_floor(value: usize) -> usize { + (value as f64).sqrt() as usize +} + fn parse_dimension(key: &str, value: &str) -> Result { value .parse() @@ -486,4 +546,99 @@ unit: feet assert_eq!(imported.metadata().format(), "ovp-text"); assert_eq!(imported.metadata().elevation_unit(), ElevationUnit::Meters); } + + // ---- import_hgt ---- + + #[cfg(feature = "hgt")] + fn hgt_bytes(samples: &[i16]) -> Vec { + samples + .iter() + .flat_map(|sample| sample.to_be_bytes()) + .collect() + } + + #[cfg(feature = "hgt")] + #[test] + fn hgt_import_decodes_big_endian_signed_i16_square_grid() { + let bytes = hgt_bytes(&[10, -20, 300, -400]); + + let imported = import_hgt(&bytes).expect("2x2 HGT fixture should import"); + + assert_eq!(imported.grid().width(), 2); + assert_eq!(imported.grid().height(), 2); + assert_eq!(imported.grid().sample(0, 0), Some(10.0)); + assert_eq!(imported.grid().sample(1, 0), Some(-20.0)); + assert_eq!(imported.grid().sample(0, 1), Some(300.0)); + assert_eq!(imported.grid().sample(1, 1), Some(-400.0)); + assert_eq!(imported.metadata().format(), "hgt"); + assert_eq!(imported.metadata().width(), 2); + assert_eq!(imported.metadata().height(), 2); + assert_eq!(imported.metadata().elevation_unit(), ElevationUnit::Meters); + } + + #[cfg(feature = "hgt")] + #[test] + fn hgt_import_reports_min_max_from_decoded_samples() { + let bytes = hgt_bytes(&[123, -32768, 0, 32767, 45, -90, 12, 999, -1000]); + + let imported = import_hgt(&bytes).expect("3x3 HGT fixture should import"); + + assert_eq!(imported.grid().width(), 3); + assert_eq!(imported.grid().height(), 3); + assert_eq!(imported.grid().min_max(), Some((-32768.0, 32767.0))); + } + + #[cfg(feature = "hgt")] + #[test] + fn hgt_import_rejects_missing_samples() { + let bytes = hgt_bytes(&[1, 2, 3]); + + let err = import_hgt(&bytes).expect_err("3 samples cannot form a square HGT grid"); + + assert!( + matches!( + err, + ImportError::SampleCountMismatch { + expected: 4, + actual: 3 + } + ), + "got: {err:?}" + ); + } + + #[cfg(feature = "hgt")] + #[test] + fn hgt_import_rejects_odd_byte_count_as_invalid_sample_count() { + let err = import_hgt(&[0x00, 0x01, 0xff]) + .expect_err("HGT samples are two-byte signed big-endian integers"); + + assert!( + matches!( + err, + ImportError::SampleCountMismatch { + expected: 2, + actual: 1 + } + ), + "got: {err:?}" + ); + } + + #[cfg(feature = "hgt")] + #[test] + fn hgt_import_rejects_empty_payload() { + let err = import_hgt(&[]).expect_err("empty HGT payload has no dimensions"); + + assert!( + matches!( + err, + ImportError::InvalidDimensions { + width: 0, + height: 0 + } + ), + "got: {err:?}" + ); + } } -- 2.39.5