diff --git a/Cargo.toml b/Cargo.toml index 805c9fb..03dce50 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ edition = "2024" default = [] app = ["dep:eframe"] hgt = [] +ascii-grid-import = [] [dependencies] clap = { version = "4.6.1", features = ["derive"] } diff --git a/src/cli.rs b/src/cli.rs index a730b58..e444f19 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -151,11 +151,19 @@ pub fn supported_presets() -> &'static [&'static str] { } pub fn supported_importers() -> &'static [&'static str] { - #[cfg(feature = "hgt")] + #[cfg(all(feature = "hgt", feature = "ascii-grid-import"))] + { + &["heightmap", "hgt", "esri-ascii-grid"] + } + #[cfg(all(feature = "hgt", not(feature = "ascii-grid-import")))] { &["heightmap", "hgt"] } - #[cfg(not(feature = "hgt"))] + #[cfg(all(not(feature = "hgt"), feature = "ascii-grid-import"))] + { + &["heightmap", "esri-ascii-grid"] + } + #[cfg(all(not(feature = "hgt"), not(feature = "ascii-grid-import")))] { &["heightmap"] } @@ -307,6 +315,12 @@ mod tests { assert!(supported_importers().contains(&"heightmap")); } + #[cfg(feature = "ascii-grid-import")] + #[test] + fn supported_importers_lists_esri_ascii_grid_with_feature() { + assert!(supported_importers().contains(&"esri-ascii-grid")); + } + #[test] #[cfg(not(feature = "hgt"))] fn hgt_importer_is_hidden_when_feature_is_disabled() { diff --git a/src/import.rs b/src/import.rs index 13c1943..8cf679a 100644 --- a/src/import.rs +++ b/src/import.rs @@ -34,9 +34,36 @@ //! //! # The `hgt` format //! -//! With the default `hgt` Cargo feature enabled, [`import_hgt`] reads SRTM HGT +//! With the `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. +//! +//! # The `esri-ascii-grid` format +//! +//! When the `ascii-grid-import` Cargo feature is enabled, +//! [`import_esri_ascii_grid`] reads the widely supported ESRI ASCII Grid text +//! format. The source begins with `keyword value` header lines and is followed +//! by `nrows` rows of `ncols` whitespace-separated elevation samples in +//! row-major order: +//! +//! ```text +//! ncols 3 +//! nrows 2 +//! xllcorner 100.0 +//! yllcorner 200.0 +//! cellsize 30.0 +//! NODATA_value -9999 +//! 1 2 3 +//! 4 5 6 +//! ``` +//! +//! `ncols`, `nrows` and `cellsize` are required, as is one horizontal and one +//! vertical origin: `xllcorner`/`yllcorner` (cell-corner reference) or +//! `xllcenter`/`yllcenter` (cell-centre reference). `NODATA_value` is optional. +//! Header keywords are matched case-insensitively. Elevations are read as +//! row-major [`f32`] samples and the source unit is recorded as metres. The +//! parser is gated behind the feature so the default build keeps only the +//! project-owned `ovp-text` importer. use std::fmt; @@ -350,6 +377,149 @@ fn parse_unit(value: &str) -> Result { } } +/// Format identifier recorded for terrains parsed by [`import_esri_ascii_grid`]. +#[cfg(feature = "ascii-grid-import")] +pub const ESRI_ASCII_GRID_FORMAT: &str = "esri-ascii-grid"; + +/// Import a terrain from the ESRI ASCII Grid text format described in the +/// module documentation. +/// +/// `ncols`, `nrows` and `cellsize` headers are required, along with one +/// horizontal and one vertical origin (`xllcorner`/`yllcorner` or +/// `xllcenter`/`yllcenter`); `NODATA_value` is optional. The body holds exactly +/// `ncols * nrows` elevation samples in row-major order. Like +/// [`import_ovp_text`], the whole source is parsed in memory and the source +/// unit is recorded as metres. +#[cfg(feature = "ascii-grid-import")] +pub fn import_esri_ascii_grid(source: &str) -> Result { + let mut ncols: Option = None; + let mut nrows: Option = None; + let mut cellsize: Option = None; + let mut x_origin: Option = None; + let mut y_origin: Option = None; + let mut _nodata: Option = None; + let mut samples: Vec = Vec::new(); + let mut in_body = false; + + for raw in source.lines() { + let line = raw.trim(); + if line.is_empty() { + continue; + } + + let mut tokens = line.split_whitespace(); + let first = tokens + .next() + .expect("a non-empty trimmed line has at least one token"); + + if !in_body { + let keyword = first.to_ascii_lowercase(); + match keyword.as_str() { + "ncols" => { + ncols = Some(parse_dimension("ncols", esri_value("ncols", &mut tokens)?)?); + continue; + } + "nrows" => { + nrows = Some(parse_dimension("nrows", esri_value("nrows", &mut tokens)?)?); + continue; + } + "cellsize" => { + cellsize = Some(parse_esri_float( + "cellsize", + esri_value("cellsize", &mut tokens)?, + )?); + continue; + } + "xllcorner" | "xllcenter" => { + x_origin = Some(parse_esri_float( + &keyword, + esri_value(&keyword, &mut tokens)?, + )?); + continue; + } + "yllcorner" | "yllcenter" => { + y_origin = Some(parse_esri_float( + &keyword, + esri_value(&keyword, &mut tokens)?, + )?); + continue; + } + "nodata_value" => { + _nodata = Some(parse_esri_float( + "NODATA_value", + esri_value("NODATA_value", &mut tokens)?, + )? as f32); + continue; + } + _ => in_body = true, + } + } + + for token in std::iter::once(first).chain(tokens) { + let value: f32 = token.parse().map_err(|_| { + ImportError::MalformedSource(format!("invalid elevation sample {token:?}")) + })?; + samples.push(value); + } + } + + let ncols = + ncols.ok_or_else(|| ImportError::MalformedSource("missing 'ncols' header".to_string()))?; + let nrows = + nrows.ok_or_else(|| ImportError::MalformedSource("missing 'nrows' header".to_string()))?; + cellsize + .ok_or_else(|| ImportError::MalformedSource("missing 'cellsize' header".to_string()))?; + x_origin.ok_or_else(|| { + ImportError::MalformedSource( + "missing horizontal origin header ('xllcorner' or 'xllcenter')".to_string(), + ) + })?; + y_origin.ok_or_else(|| { + ImportError::MalformedSource( + "missing vertical origin header ('yllcorner' or 'yllcenter')".to_string(), + ) + })?; + + if ncols == 0 || nrows == 0 { + return Err(ImportError::InvalidDimensions { + width: ncols, + height: nrows, + }); + } + + let expected = (ncols as usize) * (nrows as usize); + if samples.len() != expected { + return Err(ImportError::SampleCountMismatch { + expected, + actual: samples.len(), + }); + } + + let grid = HeightGrid::new(ncols, nrows, samples)?; + let metadata = + TerrainSourceMetadata::new(ESRI_ASCII_GRID_FORMAT, ncols, nrows, ElevationUnit::Meters); + Ok(ImportedTerrain::new(grid, metadata)) +} + +/// Take a header keyword's single value token, or report a malformed header. +#[cfg(feature = "ascii-grid-import")] +fn esri_value<'a>( + key: &str, + tokens: &mut impl Iterator, +) -> Result<&'a str, ImportError> { + tokens + .next() + .ok_or_else(|| ImportError::MalformedSource(format!("header {key:?} is missing its value"))) +} + +/// Parse an ESRI header value as a floating-point number. +#[cfg(feature = "ascii-grid-import")] +fn parse_esri_float(key: &str, value: &str) -> Result { + value + .parse() + .map_err(|_| ImportError::MalformedSource(format!("invalid {key} value {value:?}"))) +} + #[cfg(test)] mod tests { use super::*; @@ -641,4 +811,60 @@ unit: feet "got: {err:?}" ); } + + // ---- import_esri_ascii_grid ---- + + #[cfg(feature = "ascii-grid-import")] + #[test] + fn imports_tiny_esri_ascii_grid_fixture() { + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/open/tiny-esri-grid.asc" + ); + let text = std::fs::read_to_string(path).expect("fixture file should be readable"); + let imported = import_esri_ascii_grid(&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.grid().min_max(), Some((1.0, 6.0))); + assert_eq!(imported.metadata().format(), "esri-ascii-grid"); + assert_eq!(imported.metadata().elevation_unit(), ElevationUnit::Meters); + } + + #[cfg(feature = "ascii-grid-import")] + #[test] + fn esri_ascii_grid_rejects_malformed_header() { + let source = "ncols 2\nnrows nope\nxllcorner 0\nyllcorner 0\ncellsize 1\n1 2\n3 4\n"; + let err = import_esri_ascii_grid(source).expect_err("malformed nrows must be rejected"); + assert!( + matches!(err, ImportError::MalformedSource(_)), + "got: {err:?}" + ); + } + + #[cfg(feature = "ascii-grid-import")] + #[test] + fn esri_ascii_grid_rejects_wrong_sample_count() { + let source = "ncols 3\nnrows 2\nxllcorner 0\nyllcorner 0\ncellsize 1\n1 2 3\n4 5\n"; + let err = import_esri_ascii_grid(source).expect_err("short grid must be rejected"); + assert!( + matches!( + err, + ImportError::SampleCountMismatch { + expected: 6, + actual: 5 + } + ), + "got: {err:?}" + ); + } + + #[cfg(feature = "ascii-grid-import")] + #[test] + fn esri_ascii_grid_reports_min_max_for_negative_samples() { + let source = "ncols 2\nnrows 2\nxllcorner 0\nyllcorner 0\ncellsize 1\n-2.5 3\n0 8.25\n"; + let imported = import_esri_ascii_grid(source).expect("valid grid should import"); + assert_eq!(imported.grid().min_max(), Some((-2.5, 8.25))); + } } diff --git a/tests/fixtures/open/tiny-esri-grid.asc b/tests/fixtures/open/tiny-esri-grid.asc new file mode 100644 index 0000000..f06c7ce --- /dev/null +++ b/tests/fixtures/open/tiny-esri-grid.asc @@ -0,0 +1,8 @@ +ncols 3 +nrows 2 +xllcorner 100.0 +yllcorner 200.0 +cellsize 30.0 +NODATA_value -9999 +1 2 3 +4 5 6