feat: add hgt terrain importer #8

Merged
moldybits merged 1 commits from feat/hgt-srtm-importer-t_321b4e19 into main 2026-05-15 21:09:46 -04:00
4 changed files with 199 additions and 1 deletions
+1
View File
@@ -6,6 +6,7 @@ edition = "2024"
[features]
default = []
app = ["dep:eframe"]
hgt = []
[dependencies]
clap = { version = "4.6.1", features = ["derive"] }
+15
View File
@@ -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
+27
View File
@@ -121,8 +121,15 @@ 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 {
use std::fmt::Write as _;
@@ -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();
+155
View File
@@ -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<TerrainError> 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<ImportedTerrain, ImportError> {
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<ImportedTerrain, ImportError> {
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<u32, ImportError> {
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<u8> {
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:?}"
);
}
}