feat: add terrain import boundary #3

Merged
moldybits merged 1 commits from feat/terrain-import-abstraction into main 2026-05-15 20:48:29 -04:00
4 changed files with 514 additions and 0 deletions
+16
View File
@@ -18,6 +18,22 @@ The repository `.gitignore` excludes `reference/`, `.work/`, archives, disk imag
- Synthetic test fixtures generated specifically for this project. - Synthetic test fixtures generated specifically for this project.
- Public-domain terrain data, such as USGS/NASA datasets, when redistributed according to source terms. - Public-domain terrain data, such as USGS/NASA datasets, when redistributed according to source terms.
## Test fixture layout
Project-owned synthetic terrain fixtures live under `tests/fixtures/open/`. The
`open/` segment marks the directory as cleared for commit: every file there
must be authored specifically for OpenVistaPro, or carry an explicit open
license with attribution alongside it.
Never place proprietary VistaPro data, extracted program files, manuals,
screenshots, archives, disk images, or local-only reference payloads under
`tests/`. Those belong in the git-ignored `reference/` and `.work/` directories
and must stay local-only.
Importers parse only open or synthetic formats. The `ovp-text` plain-text
heightfield format read by `src/import.rs` is an original OpenVistaPro format;
it is not a reimplementation of any proprietary VistaPro file layout.
## Reverse engineering boundary ## Reverse engineering boundary
Use the reference copies for product behavior research only. Avoid decompilation or binary translation unless a future legal review explicitly approves a narrow compatibility investigation. Use the reference copies for product behavior research only. Avoid decompilation or binary translation unless a future legal review explicitly approves a narrow compatibility investigation.
+489
View File
@@ -0,0 +1,489 @@
//! Terrain import boundary.
//!
//! This module isolates open-format terrain importers from the renderer's
//! internal model. An importer parses an external representation entirely in
//! memory and yields an [`ImportedTerrain`]: a validated [`HeightGrid`] plus
//! [`TerrainSourceMetadata`] recording where the data came from. Renderer and
//! scene code only ever see the clean internal `HeightGrid`, never a
//! source-specific format.
//!
//! Per `docs/legal/asset-policy.md`, importers operate only on open or
//! synthetic data. No proprietary VistaPro file compatibility is implemented
//! here; historical-format support is deferred to a separately reviewed
//! clean-room plan.
//!
//! # The `ovp-text` format
//!
//! [`import_ovp_text`] reads a tiny, project-owned plain-text heightfield
//! format authored for OpenVistaPro. It is intentionally minimal: it exists to
//! exercise the import boundary and to give tests a human-readable fixture.
//!
//! ```text
//! # comment lines start with '#'
//! width: 3
//! height: 2
//! unit: meters
//! 10 20 30
//! 40 50 60
//! ```
//!
//! Three headers are required (`width`, `height`, `unit`) and must precede the
//! 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.
use std::fmt;
use crate::terrain::{HeightGrid, TerrainError};
/// Vertical unit of the elevation samples in an imported terrain source.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ElevationUnit {
/// Elevations expressed in metres.
Meters,
/// Elevations expressed in feet.
Feet,
}
impl ElevationUnit {
/// Lower-case identifier used in `ovp-text` headers and in `info` output.
pub fn as_str(self) -> &'static str {
match self {
ElevationUnit::Meters => "meters",
ElevationUnit::Feet => "feet",
}
}
}
impl fmt::Display for ElevationUnit {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
/// Provenance for an imported terrain: which open format it came from, the
/// source grid dimensions, and the vertical unit of its samples.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TerrainSourceMetadata {
format: String,
width: u32,
height: u32,
elevation_unit: ElevationUnit,
}
impl TerrainSourceMetadata {
/// Build metadata for a terrain parsed from `format` with the given source
/// dimensions and elevation unit.
pub fn new(
format: impl Into<String>,
width: u32,
height: u32,
elevation_unit: ElevationUnit,
) -> Self {
TerrainSourceMetadata {
format: format.into(),
width,
height,
elevation_unit,
}
}
/// Short identifier of the source format, e.g. `"ovp-text"`.
pub fn format(&self) -> &str {
&self.format
}
/// Width of the source grid in samples.
pub fn width(&self) -> u32 {
self.width
}
/// Height of the source grid in samples.
pub fn height(&self) -> u32 {
self.height
}
/// Vertical unit the source expressed elevations in.
pub fn elevation_unit(&self) -> ElevationUnit {
self.elevation_unit
}
}
/// A terrain parsed from an open external format: the validated internal
/// [`HeightGrid`] paired with [`TerrainSourceMetadata`] describing its origin.
#[derive(Debug, Clone)]
pub struct ImportedTerrain {
grid: HeightGrid,
metadata: TerrainSourceMetadata,
}
impl ImportedTerrain {
/// Pair a parsed `grid` with its source `metadata`.
pub fn new(grid: HeightGrid, metadata: TerrainSourceMetadata) -> Self {
ImportedTerrain { grid, metadata }
}
/// Borrow the internal height grid.
pub fn grid(&self) -> &HeightGrid {
&self.grid
}
/// Borrow the source metadata.
pub fn metadata(&self) -> &TerrainSourceMetadata {
&self.metadata
}
/// Consume the imported terrain, returning just the internal height grid.
pub fn into_grid(self) -> HeightGrid {
self.grid
}
}
/// Errors produced while importing terrain from an open external format.
#[derive(Debug)]
pub enum ImportError {
/// The source declared a zero width or height.
InvalidDimensions {
/// Declared source width.
width: u32,
/// Declared source height.
height: u32,
},
/// The source payload was structurally malformed: a missing or unknown
/// header, an unparseable number, or otherwise unreadable content.
MalformedSource(String),
/// The number of elevation samples did not match the declared dimensions.
SampleCountMismatch {
/// Sample count implied by the declared dimensions.
expected: usize,
/// Sample count actually found in the source body.
actual: usize,
},
/// Constructing the internal [`HeightGrid`] from parsed data failed.
Grid(TerrainError),
}
impl fmt::Display for ImportError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ImportError::InvalidDimensions { width, height } => write!(
f,
"imported terrain has invalid dimensions {width}x{height}; \
width and height must be non-zero"
),
ImportError::MalformedSource(detail) => {
write!(f, "malformed terrain source: {detail}")
}
ImportError::SampleCountMismatch { expected, actual } => write!(
f,
"expected {expected} elevation samples for the declared dimensions, got {actual}"
),
ImportError::Grid(e) => write!(f, "could not build height grid from import: {e}"),
}
}
}
impl std::error::Error for ImportError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
ImportError::Grid(e) => Some(e),
_ => None,
}
}
}
impl From<TerrainError> for ImportError {
fn from(e: TerrainError) -> Self {
ImportError::Grid(e)
}
}
/// Format identifier recorded for terrains parsed by [`import_ovp_text`].
pub const OVP_TEXT_FORMAT: &str = "ovp-text";
/// Import a terrain from the project-owned `ovp-text` plain-text heightfield
/// format described in the module documentation.
///
/// The entire source is parsed in memory; no filesystem or network access is
/// performed. Callers that have a file path read it themselves and pass the
/// contents here, which keeps the importer easy to test and reuse.
pub fn import_ovp_text(source: &str) -> Result<ImportedTerrain, ImportError> {
let mut width: Option<u32> = None;
let mut height: Option<u32> = None;
let mut unit: Option<ElevationUnit> = None;
let mut samples: Vec<f32> = Vec::new();
let mut seen_sample = false;
for raw in source.lines() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((key, value)) = line.split_once(':') {
if seen_sample {
return Err(ImportError::MalformedSource(format!(
"header field {:?} appears after elevation samples",
key.trim()
)));
}
let key = key.trim();
let value = value.trim();
match key {
"width" => width = Some(parse_dimension(key, value)?),
"height" => height = Some(parse_dimension(key, value)?),
"unit" => unit = Some(parse_unit(value)?),
other => {
return Err(ImportError::MalformedSource(format!(
"unknown header field {other:?}"
)));
}
}
} else {
seen_sample = true;
for token in line.split_whitespace() {
let value: f32 = token.parse().map_err(|_| {
ImportError::MalformedSource(format!("invalid elevation sample {token:?}"))
})?;
samples.push(value);
}
}
}
let width =
width.ok_or_else(|| ImportError::MalformedSource("missing 'width' header".to_string()))?;
let height = height
.ok_or_else(|| ImportError::MalformedSource("missing 'height' header".to_string()))?;
let unit =
unit.ok_or_else(|| ImportError::MalformedSource("missing 'unit' header".to_string()))?;
if width == 0 || height == 0 {
return Err(ImportError::InvalidDimensions { width, height });
}
let expected = (width as usize) * (height as usize);
if samples.len() != expected {
return Err(ImportError::SampleCountMismatch {
expected,
actual: samples.len(),
});
}
let grid = HeightGrid::new(width, height, samples)?;
let metadata = TerrainSourceMetadata::new(OVP_TEXT_FORMAT, width, height, unit);
Ok(ImportedTerrain::new(grid, metadata))
}
fn parse_dimension(key: &str, value: &str) -> Result<u32, ImportError> {
value
.parse()
.map_err(|_| ImportError::MalformedSource(format!("invalid {key} value {value:?}")))
}
fn parse_unit(value: &str) -> Result<ElevationUnit, ImportError> {
match value {
"meters" => Ok(ElevationUnit::Meters),
"feet" => Ok(ElevationUnit::Feet),
other => Err(ImportError::MalformedSource(format!(
"unknown elevation unit {other:?} (expected \"meters\" or \"feet\")"
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::terrain::HeightGrid;
// ---- TerrainSourceMetadata ----
#[test]
fn metadata_records_format_dimensions_and_units() {
let meta = TerrainSourceMetadata::new("hgt", 1201, 1201, ElevationUnit::Meters);
assert_eq!(meta.format(), "hgt");
assert_eq!(meta.width(), 1201);
assert_eq!(meta.height(), 1201);
assert_eq!(meta.elevation_unit(), ElevationUnit::Meters);
}
#[test]
fn elevation_unit_displays_human_readable_name() {
assert_eq!(ElevationUnit::Meters.to_string(), "meters");
assert_eq!(ElevationUnit::Feet.to_string(), "feet");
}
// ---- ImportedTerrain ----
#[test]
fn imported_terrain_exposes_grid_and_metadata() {
let grid = HeightGrid::plane(2, 2).unwrap();
let meta = TerrainSourceMetadata::new("ovp-text", 2, 2, ElevationUnit::Meters);
let imported = ImportedTerrain::new(grid, meta.clone());
assert_eq!(imported.grid().width(), 2);
assert_eq!(imported.grid().height(), 2);
assert_eq!(imported.metadata(), &meta);
}
#[test]
fn imported_terrain_into_grid_yields_owned_height_grid() {
let grid = HeightGrid::plane(3, 1).unwrap();
let meta = TerrainSourceMetadata::new("ovp-text", 3, 1, ElevationUnit::Meters);
let imported = ImportedTerrain::new(grid, meta);
let owned: HeightGrid = imported.into_grid();
assert_eq!(owned.width(), 3);
assert_eq!(owned.height(), 1);
}
// ---- ImportError ----
#[test]
fn invalid_dimensions_error_is_displayable() {
let err = ImportError::InvalidDimensions {
width: 0,
height: 4,
};
let msg = err.to_string();
assert!(msg.contains("dimension"), "got: {msg:?}");
assert!(msg.contains('0') && msg.contains('4'), "got: {msg:?}");
}
#[test]
fn malformed_source_error_is_displayable() {
let err = ImportError::MalformedSource("missing 'width' header".to_string());
assert!(err.to_string().contains("missing 'width' header"));
}
#[test]
fn sample_count_mismatch_error_is_displayable() {
let err = ImportError::SampleCountMismatch {
expected: 6,
actual: 5,
};
let msg = err.to_string();
assert!(msg.contains('6') && msg.contains('5'), "got: {msg:?}");
}
#[test]
fn import_error_is_std_error() {
fn assert_error<E: std::error::Error>(_: &E) {}
assert_error(&ImportError::MalformedSource("x".into()));
}
// ---- import_ovp_text ----
#[test]
fn imports_tiny_ovp_text_heightfield() {
let source = "\
width: 3
height: 2
unit: meters
10 20 30
40 50 60
";
let imported = import_ovp_text(source).expect("valid ovp-text should import");
assert_eq!(imported.grid().width(), 3);
assert_eq!(imported.grid().height(), 2);
assert_eq!(imported.grid().sample(0, 0), Some(10.0));
assert_eq!(imported.grid().sample(2, 1), Some(60.0));
assert_eq!(imported.metadata().format(), "ovp-text");
assert_eq!(imported.metadata().width(), 3);
assert_eq!(imported.metadata().height(), 2);
assert_eq!(imported.metadata().elevation_unit(), ElevationUnit::Meters);
}
#[test]
fn import_skips_comments_and_blank_lines() {
let source = "\
# OpenVistaPro plain-text heightfield
width: 2
height: 2
unit: feet
# elevation rows follow
1 2
3 4
";
let imported = import_ovp_text(source).expect("comments and blanks should be ignored");
assert_eq!(imported.metadata().elevation_unit(), ElevationUnit::Feet);
assert_eq!(imported.grid().sample(1, 1), Some(4.0));
}
#[test]
fn import_rejects_missing_header_field() {
let source = "width: 2\nheight: 2\n1 2\n3 4\n";
let err = import_ovp_text(source).expect_err("missing unit header must be rejected");
assert!(
matches!(err, ImportError::MalformedSource(_)),
"got: {err:?}"
);
}
#[test]
fn import_rejects_zero_dimensions() {
let source = "width: 0\nheight: 2\nunit: meters\n";
let err = import_ovp_text(source).expect_err("zero width must be rejected");
assert!(
matches!(
err,
ImportError::InvalidDimensions {
width: 0,
height: 2
}
),
"got: {err:?}"
);
}
#[test]
fn import_rejects_unparseable_sample() {
let source = "width: 2\nheight: 1\nunit: meters\n1 oops\n";
let err = import_ovp_text(source).expect_err("non-numeric sample must be rejected");
assert!(
matches!(err, ImportError::MalformedSource(_)),
"got: {err:?}"
);
}
#[test]
fn import_rejects_sample_count_mismatch() {
let source = "width: 3\nheight: 2\nunit: meters\n1 2 3\n4 5\n";
let err = import_ovp_text(source).expect_err("short sample grid must be rejected");
assert!(
matches!(
err,
ImportError::SampleCountMismatch {
expected: 6,
actual: 5
}
),
"got: {err:?}"
);
}
#[test]
fn import_rejects_unknown_unit() {
let source = "width: 1\nheight: 1\nunit: cubits\n7\n";
let err = import_ovp_text(source).expect_err("unknown unit must be rejected");
assert!(
matches!(err, ImportError::MalformedSource(_)),
"got: {err:?}"
);
}
#[test]
fn imports_project_owned_fixture_from_disk() {
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/open/tiny-heightfield.ovptext"
);
let text = std::fs::read_to_string(path).expect("fixture file should be readable");
let imported = import_ovp_text(&text).expect("fixture should import cleanly");
assert_eq!(imported.grid().width(), 3);
assert_eq!(imported.grid().height(), 2);
assert_eq!(imported.grid().sample(0, 0), Some(1.0));
assert_eq!(imported.grid().sample(2, 1), Some(6.0));
assert_eq!(imported.metadata().format(), "ovp-text");
assert_eq!(imported.metadata().elevation_unit(), ElevationUnit::Meters);
}
}
+1
View File
@@ -1,5 +1,6 @@
pub mod cli; pub mod cli;
pub mod colormap; pub mod colormap;
pub mod import;
pub mod render; pub mod render;
pub mod scene; pub mod scene;
pub mod scene_file; pub mod scene_file;
+8
View File
@@ -0,0 +1,8 @@
# OpenVistaPro plain-text heightfield fixture (ovp-text format)
# Project-owned synthetic data authored for OpenVistaPro tests.
# See docs/legal/asset-policy.md for the fixture/data policy.
width: 3
height: 2
unit: meters
1 2 3
4 5 6