feat: add hgt terrain importer #8
@@ -6,6 +6,7 @@ edition = "2024"
|
||||
[features]
|
||||
default = []
|
||||
app = ["dep:eframe"]
|
||||
hgt = []
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4.6.1", features = ["derive"] }
|
||||
|
||||
@@ -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
|
||||
|
||||
+28
-1
@@ -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();
|
||||
|
||||
+155
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user