feat: add ASCII grid importer #6
@@ -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
@@ -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
@@ -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
@@ -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