feat: add ASCII grid importer #6

Merged
moldybits merged 1 commits from feat/ascii-grid-importer-open into main 2026-05-16 13:52:31 -04:00
4 changed files with 252 additions and 3 deletions
+1
View File
@@ -7,6 +7,7 @@ edition = "2024"
default = [] default = []
app = ["dep:eframe"] app = ["dep:eframe"]
hgt = [] hgt = []
ascii-grid-import = []
[dependencies] [dependencies]
clap = { version = "4.6.1", features = ["derive"] } clap = { version = "4.6.1", features = ["derive"] }
+16 -2
View File
@@ -151,11 +151,19 @@ pub fn supported_presets() -> &'static [&'static str] {
} }
pub fn supported_importers() -> &'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"] &["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"] &["heightmap"]
} }
@@ -307,6 +315,12 @@ mod tests {
assert!(supported_importers().contains(&"heightmap")); 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] #[test]
#[cfg(not(feature = "hgt"))] #[cfg(not(feature = "hgt"))]
fn hgt_importer_is_hidden_when_feature_is_disabled() { fn hgt_importer_is_hidden_when_feature_is_disabled() {
+227 -1
View File
@@ -34,9 +34,36 @@
//! //!
//! # The `hgt` format //! # 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 //! payloads: square grids of big-endian signed 16-bit elevation samples in
//! metres. Tests use tiny synthetic byte arrays rather than real terrain tiles. //! 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; use std::fmt;
@@ -350,6 +377,149 @@ fn parse_unit(value: &str) -> Result<ElevationUnit, ImportError> {
} }
} }
/// 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<ImportedTerrain, ImportError> {
let mut ncols: Option<u32> = None;
let mut nrows: Option<u32> = None;
let mut cellsize: Option<f64> = None;
let mut x_origin: Option<f64> = None;
let mut y_origin: Option<f64> = None;
let mut _nodata: Option<f32> = None;
let mut samples: Vec<f32> = 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<Item = &'a str>,
) -> 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<f64, ImportError> {
value
.parse()
.map_err(|_| ImportError::MalformedSource(format!("invalid {key} value {value:?}")))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -641,4 +811,60 @@ unit: feet
"got: {err:?}" "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)));
}
} }
+8
View File
@@ -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