feat: add hgt terrain importer #8
@@ -6,6 +6,7 @@ edition = "2024"
|
|||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
app = ["dep:eframe"]
|
app = ["dep:eframe"]
|
||||||
|
hgt = []
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clap = { version = "4.6.1", features = ["derive"] }
|
clap = { version = "4.6.1", features = ["derive"] }
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ This repository currently contains:
|
|||||||
- A first-pass knowledgebase under `docs/knowledgebase/`.
|
- A first-pass knowledgebase under `docs/knowledgebase/`.
|
||||||
- An implementation roadmap under `docs/plans/`.
|
- An implementation roadmap under `docs/plans/`.
|
||||||
- Legal and reference-material hygiene notes under `docs/legal/` and `docs/research/`.
|
- 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
|
## 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.
|
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.
|
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.
|
The default `render` mode writes a deterministic top-down elevation preview.
|
||||||
Passing `--camera-demo` switches to the current CPU perspective renderer spike:
|
Passing `--camera-demo` switches to the current CPU perspective renderer spike:
|
||||||
a simple pinhole-camera raymarcher with bilinear height sampling, fixed step
|
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] {
|
pub fn supported_importers() -> &'static [&'static str] {
|
||||||
&[]
|
#[cfg(feature = "hgt")]
|
||||||
|
{
|
||||||
|
&["hgt"]
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "hgt"))]
|
||||||
|
{
|
||||||
|
&[]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn info_text() -> String {
|
pub fn info_text() -> String {
|
||||||
@@ -257,10 +264,30 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[cfg(not(feature = "hgt"))]
|
||||||
fn supported_importers_is_empty_for_now() {
|
fn supported_importers_is_empty_for_now() {
|
||||||
assert!(supported_importers().is_empty());
|
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]
|
#[test]
|
||||||
fn info_text_contains_program_name_and_version() {
|
fn info_text_contains_program_name_and_version() {
|
||||||
let text = info_text();
|
let text = info_text();
|
||||||
|
|||||||
+155
@@ -31,6 +31,12 @@
|
|||||||
//! elevation rows. `unit` is `meters` or `feet`. After the headers, the body
|
//! elevation rows. `unit` is `meters` or `feet`. After the headers, the body
|
||||||
//! holds exactly `width * height` whitespace-separated decimal samples in
|
//! holds exactly `width * height` whitespace-separated decimal samples in
|
||||||
//! row-major order. Blank lines and `#` comments are ignored anywhere.
|
//! 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;
|
use std::fmt;
|
||||||
|
|
||||||
@@ -201,6 +207,10 @@ impl From<TerrainError> for ImportError {
|
|||||||
/// Format identifier recorded for terrains parsed by [`import_ovp_text`].
|
/// Format identifier recorded for terrains parsed by [`import_ovp_text`].
|
||||||
pub const OVP_TEXT_FORMAT: &str = "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
|
/// Import a terrain from the project-owned `ovp-text` plain-text heightfield
|
||||||
/// format described in the module documentation.
|
/// 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))
|
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> {
|
fn parse_dimension(key: &str, value: &str) -> Result<u32, ImportError> {
|
||||||
value
|
value
|
||||||
.parse()
|
.parse()
|
||||||
@@ -486,4 +546,99 @@ unit: feet
|
|||||||
assert_eq!(imported.metadata().format(), "ovp-text");
|
assert_eq!(imported.metadata().format(), "ovp-text");
|
||||||
assert_eq!(imported.metadata().elevation_unit(), ElevationUnit::Meters);
|
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