diff --git a/docs/legal/asset-policy.md b/docs/legal/asset-policy.md index baa71b8..3e6f34f 100644 --- a/docs/legal/asset-policy.md +++ b/docs/legal/asset-policy.md @@ -18,6 +18,22 @@ The repository `.gitignore` excludes `reference/`, `.work/`, archives, disk imag - Synthetic test fixtures generated specifically for this project. - 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 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. diff --git a/src/import.rs b/src/import.rs new file mode 100644 index 0000000..7ee1d74 --- /dev/null +++ b/src/import.rs @@ -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, + 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 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 { + let mut width: Option = None; + let mut height: Option = None; + let mut unit: Option = None; + let mut samples: Vec = 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 { + value + .parse() + .map_err(|_| ImportError::MalformedSource(format!("invalid {key} value {value:?}"))) +} + +fn parse_unit(value: &str) -> Result { + 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) {} + 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); + } +} diff --git a/src/lib.rs b/src/lib.rs index 3a1b346..50590fd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ pub mod cli; pub mod colormap; +pub mod import; pub mod render; pub mod scene; pub mod scene_file; diff --git a/tests/fixtures/open/tiny-heightfield.ovptext b/tests/fixtures/open/tiny-heightfield.ovptext new file mode 100644 index 0000000..907c230 --- /dev/null +++ b/tests/fixtures/open/tiny-heightfield.ovptext @@ -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