feat: add ASCII grid importer #6
@@ -7,6 +7,7 @@ edition = "2024"
|
||||
default = []
|
||||
app = ["dep:eframe"]
|
||||
hgt = []
|
||||
ascii-grid-import = []
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4.6.1", features = ["derive"] }
|
||||
|
||||
+16
-2
@@ -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() {
|
||||
|
||||
+227
-1
@@ -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<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)]
|
||||
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)));
|
||||
}
|
||||
}
|
||||
|
||||
+8
@@ -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
|
||||
Reference in New Issue
Block a user