From 0b8a20f1f1a825198d557935b9ab77d5c96f7601 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sat, 16 May 2026 14:18:14 -0400 Subject: [PATCH 1/4] docs: add terrain generation roadmap --- docs/plans/terrain-generation.md | 167 +++++++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 docs/plans/terrain-generation.md diff --git a/docs/plans/terrain-generation.md b/docs/plans/terrain-generation.md new file mode 100644 index 0000000..8102071 --- /dev/null +++ b/docs/plans/terrain-generation.md @@ -0,0 +1,167 @@ +# Terrain Generation Roadmap + +## Purpose + +Define the next terrain-generation workstream so future slices stay small, deterministic, and testable. This plan is intentionally clean-room: it uses only project-owned synthetic fixtures and open math/algorithm ideas, never proprietary VistaPro material. + +## Current baseline + +OpenVistaPro already has: + +- `src/terrain.rs` with immutable `HeightGrid` storage, safe indexing, min/max, and deterministic `plane` / `radial_hill` fixtures. +- `src/render.rs` with a deterministic top-down preview and a CPU perspective spike that only depends on `HeightGrid` + `Scene`. +- `src/scene.rs` and `src/app_state.rs` for scene controls and preview wiring. +- `src/import.rs` for the open-format import boundary. +- `docs/plans/initial-roadmap.md` and `docs/plans/phase-4-formats-scripts-ui.md` for the broader project sequence. + +The missing piece is a dedicated procedural terrain generator pipeline that can produce richer synthetic landscapes without mixing algorithm state into `HeightGrid`. + +## Decision summary + +### First generator family: seeded 2D value-noise fBm + +Implement a deterministic, seedable fractal terrain family first, built from 2D value noise with fBm-style octaves. + +Why this first: + +- It is fully clean-room and easy to describe/test. +- It produces organic terrain that is more representative than the current plane/hill fixtures. +- It works well with tiny synthetic grids, which keeps tests fast and stable. +- It can grow into ridged / warped / terraced variants later without changing the external boundary. + +Initial scope should stay modest: generate a single height grid from a seed, dimensions, and a small set of parameters. Do not add erosion, climate, or streaming at first. + +## API boundary + +Keep `HeightGrid` as pure data plus basic helpers. + +Recommended split: + +- `src/terrain.rs`: immutable grid storage, validation, indexing, min/max, and tiny deterministic fixtures like `plane` and `radial_hill`. +- New generator module, e.g. `src/terrain/generation.rs` or `src/generation.rs`: procedural generation logic, seed handling, interpolation/noise helpers, and generator presets. +- Public API should return `HeightGrid` and nothing renderer-specific. +- Any generator metadata should live in a separate spec/config type, not in the grid itself. + +A good minimal boundary is: + +- `TerrainGenerationSpec` or similar: dimensions, seed, octave count, lacunarity, gain, base frequency, amplitude. +- `generate(spec) -> Result` + +If the implementation later needs richer provenance, add a lightweight wrapper beside the spec, but keep the renderer and scene code consuming `HeightGrid` only. + +## Roadmap slices + +### Slice 1: generator module skeleton + +Goal: introduce the procedural terrain namespace without changing renderer behavior. + +Deliverables: + +- module scaffolding for generator code +- a small spec type with seed and size fields +- a deterministic construction path that still returns a `HeightGrid` + +Acceptance: + +- `HeightGrid` stays unchanged as a storage type +- generator tests compile without touching render code + +### Slice 2: deterministic value-noise core + +Goal: implement the smallest reusable noise primitive. + +Deliverables: + +- seeded lattice/value-noise helper +- interpolation across grid cells +- deterministic output for identical inputs + +Acceptance: + +- same seed + same spec always yields identical samples +- different seeds produce different grids +- tiny grids do not panic at edges + +### Slice 3: fBm terrain composition + +Goal: layer octaves into usable synthetic landscapes. + +Deliverables: + +- octave accumulation +- amplitude/frequency controls +- normalization into the range expected by the renderer/palette logic + +Acceptance: + +- output min/max remain bounded and predictable +- generated terrain exercises multiple elevation bands in the preview + +### Slice 4: preset profiles + +Goal: provide a few named generator presets for common shapes. + +Suggested first presets: + +- `plain`-like flat terrain via zeroed noise amplitude +- `island` / `continental` profile with a radial falloff mask +- `mountain` profile with stronger octave contrast + +Acceptance: + +- preset selection is deterministic +- presets remain thin wrappers around the shared generator core + +### Slice 5: later enhancements + +Only after the core is stable: + +- ridged multifractal terrain +- domain warping +- terraces / plateaus +- simple erosion pass +- derived slope / normal helpers if the renderer needs them +- CLI/script integration so generated terrain can be rendered and scripted like imported terrain + +## Test matrix + +Use tiny synthetic fixtures only. + +### Unit tests + +- dimensions are preserved exactly +- zero dimensions are rejected +- identical seed/spec pairs produce identical grids +- different seeds change output +- sample access stays in-bounds and row-major expectations remain intact +- min/max are sane after normalization +- presets produce the expected broad shape characteristics + +### Behavior tests + +- a small generated grid renders successfully through the existing top-down path +- the perspective spike accepts generated grids without special-case code +- the generator does not mutate renderer or scene state + +### Validation commands + +Run these for each generator slice: + +```bash +cargo fmt --check +cargo test terrain +cargo test +cargo clippy --all-targets -- -D warnings +``` + +Add one feature-specific smoke command for the slice once the generator has a public entry point, for example a tiny render or sample-generation command that writes to `/tmp` and proves the generated grid is usable end-to-end. + +## Definition of done for the workstream + +The terrain-generation workstream is ready to close when: + +- the generator module is separate from `HeightGrid` +- at least one seeded procedural family exists +- tests prove determinism and shape invariants +- generated terrain can flow into the existing render pipeline without special handling +- future slices can add more generator families without changing the grid storage contract -- 2.39.5 From e8253b542683d02827866a370e7d9827a7af2105 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sat, 16 May 2026 14:19:31 -0400 Subject: [PATCH 2/4] feat: add optional GeoTIFF importer --- Cargo.lock | 316 +++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 6 + src/cli.rs | 71 +++++++++- src/import.rs | 11 ++ src/import/geotiff.rs | 95 +++++++++++++ 5 files changed, 493 insertions(+), 6 deletions(-) create mode 100644 src/import/geotiff.rs diff --git a/Cargo.lock b/Cargo.lock index eb07446..891eae9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,6 +37,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android-activity" version = "0.6.1" @@ -461,6 +467,25 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -657,6 +682,12 @@ dependencies = [ "winit", ] +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "emath" version = "0.32.3" @@ -712,6 +743,12 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "fax" version = "0.2.7" @@ -749,6 +786,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.5.0" @@ -809,6 +852,41 @@ dependencies = [ "slab", ] +[[package]] +name = "geotiff-core" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63dcef5fa901867a96414d2e7f41bc29d8ab62f0c386982759d0592d23907622" + +[[package]] +name = "geotiff-reader" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59c23512e155c5be744bed8b22b52ac14ec445f4b8301a85c3a8f9a9a8927392" +dependencies = [ + "geotiff-core", + "lru", + "memmap2", + "ndarray", + "parking_lot", + "thiserror 2.0.18", + "tiff-reader", +] + +[[package]] +name = "geotiff-writer" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cff56075759ffbb8531bb7a752f587d8f33ed0ad7dc80e4825558ddaa93a6b3c" +dependencies = [ + "geotiff-core", + "ndarray", + "tempfile", + "thiserror 2.0.18", + "tiff-core", + "tiff-writer", +] + [[package]] name = "gethostname" version = "1.1.0" @@ -938,7 +1016,18 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", ] [[package]] @@ -1165,6 +1254,21 @@ dependencies = [ "libc", ] +[[package]] +name = "jpeg-decoder" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07" +dependencies = [ + "rayon", +] + +[[package]] +name = "jpeg-encoder" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b454d911ac55068f53495488d8ccd0646eaa540c033a28ee15b07838afafb01f" + [[package]] name = "js-sys" version = "0.3.98" @@ -1183,6 +1287,38 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +[[package]] +name = "lerc-band-materialize" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07ad02b62a38008d9615a52159fd3b7e56aca97ee013fdec55cfe80040cc5aa5" + +[[package]] +name = "lerc-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f428c60749fada87c7a95d08bc3942d05e4a9e3e0543a460a3331bb751dd2ddc" + +[[package]] +name = "lerc-reader" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68327488da0851ab38d662f6aa0baf29ee16192a5e286429aa02b3ffd71a5565" +dependencies = [ + "lerc-band-materialize", + "lerc-core", + "ndarray", +] + +[[package]] +name = "lerc-writer" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d281f7aaa603b0562ceb1901c9b85bec7f19b1f2b9fc651cbc122906f4422411" +dependencies = [ + "lerc-core", +] + [[package]] name = "libc" version = "0.2.186" @@ -1256,6 +1392,25 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "matrixmultiply" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" +dependencies = [ + "autocfg", + "rawpointer", +] + [[package]] name = "memchr" version = "2.8.0" @@ -1324,6 +1479,21 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "ndarray" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + [[package]] name = "ndk" version = "0.9.0" @@ -1360,6 +1530,24 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1681,7 +1869,10 @@ version = "0.1.0" dependencies = [ "clap", "eframe", + "geotiff-reader", + "geotiff-writer", "image", + "ndarray", "serde", "toml", ] @@ -1811,6 +2002,15 @@ version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -1886,6 +2086,32 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.4.1" @@ -2221,6 +2447,19 @@ dependencies = [ "syn", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + [[package]] name = "termcolor" version = "1.4.1" @@ -2284,6 +2523,53 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "tiff-core" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaa9ce13dd11a58b8fd1f834c97eb6f752e42f497cb3ec24fa293932a475f4b" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "tiff-reader" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98c4f45d57ee731f4a27baf7a4db00e79a6ccecc64f5dd62dc23327b82af9a24" +dependencies = [ + "flate2", + "jpeg-decoder", + "lerc-core", + "lerc-reader", + "lru", + "memmap2", + "ndarray", + "parking_lot", + "rayon", + "smallvec", + "thiserror 2.0.18", + "tiff-core", + "weezl", + "zstd", +] + +[[package]] +name = "tiff-writer" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22edef41291232fd124f76f05de090143dde08874d919a7bc11882728aa05842" +dependencies = [ + "flate2", + "jpeg-encoder", + "lerc-core", + "lerc-writer", + "thiserror 2.0.18", + "tiff-core", + "weezl", + "zstd", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -3236,6 +3522,34 @@ dependencies = [ "syn", ] +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "zune-core" version = "0.4.12" diff --git a/Cargo.toml b/Cargo.toml index 03dce50..7293f63 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,15 +8,21 @@ default = [] app = ["dep:eframe"] hgt = [] ascii-grid-import = [] +import-geotiff = ["dep:geotiff-reader"] [dependencies] clap = { version = "4.6.1", features = ["derive"] } eframe = { version = "0.32.3", optional = true, default-features = false, features = ["default_fonts", "wayland", "wgpu", "x11"] } #wgpu = { version = "25.0.2", features = ["metal"] } image = { version = "0.25.9", default-features = false, features = ["png"] } +geotiff-reader = { version = "0.4.0", optional = true, default-features = false, features = ["local"] } serde = { version = "1", features = ["derive"] } toml = "0.8" +[dev-dependencies] +geotiff-writer = { version = "0.4.0", default-features = false } +ndarray = "0.17" + [[bin]] name = "openvistapro_app" path = "src/bin/openvistapro_app.rs" diff --git a/src/cli.rs b/src/cli.rs index e444f19..ccb6884 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -151,19 +151,67 @@ pub fn supported_presets() -> &'static [&'static str] { } pub fn supported_importers() -> &'static [&'static str] { - #[cfg(all(feature = "hgt", feature = "ascii-grid-import"))] + #[cfg(all( + feature = "hgt", + feature = "ascii-grid-import", + feature = "import-geotiff" + ))] + { + &["heightmap", "hgt", "esri-ascii-grid", "geotiff"] + } + #[cfg(all( + feature = "hgt", + feature = "ascii-grid-import", + not(feature = "import-geotiff") + ))] { &["heightmap", "hgt", "esri-ascii-grid"] } - #[cfg(all(feature = "hgt", not(feature = "ascii-grid-import")))] + #[cfg(all( + feature = "hgt", + not(feature = "ascii-grid-import"), + feature = "import-geotiff" + ))] + { + &["heightmap", "hgt", "geotiff"] + } + #[cfg(all( + feature = "hgt", + not(feature = "ascii-grid-import"), + not(feature = "import-geotiff") + ))] { &["heightmap", "hgt"] } - #[cfg(all(not(feature = "hgt"), feature = "ascii-grid-import"))] + #[cfg(all( + not(feature = "hgt"), + feature = "ascii-grid-import", + feature = "import-geotiff" + ))] + { + &["heightmap", "esri-ascii-grid", "geotiff"] + } + #[cfg(all( + not(feature = "hgt"), + feature = "ascii-grid-import", + not(feature = "import-geotiff") + ))] { &["heightmap", "esri-ascii-grid"] } - #[cfg(all(not(feature = "hgt"), not(feature = "ascii-grid-import")))] + #[cfg(all( + not(feature = "hgt"), + not(feature = "ascii-grid-import"), + feature = "import-geotiff" + ))] + { + &["heightmap", "geotiff"] + } + #[cfg(all( + not(feature = "hgt"), + not(feature = "ascii-grid-import"), + not(feature = "import-geotiff") + ))] { &["heightmap"] } @@ -322,7 +370,7 @@ mod tests { } #[test] - #[cfg(not(feature = "hgt"))] + #[cfg(all(not(feature = "hgt"), not(feature = "import-geotiff")))] fn hgt_importer_is_hidden_when_feature_is_disabled() { assert!(!supported_importers().contains(&"hgt")); } @@ -340,6 +388,19 @@ mod tests { assert!(text.contains("hgt"), "got: {text:?}"); } + #[test] + #[cfg(feature = "import-geotiff")] + fn supported_importers_lists_geotiff_when_feature_is_enabled() { + assert!(supported_importers().contains(&"geotiff")); + } + + #[test] + #[cfg(feature = "import-geotiff")] + fn info_text_lists_geotiff_importer_when_feature_is_enabled() { + let text = info_text(); + assert!(text.contains("geotiff"), "got: {text:?}"); + } + #[test] fn info_text_contains_program_name_and_version() { let text = info_text(); diff --git a/src/import.rs b/src/import.rs index 8cf679a..2184a72 100644 --- a/src/import.rs +++ b/src/import.rs @@ -64,9 +64,20 @@ //! 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. +//! +//! # The `geotiff` format +//! +//! With the optional `import-geotiff` Cargo feature enabled, [`geotiff::parse_geotiff_bytes`] +//! reads tiny synthetic GeoTIFF elevation tiles using the pure-Rust +//! `geotiff-reader` crate. The importer starts with a deliberately small +//! supported subset so default builds stay lean. use std::fmt; +#[cfg(feature = "import-geotiff")] +#[path = "import/geotiff.rs"] +pub mod geotiff; + use crate::terrain::{HeightGrid, TerrainError}; /// Vertical unit of the elevation samples in an imported terrain source. diff --git a/src/import/geotiff.rs b/src/import/geotiff.rs new file mode 100644 index 0000000..ef48bfa --- /dev/null +++ b/src/import/geotiff.rs @@ -0,0 +1,95 @@ +//! GeoTIFF terrain importer behind the optional `import-geotiff` feature. +//! +//! This importer parses tiny synthetic GeoTIFF elevation tiles entirely in +//! memory using the pure-Rust `geotiff-reader` crate. It supports a +//! deliberately small subset: a single-band raster decoded as `f32`. + +use crate::import::{ElevationUnit, ImportError, ImportedTerrain, TerrainSourceMetadata}; +use crate::terrain::HeightGrid; +use geotiff_reader::GeoTiffFile; + +/// Format identifier recorded for terrains parsed by [`parse_geotiff_bytes`]. +const GEOTIFF_FORMAT: &str = "geotiff"; + +/// Parse a GeoTIFF payload from memory into an [`ImportedTerrain`]. +/// +/// Only single-band rasters are supported; the raster is decoded as `f32` and +/// flattened in row-major order into a [`HeightGrid`]. Any reader failure +/// (including non-GeoTIFF input) is reported as [`ImportError::MalformedSource`]. +pub fn parse_geotiff_bytes(source: &[u8]) -> Result { + let file = GeoTiffFile::from_bytes(source.to_vec()) + .map_err(|e| ImportError::MalformedSource(format!("could not read GeoTIFF: {e}")))?; + + let band_count = file.band_count(); + if band_count != 1 { + return Err(ImportError::MalformedSource(format!( + "expected a single-band GeoTIFF, got {band_count} bands" + ))); + } + + let raster = file.read_raster::().map_err(|e| { + ImportError::MalformedSource(format!("could not decode GeoTIFF raster: {e}")) + })?; + + let samples: Vec = raster.iter().copied().collect(); + + let width = file.width(); + let height = file.height(); + + let grid = HeightGrid::new(width, height, samples)?; + let metadata = TerrainSourceMetadata::new(GEOTIFF_FORMAT, width, height, ElevationUnit::Meters); + Ok(ImportedTerrain::new(grid, metadata)) +} + +#[cfg(test)] +mod tests { + use super::parse_geotiff_bytes; + use crate::import::{ElevationUnit, ImportError}; + use geotiff_writer::GeoTiffBuilder; + use ndarray::array; + use std::io::Cursor; + + fn tiny_synthetic_geotiff_bytes() -> Vec { + let data = array![[10.0_f32, 20.0, 30.0], [40.0, 50.0, 60.0]]; + let mut cursor = Cursor::new(Vec::new()); + GeoTiffBuilder::new(3, 2) + .epsg(4326) + .pixel_scale(1.0, 1.0) + .origin(0.0, 2.0) + .write_2d_to(&mut cursor, data.view()) + .expect("synthetic GeoTIFF fixture should write"); + cursor.into_inner() + } + + #[test] + fn parses_tiny_synthetic_geotiff_dimensions_and_samples() { + let bytes = tiny_synthetic_geotiff_bytes(); + let imported = parse_geotiff_bytes(&bytes).expect("GeoTIFF fixture should parse"); + 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(), "geotiff"); + assert_eq!(imported.metadata().width(), 3); + assert_eq!(imported.metadata().height(), 2); + assert_eq!(imported.metadata().elevation_unit(), ElevationUnit::Meters); + } + + #[test] + fn rejects_non_geotiff_payloads() { + let err = parse_geotiff_bytes(b"not a geotiff").expect_err("bad bytes must be rejected"); + assert!( + matches!(err, ImportError::MalformedSource(_)), + "got: {err:?}" + ); + } + + #[test] + fn tiny_fixture_is_not_empty() { + let bytes = tiny_synthetic_geotiff_bytes(); + assert!( + bytes.len() > 64, + "fixture should contain real GeoTIFF bytes" + ); + } +} -- 2.39.5 From 101303cd5cdc5af1dcbb5f184fd97b40f152968b Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sat, 16 May 2026 14:23:52 -0400 Subject: [PATCH 3/4] docs: reconcile feature inventory against manuals --- docs/knowledgebase/feature-inventory.md | 89 ++++++++++--------------- 1 file changed, 34 insertions(+), 55 deletions(-) diff --git a/docs/knowledgebase/feature-inventory.md b/docs/knowledgebase/feature-inventory.md index dadeb94..274a17d 100644 --- a/docs/knowledgebase/feature-inventory.md +++ b/docs/knowledgebase/feature-inventory.md @@ -1,63 +1,42 @@ # Feature Inventory -This is a first-pass inventory from the VistaPro 2/3 manuals, MakePath guide, screenshots, and public descriptions. +This is a normalized reconciliation of the VistaPro manuals, MakePath guide, screenshots, and current OpenVistaPro implementation. -## Terrain sources +Status counts by normalized feature family: +- Implemented: 7 +- Partial: 7 +- Planned: 6 -- VistaPro landscape files. -- USGS DEM elevation data. -- NASA/planetary or other gridded elevation datasets. -- Fractal landscape generation. -- User-supplied two-dimensional integer height arrays, up to historical limits around 1024x1024. +Notes: +- “Implemented” means the current codebase has a working, tested slice for that family. +- “Partial” means the codebase covers part of the family but not the full manual-described workflow. +- “Planned” means the family is still absent or only mentioned as roadmap context. -## Scene controls +## Normalized feature families -- Camera and target X/Y/Z positioning. -- Lens/range controls. -- Bank, heading, and pitch. -- Sea level and water planes. -- Lake and river controls. -- Timber/tree line, tree drawing, and tree density. -- Snow line. -- Haze/fog distance. -- Sky/stars options. -- Light direction and custom lighting. -- Vertical exaggeration. -- Color maps/palettes. -- Texture image loading, including PCX in the Windows manual. +| Feature family | Manual / reference evidence | OpenVistaPro status | Implementation evidence | Gap / next step | +|---|---|---|---|---| +| Terrain sources and compatibility boundary | VistaPro 2 manual: Load Landscape / Save Landscape; VistaPro 3 manual: Load, Save, Exp/Imp menus and DEM/PCX/Targa24 references. | Partial | `src/import.rs`, `src/cli.rs` (`supported_importers()`), `README.md` importer status. | OpenVistaPro intentionally keeps the clean internal model separate and does not claim legacy VistaPro format compatibility. | +| Project-owned plain-text heightfields (`ovp-text`) | Clean-room project fixture format, not part of the legacy manuals; used to model the import boundary safely. | Implemented | `src/import.rs` (`import_ovp_text`), tests in `src/import.rs`, fixture in `tests/fixtures/open/`. | No gap for the MVP slice; this is the project-owned test/import path. | +| SRTM / HGT terrain import | VistaPro manuals describe loading DEM-like landscape data; the open equivalent is the SRTM/HGT family. | Implemented | `src/import.rs` (`import_hgt` behind `hgt`), `README.md`, tests in `src/import.rs`. | Still only the open SRTM slice; broader compatibility formats remain separate. | +| GeoTIFF terrain import | Modern open terrain source, not a legacy VistaPro format. | Implemented | `src/import/geotiff.rs` behind `import-geotiff`, tests in that module. | Deliberately narrow subset: tiny synthetic single-band raster support only. | +| Fractal / synthetic terrain generation | VistaPro overview calls out fractal landscapes and generated terrain. | Partial | `src/terrain.rs` (`plane`, `radial_hill`), `src/app_state.rs` presets. | Current terrain generation is only deterministic fixtures, not a true fractal/noise terrain engine. | +| Camera and target placement | VistaPro 2 / 3 manuals: “Setting Camera and Target”; screenshot workflow uses camera/target gadgets. | Implemented | `src/scene.rs` (`Camera`), `src/app.rs` (camera position/target controls), `src/app_state.rs`. | Only the core position/target slice exists; there is no map-click placement UI yet. | +| Lens / range / orientation controls | VistaPro manuals describe lens/range, bank, heading, and pitch controls. | Partial | `src/scene.rs` (`Camera.fov_degrees`), `src/render.rs` perspective renderer. | No explicit bank/heading/pitch model or legacy lens/range UI yet. | +| Water / sea level, tree line, snow line, haze | Manuals repeatedly mention tree line, snow line, water level, haze, and atmospheric tuning. | Implemented | `src/scene.rs`, `src/app.rs` sliders, `src/colormap.rs`, `src/render.rs`. | Rivers/lakes are still missing, but the core elevation-band controls are present. | +| Rivers and lakes | VistaPro manuals explicitly mention rivers and lakes as adjustable landscape features. | Planned | Not yet represented in `Scene` or renderer code. | Add hydrology controls/data model before claiming this family. | +| Light direction and custom lighting | Manuals discuss sunlight placement and lighting experiments. | Partial | `src/scene.rs` (`Light`), `src/render.rs`, `src/app.rs` (light state exists in the scene model even if UI is minimal). | The current model is much simpler than VistaPro’s lighting workflow and lacks richer light controls. | +| Vertical exaggeration | VistaPro manuals describe vertical scaling / scene exaggeration controls. | Planned | No dedicated field or control in the current scene model. | Add an explicit vertical-scale parameter and render integration. | +| Color maps / palettes / texture image loading | VistaPro 3 manual includes loading PCX images, adding texture, and saving/loading color maps. | Partial | `src/colormap.rs` fixed bands, `src/render.rs` uses scene thresholds. | No color-map editor, no palette import/export, and no PCX/texture loading yet. | +| Preview / final render workflow | VistaPro manuals describe rough preview rendering and full render output. | Implemented | `src/render.rs` (`render_top_down`, `render_perspective`), `src/cli.rs` (`render`), tests in `src/render.rs`. | The preview/final split is still simplified, but the core render outputs are working. | +| Render quality presets / smoothing / detail tradeoffs | VistaPro manuals describe quality menus and poly/detail tradeoffs. | Planned | No dedicated quality preset system in current code. | Add explicit quality presets or a render-quality profile object. | +| Scene file save/load (`.ovp.toml`) | Not a VistaPro legacy format; this is the clean-room OpenVistaPro scene format. | Implemented | `src/scene_file.rs`, `src/cli.rs` (`scene export`), tests in `src/scene_file.rs`. | No gap for the project-owned scene format slice. | +| Script language parser | MakePath guide and VistaPro manual describe scripts and “Run Script” workflows. | Partial | `src/script.rs` parser, tests in `src/script.rs`, `README.md` script section. | Parser exists, but script execution is intentionally deferred. | +| Script execution and animation frames | MakePath guide says scripts should render full animations and VistaPro can run scripts from the Script menu. | Planned | No script runner or frame-sequencing engine exists yet. | Add execution semantics once the command model is stable. | +| MakePath-style path generation and motion models | MakePath guide describes spline nodes, previewing a path, and vehicle models (jet, glider, dune buggy, motorcycle, helicopter, cruise missile, custom). | Planned | No path generator or motion-model layer exists yet. | This is a separate planner/animation feature, not just a script parser. | +| UI shell, menus, dialogs, and numeric gadgets | VistaPro screenshots/manuals show dense menus, dialogs, map tools, and numeric gadgets. | Partial | `src/app.rs`, `src/app_state.rs`, `src/bin/openvistapro_app.rs`. | Current UI is an egui CPU-preview shell with a small control set, not the legacy menu hierarchy. | +| Legacy image / landscape export formats | VistaPro manuals mention saving rendered images and landscapes in formats like IFF/IFF24/RGB and DEM/binary landscape files. | Planned | Current output is PNG plus project-owned `.ovp.toml` scenes. | Add separate compatibility/export work only after the clean internal pipeline is stable. | -## Rendering and quality +## Current reconciliation summary -- Wire/topographic preview workflow. -- Progressive/final render action. -- Quality menu/settings for smoothing/detail/performance tradeoffs. -- Save rendered images; historical formats included IFF/IFF24/RGB on Amiga and PC-era formats on Windows. -- 24-bit output was a differentiator in user examples. - -## Scripting and animation - -- Script files list camera/target positions and commands. -- Scripts are landscape-independent enough to rerun against different terrain/settings. -- Commands include camera coordinates, vertical scale, and render actions. -- VistaPro could generate simple linear camera-to-target paths. -- MakePath Flight Director created spline paths over a topographic map and exported VistaPro scripts. -- MakePath vehicle models included jet, glider, dune buggy, motorcycle, helicopter, cruise missile, and custom vehicles. - -## UI surfaces observed in screenshots - -- Main workspace with topographic map/preview on the left and dense control panels on the right. -- Pull-down menus: Project, Load, Save, Exp/Imp, Script, Image, Quality, and related dialogs. -- File dialogs and confirmation/progress dialogs. -- Numeric/text gadgets for direct value editing. -- Multiple lower control panels for color-map/palette editing, fractal settings, lighting, and scene/environment settings. -- Color-map editor with vertical channel/range sliders and palette swatches. -- Map/radar-like view for camera/target placement. - -## Modernization opportunities - -- Replace dense fixed control panels with an immediate-mode or dockable UI. -- Use modern terrain formats: GeoTIFF/COG, DEM, HGT/SRTM, PNG/RAW heightmaps. -- Use WGPU for cross-platform rendering. -- Add reproducible scene files in human-readable formats such as RON/TOML/JSON. -- Provide CLI rendering for tests and batch workflows. -- Keep compatibility import/export separate from the clean internal model. +OpenVistaPro already covers the core clean-room pipeline: terrain grids, open importers, scene state, preview/final rendering, project-owned scene files, and a small script parser. The remaining VistaPro-specific gaps cluster around legacy compatibility, richer scene controls, script execution, MakePath-style animation tooling, and the old dense UI/menu workflow. -- 2.39.5 From 83a6d933565a68a9764faec72bfbdc48b75f2d5e Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sat, 16 May 2026 14:26:03 -0400 Subject: [PATCH 4/4] docs: sync GeoTIFF importer notes --- README.md | 6 ++ docs/knowledgebase/architecture-notes.md | 4 +- docs/plans/phase-4-formats-scripts-ui.md | 63 ++++++++++------- docs/research/geotiff-import-strategy.md | 86 +++++++++++++----------- 4 files changed, 94 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index 8616141..b702ad0 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,9 @@ Importer status: - `ovp-text`: project-owned plain-text heightfield fixture format used for import-boundary tests. - `hgt`: enabled by the optional `hgt` Cargo feature; parses SRTM HGT payloads as square grids of big-endian signed 16-bit metre samples. The implementation and tests use open specifications and synthetic/tiny fixtures only. - `esri-ascii-grid`: enabled by the optional `ascii-grid-import` Cargo feature; parses open ESRI ASCII Grid text with synthetic/project-owned fixtures only. +- `geotiff`: enabled by the optional `import-geotiff` Cargo feature; parses single-band GeoTIFF elevation tiles in memory via the pure-Rust `geotiff-reader` crate (no GDAL, no native dependency). It supports a deliberately narrow subset — a single-band raster decoded as `f32` — and is reported by `openvistapro info` only when the feature is built. + +All importer tests use tiny synthetic, project-owned fixture data: HGT uses inline synthetic byte arrays, ESRI ASCII Grid uses tiny project-owned text fixtures, and the GeoTIFF tests generate a tiny single-band tile in memory rather than reading committed binaries or real geodata. To verify the importer feature surface: @@ -47,6 +50,9 @@ cargo test ascii_grid --features ascii-grid-import cargo run --features hgt --bin openvistapro -- info cargo run --features ascii-grid-import --bin openvistapro -- info cargo test --no-default-features +cargo test geotiff --features import-geotiff +cargo run --features import-geotiff --bin openvistapro -- info +cargo test --all-features ``` The default `render` mode writes a deterministic top-down elevation preview. diff --git a/docs/knowledgebase/architecture-notes.md b/docs/knowledgebase/architecture-notes.md index 5e93689..7cf014f 100644 --- a/docs/knowledgebase/architecture-notes.md +++ b/docs/knowledgebase/architecture-notes.md @@ -5,7 +5,7 @@ Start simple, then split into crates when module boundaries stabilize. - `src/terrain.rs`: height grid, bounds, sampling, and deterministic terrain fixtures. -- `src/import.rs`: importers for open/safe formats (`ovp-text`, feature-gated HGT, feature-gated ESRI ASCII Grid); historical compatibility later. +- `src/import.rs`: importers for open/safe formats (`ovp-text`, feature-gated HGT, feature-gated ESRI ASCII Grid, and feature-gated GeoTIFF via `src/import/geotiff.rs`); historical compatibility later. Each importer yields the same internal `HeightGrid` plus `TerrainSourceMetadata`, keeping source formats out of renderer code. - `src/scene.rs` and `src/scene_file.rs`: camera, light, atmosphere, water/vegetation thresholds, and `.ovp.toml` persistence. - `src/render.rs`: deterministic CPU top-down renderer plus CPU perspective demo renderer; WGPU renderer later. - `src/script.rs` and `src/script_exec.rs`: parse and execute project-owned OpenVistaPro script commands. @@ -22,7 +22,7 @@ Start simple, then split into crates when module boundaries stabilize. - `image` for PNG output and texture loading. - `nalgebra` or `glam` for vector/matrix math. - `wgpu` plus `winit`/`egui` for the eventual interactive app. -- `gdal` support should be optional because native GDAL dependency setup can be heavy. +- GeoTIFF support uses the pure-Rust `geotiff-reader` crate behind the optional `import-geotiff` feature, so default builds stay free of native dependencies. `gdal` is intentionally not used; any future GDAL-backed path would be a separate, opt-in feature because native GDAL setup can be heavy. ## Development strategy diff --git a/docs/plans/phase-4-formats-scripts-ui.md b/docs/plans/phase-4-formats-scripts-ui.md index fd0b2df..a2a4a2b 100644 --- a/docs/plans/phase-4-formats-scripts-ui.md +++ b/docs/plans/phase-4-formats-scripts-ui.md @@ -19,7 +19,7 @@ The repository already has: - `src/scene_file.rs`: project-owned `.ovp.toml` files with `schema = "openvistapro.scene"`, `version = 1`, and a serialized `Scene` payload. - `src/colormap.rs`: deterministic elevation-band colors. - `src/render.rs`: deterministic top-down PNG renderer plus spike-quality CPU perspective raymarcher. -- `src/import.rs`: open-format import boundary with `ovp-text`, feature-gated SRTM/HGT bytes, and feature-gated ESRI ASCII Grid parsing. +- `src/import.rs`: open-format import boundary with `ovp-text`, feature-gated SRTM/HGT bytes, feature-gated ESRI ASCII Grid parsing, and feature-gated GeoTIFF parsing. - `src/script.rs` and `src/script_exec.rs`: project-owned script parsing and execution for presets, grayscale PNG heightmaps, threshold changes, and PNG render outputs. - `src/path.rs`: deterministic MakePath-inspired camera keyframe interpolation. - `src/app_state.rs`, `src/app.rs`, and `src/bin/openvistapro_app.rs`: feature-gated `app` shell using `eframe`/`egui` with scene controls and CPU preview rendering. @@ -348,35 +348,52 @@ Run: `cargo test dem --features import-dem` Expected: PASS. -### Task C3: Add GeoTIFF spike behind an optional feature +### Task C3: GeoTIFF importer behind an optional feature (implemented) -**Objective:** Decide whether to use a pure-Rust TIFF path or optional GDAL without making default builds fragile. +**Status:** Done. Landed as the optional `import-geotiff` feature; this section +records the implemented design. The crate survey behind it is +[`docs/research/geotiff-import-strategy.md`](../research/geotiff-import-strategy.md). + +**Objective:** Parse single-band GeoTIFF elevation tiles into the internal +`HeightGrid` without making default builds fragile or pulling a native +dependency. + +**Outcome:** A pure-Rust path was chosen over GDAL. The importer uses the +`geotiff-reader` crate (`local` feature, `cog`/network feature off), declared as +an optional dependency gated by the `import-geotiff` Cargo feature. There is no +GDAL dependency and no native toolchain requirement, so `import-geotiff` builds +and tests run anywhere `cargo` runs — the "documented skip if native GDAL is not +installed" escape hatch is not needed and is reserved for any future, +separately reviewed GDAL-backed feature. **Files:** -- Create: `src/import/geotiff.rs` -- Modify: `Cargo.toml` -- Modify: `docs/plans/phase-4-formats-scripts-ui.md` or follow-up notes if the spike changes direction -- Test: tiny openly licensed or generated GeoTIFF fixture only if licensing is clear +- `src/import/geotiff.rs`: `cfg(feature = "import-geotiff")` module exposing + `parse_geotiff_bytes(&[u8]) -> Result`. It reads + the payload in memory, rejects non-single-band rasters, decodes the raster as + `f32`, and builds a `HeightGrid` plus `TerrainSourceMetadata` (format + `"geotiff"`). Reader/decode failures map to `ImportError::MalformedSource`. +- `src/import.rs`: declares the `geotiff` submodule under the feature cfg. +- `Cargo.toml`: `import-geotiff = ["dep:geotiff-reader"]`; `geotiff-writer` and + `ndarray` are dev-dependencies used only to generate the test fixture. +- `src/cli.rs`: `supported_importers()` and `info_text()` list `geotiff` only + when the feature is built. (A `render --import-geotiff` flag mirroring the HGT + path remains future work.) -**Step 1: Write failing test or spike acceptance check** +**Fixture hygiene:** Tests do not commit any GeoTIFF binary. They generate a +tiny single-band elevation tile in memory with `geotiff-writer` (dev-dependency) +and parse it back, mirroring the synthetic-fixture approach used for HGT. No +proprietary data and no large real-world DEMs enter the repository; real tiles +may be used only for local manual verification under the already-ignored +`reference/` and `.work/` directories. -Prefer a generated fixture committed under an explicit license. If that is not practical, write parser-interface tests and keep the full fixture local until license is resolved. +**Validation** -**Step 2: Verify RED** +Run: `cargo test --no-default-features`, `cargo test geotiff --features import-geotiff`, +and `cargo test --all-features`. -Run: `cargo test geotiff --features import-geotiff` - -Expected: FAIL until the selected crate and parser stub exist. - -**Step 3: Implement minimal code** - -Add only enough to detect/parse a tiny supported subset. If GDAL is required, keep the feature opt-in and document native setup. - -**Step 4: Verify GREEN** - -Run: `cargo test --features import-geotiff` - -Expected: PASS on systems with the optional dependency available, or a documented skip if native GDAL is not installed. +Expected: default and `--no-default-features` builds stay GeoTIFF-free; the +feature build and `--all-features` exercise the parser and pass without any +native dependency. ## Milestone D: Scene/project metadata evolution diff --git a/docs/research/geotiff-import-strategy.md b/docs/research/geotiff-import-strategy.md index 6e5559c..37dca53 100644 --- a/docs/research/geotiff-import-strategy.md +++ b/docs/research/geotiff-import-strategy.md @@ -1,11 +1,13 @@ # GeoTIFF Import Strategy Research Note -> **Status:** Spike / decision note. Documentation-only — no code or `Cargo.toml` -> changes accompany this note. +> **Status:** Decision implemented. The recommended pure-Rust path has landed +> as the optional `import-geotiff` feature; the crate survey below is kept for +> provenance. > -> **Context:** Phase 4 Milestone C, Task C3 ("Add GeoTIFF spike behind an -> optional feature"). This note records the crate survey and the recommended -> direction so the implementation slice can start from a settled decision. +> **Context:** Phase 4 Milestone C, Task C3 ("GeoTIFF importer behind an +> optional feature"). This note recorded the crate survey and recommended +> direction; the "Implementation notes" section below describes what was +> actually built. ## Recommendation @@ -136,44 +138,48 @@ Consistent with Task C1's existing feature scaffold (`import-dem`, - The committed fixture should be only as large as the test needs (target: well under a kilobyte). -## Follow-up implementation slice +## Implementation notes -Concrete next slice for Task C3, to be done TDD (RED → minimal GREEN → -validation) on a separate implementation branch: +The recommended pure-Rust path has landed. What was built: -1. **Add the feature + dependency.** In `Cargo.toml`, make `geotiff-reader` an - optional dependency gated by `import-geotiff` (default `cog` feature - disabled). Confirm `cargo test --no-default-features` and the default build - stay GeoTIFF-free. -2. **Create `src/import/geotiff.rs`.** A `cfg(feature = "import-geotiff")` - module exposing something like - `parse_geotiff_bytes(&[u8]) -> Result`, - reusing the `ImportedTerrain` / `ImportError` / `TerrainSourceMetadata` - types from Milestone A. -3. **Generate the synthetic fixture.** Add a tiny generated single-band - elevation GeoTIFF as a project-owned fixture (separate commit from the - parser, per the plan's commit strategy). -4. **RED test.** `cargo test geotiff --features import-geotiff` — assert - dimensions, sample values, and metadata; assert malformed input yields a - typed error. -5. **Minimal GREEN.** Parse only the tiny supported subset (single band, - uncompressed, known sample type). Reject everything else explicitly; - document that broad real-world GeoTIFF coverage is future work. -6. **CLI plumbing (optional, can be a later slice).** Mirror the HGT pattern - (Task B3): an `--import-geotiff ` render input, gated so it only - appears when the feature is built. -7. **Validation.** `cargo test --no-default-features`, - `cargo test --features import-geotiff`, `cargo test --all-features`, - `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, - `git diff --check`. -8. **Update `openvistapro info`** to report GeoTIFF support honestly — only - once a real parser and fixture exist (consistent with Task A3). +1. **Feature + dependency.** `Cargo.toml` declares + `import-geotiff = ["dep:geotiff-reader"]`; `geotiff-reader` is an optional + dependency built with its `local` feature and `default-features = false`, so + the network-oriented `cog` feature stays off. Default and + `--no-default-features` builds remain GeoTIFF-free. +2. **`src/import/geotiff.rs`.** A `cfg(feature = "import-geotiff")` module + exposing `parse_geotiff_bytes(&[u8]) -> Result`, + reusing the `ImportedTerrain` / `ImportError` / `TerrainSourceMetadata` types + from Milestone A. It reads the payload in memory, rejects rasters that are + not single-band, decodes the raster as `f32` into a row-major `HeightGrid`, + and records `TerrainSourceMetadata` with format `"geotiff"`. Reader and + decode failures map to `ImportError::MalformedSource`. +3. **Synthetic fixtures only.** No GeoTIFF binary is committed. Tests generate a + tiny single-band elevation tile in memory with the `geotiff-writer` + dev-dependency (alongside `ndarray`) and parse it back — mirroring the + inline-bytes approach used for HGT. Real USGS/NASA tiles may be used for + local manual verification only, under the already-ignored `reference/` and + `.work/` directories. +4. **Tests.** `cargo test geotiff --features import-geotiff` asserts dimensions, + sample values, and metadata, and checks that non-GeoTIFF input yields a typed + `ImportError::MalformedSource`. +5. **Supported subset.** Only the narrow single-band `f32` case is parsed; + broader real-world GeoTIFF coverage (multi-band, exotic compression, full + geo-tag interpretation) remains future work. +6. **`openvistapro info`.** `supported_importers()` and `info_text()` list + `geotiff` only when the feature is built, keeping `info` honest. +7. **GDAL is not part of the landed feature.** The importer has zero native + dependencies; a GDAL-backed path stays out of scope until a separate review + approves the native-dependency cost, and would use its own opt-in feature + name rather than `import-geotiff`. -If `geotiff-reader` proves insufficient during the spike (missing geo-tag -coverage, API gaps), fall back in this order: `georaster` → `wbgeotiff` → -hand-rolled geo-tag reading on top of `image`'s TIFF decoder. A GDAL-backed -path stays out of scope until a separate review approves the native-dependency -cost. +**Not yet done:** CLI render plumbing. A `render --import-geotiff ` input +mirroring the HGT pattern (Task B3) is still future work; the parser is reachable +through the library API and reported by `info`, but not yet wired into `render`. + +If `geotiff-reader` later proves insufficient (missing geo-tag coverage, API +gaps), the documented fallback order remains `georaster` → `wbgeotiff` → +hand-rolled geo-tag reading on top of `image`'s TIFF decoder. ## Sources -- 2.39.5