feat: wire shell placeholders to backend actions #12
@@ -7,6 +7,7 @@ edition = "2024"
|
|||||||
default = []
|
default = []
|
||||||
app = ["dep:eframe"]
|
app = ["dep:eframe"]
|
||||||
hgt = []
|
hgt = []
|
||||||
|
ascii-grid-import = []
|
||||||
import-geotiff = ["dep:geotiff-reader"]
|
import-geotiff = ["dep:geotiff-reader"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ This repository currently contains:
|
|||||||
- A first-pass knowledgebase under `docs/knowledgebase/`.
|
- A first-pass knowledgebase under `docs/knowledgebase/`.
|
||||||
- An implementation roadmap under `docs/plans/`.
|
- An implementation roadmap under `docs/plans/`.
|
||||||
- Legal and reference-material hygiene notes under `docs/legal/` and `docs/research/`.
|
- Legal and reference-material hygiene notes under `docs/legal/` and `docs/research/`.
|
||||||
- A clean-room terrain import boundary with project-owned `ovp-text` fixtures and an SRTM/HGT byte importer behind the `hgt` Cargo feature.
|
- A clean-room terrain import boundary with project-owned `ovp-text` fixtures, a PNG heightmap script importer, an SRTM/HGT byte importer behind the `hgt` Cargo feature, an ESRI ASCII Grid parser behind the `ascii-grid-import` feature, and a deterministic terrain-generation module in `src/terrain_gen.rs` with `TerrainGenerationSpec` / `DeterministicTerrainGenerator` (see `cargo test terrain_gen` and its determinism/seed note).
|
||||||
- A clean-room procedural terrain generator module (`src/terrain_gen.rs`) that produces deterministic, seeded synthetic landscapes.
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -29,54 +28,33 @@ cargo run --features app --bin openvistapro_app
|
|||||||
```
|
```
|
||||||
|
|
||||||
The optional app shell is gated behind the `app` feature so default CLI builds stay GPU-free.
|
The optional app shell is gated behind the `app` feature so default CLI builds stay GPU-free.
|
||||||
It opens an `eframe`/`egui` window titled `OpenVistaPro` arranged as a docked shell: a left
|
It opens an `eframe`/`egui` window titled `OpenVistaPro` with scene controls and a CPU-rendered terrain preview.
|
||||||
panel with terrain, scene/camera, and render controls; a right scripts/paths panel; a top
|
|
||||||
project bar and a bottom status bar for file/status chrome; and a central CPU-rendered
|
|
||||||
terrain preview. Still-planned actions — heightmap import, run script, make path, and file
|
|
||||||
new/open/save — appear as disabled placeholders, and legacy menus/dialogs remain on the
|
|
||||||
roadmap. The shell is still CPU-only; there is no GPU viewport yet.
|
|
||||||
|
|
||||||
Importer status:
|
Importer status:
|
||||||
|
|
||||||
- `ovp-text`: project-owned plain-text heightfield fixture format used for tests.
|
- `heightmap`: script execution can import grayscale PNG heightmaps with `import heightmap "path.png"` and map brightness to elevation.
|
||||||
|
- `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.
|
- `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.
|
- `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, and the GeoTIFF tests generate a tiny single-band tile in memory rather than reading committed binaries or real geodata.
|
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:
|
To verify the importer feature surface:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
cargo test import
|
||||||
cargo test hgt
|
cargo test hgt
|
||||||
cargo test hgt --features hgt
|
cargo test hgt --features hgt
|
||||||
|
cargo test ascii_grid --features ascii-grid-import
|
||||||
cargo run --features hgt --bin openvistapro -- info
|
cargo run --features hgt --bin openvistapro -- info
|
||||||
|
cargo run --features ascii-grid-import --bin openvistapro -- info
|
||||||
cargo test --no-default-features
|
cargo test --no-default-features
|
||||||
cargo test geotiff --features import-geotiff
|
cargo test geotiff --features import-geotiff
|
||||||
cargo run --features import-geotiff --bin openvistapro -- info
|
cargo run --features import-geotiff --bin openvistapro -- info
|
||||||
cargo test --all-features
|
cargo test --all-features
|
||||||
```
|
```
|
||||||
|
|
||||||
## Terrain generation
|
|
||||||
|
|
||||||
OpenVistaPro includes a clean-room procedural terrain generator in
|
|
||||||
`src/terrain_gen.rs`. Its public surface is intentionally small:
|
|
||||||
`TerrainGenerationSpec` captures the seed and dimensions of a generation
|
|
||||||
request, the `TerrainGenerator` trait defines the `generate` boundary, and
|
|
||||||
`DeterministicTerrainGenerator` implements it with a seeded value-noise fBm
|
|
||||||
stack that returns a plain `HeightGrid` — generator state never leaks into the
|
|
||||||
grid or renderer.
|
|
||||||
|
|
||||||
Generation is seeded and deterministic: an identical spec always yields an
|
|
||||||
identical grid, and different seeds diverge. As with the importers, the
|
|
||||||
generator tests use only tiny synthetic, project-owned fixtures — no committed
|
|
||||||
binaries and no real geodata.
|
|
||||||
|
|
||||||
To verify the terrain-generation surface:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo test terrain_gen -- --nocapture
|
|
||||||
```
|
|
||||||
|
|
||||||
The default `render` mode writes a deterministic top-down elevation preview.
|
The default `render` mode writes a deterministic top-down elevation preview.
|
||||||
Passing `--camera-demo` switches to the current CPU perspective renderer spike:
|
Passing `--camera-demo` switches to the current CPU perspective renderer spike:
|
||||||
a simple pinhole-camera raymarcher with bilinear height sampling, fixed step
|
a simple pinhole-camera raymarcher with bilinear height sampling, fixed step
|
||||||
@@ -103,14 +81,19 @@ or one command:
|
|||||||
```text
|
```text
|
||||||
use preset hill # `hill` or `plane`
|
use preset hill # `hill` or `plane`
|
||||||
set thresholds water=0.18 tree=0.42 snow=0.77
|
set thresholds water=0.18 tree=0.42 snow=0.77
|
||||||
import heightmap "data/demo-height.png"
|
import heightmap "data/demo-height.png" # optional grayscale PNG terrain input
|
||||||
render output "out/demo.png"
|
render output "out/demo.png"
|
||||||
```
|
```
|
||||||
|
|
||||||
Design goals for the MVP: one command per line for readable diffs,
|
Run the checked-in demo script with:
|
||||||
deterministic parsing with no I/O, and parse errors that carry a 1-based line
|
|
||||||
number. This slice only parses scripts into an AST; executing those commands is
|
```bash
|
||||||
intentionally left for a later card.
|
cargo run --bin openvistapro -- script run --input examples/demo.ovps
|
||||||
|
```
|
||||||
|
|
||||||
|
Script paths are resolved relative to the script file. `use preset` and
|
||||||
|
`import heightmap` select the active terrain, `set thresholds` updates scene
|
||||||
|
bands, and execution writes each `render output` to a deterministic PNG.
|
||||||
|
|
||||||
## Project principles
|
## Project principles
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +1,19 @@
|
|||||||
# Architecture Notes
|
# Architecture Notes
|
||||||
|
|
||||||
## Proposed Rust workspace structure
|
## Current Rust module structure
|
||||||
|
|
||||||
Start simple, then split into crates when module boundaries stabilize.
|
Start simple, then split into crates when module boundaries stabilize.
|
||||||
|
|
||||||
- `src/terrain.rs`: height grid, bounds, sampling, normals, terrain transforms.
|
- `src/terrain.rs`: height grid, bounds, sampling, and deterministic terrain fixtures.
|
||||||
- `src/terrain_gen.rs`: procedural terrain generator boundary. `TerrainGenerationSpec`
|
- `src/terrain_gen.rs`: `TerrainGenerationSpec` and `DeterministicTerrainGenerator` for the seeded terrain-generation pipeline; `cargo test terrain_gen` exercises the determinism/seed note.
|
||||||
captures a request's seed and dimensions, and `DeterministicTerrainGenerator`
|
- `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.
|
||||||
(a seeded value-noise fBm generator behind the `TerrainGenerator` trait)
|
- `src/scene.rs` and `src/scene_file.rs`: camera, light, atmosphere, water/vegetation thresholds, and `.ovp.toml` persistence.
|
||||||
consumes that spec and returns a plain `HeightGrid` — generator state never
|
- `src/render.rs`: deterministic CPU top-down renderer plus CPU perspective demo renderer; WGPU renderer later.
|
||||||
leaks into the grid or renderer. Generation is seeded and deterministic: an
|
- `src/script.rs` and `src/script_exec.rs`: parse and execute project-owned OpenVistaPro script commands.
|
||||||
identical spec always yields an identical grid. Its tests use only tiny
|
- `src/path.rs`: MakePath-inspired camera keyframe interpolation.
|
||||||
synthetic, project-owned fixtures, never committed binaries or real geodata,
|
|
||||||
and run via `cargo test terrain_gen -- --nocapture`.
|
|
||||||
- `src/import/`: importers for open/safe formats; historical compatibility later. Implemented so far: the project-owned `ovp-text` heightfield, an SRTM/HGT byte parser behind the `hgt` feature, and an optional single-band GeoTIFF importer (`src/import/geotiff.rs`) behind the `import-geotiff` feature. Each importer yields the same internal `HeightGrid` plus `TerrainSourceMetadata`, keeping source formats out of renderer code.
|
|
||||||
- `src/scene.rs`: camera, target, light, atmosphere, water, vegetation parameters.
|
|
||||||
- `src/render/`: CPU reference renderer first, then WGPU renderer.
|
|
||||||
- `src/script.rs`: parse and execute OpenVistaPro script commands.
|
|
||||||
- `src/colormap.rs`: palettes and elevation/biome color mapping.
|
- `src/colormap.rs`: palettes and elevation/biome color mapping.
|
||||||
- `src/bin/openvistapro.rs` or current `src/main.rs`: CLI entry point.
|
- `src/cli.rs` plus `src/main.rs`: CLI entry point for `info`, `scene export`, `render`, and `script run`.
|
||||||
- `src/app_state.rs` and `src/app.rs`: optional `app`-feature desktop shell.
|
- `src/app_state.rs`, `src/app.rs`, and `src/bin/openvistapro_app.rs`: optional `app` feature shell built with `eframe`/`egui`.
|
||||||
`app_state.rs` keeps UI state plus `AppAction` reducers testable without a window;
|
|
||||||
`app.rs` builds the docked `eframe`/`egui` shell — left terrain/scene/camera/render
|
|
||||||
controls, a right scripts/paths panel, a top project bar, a bottom status bar, and the
|
|
||||||
central CPU preview — with disabled placeholders for still-planned import/script/path
|
|
||||||
and file actions. It remains CPU-only until the WGPU backend lands.
|
|
||||||
|
|
||||||
## Suggested technology choices
|
## Suggested technology choices
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ This is a normalized reconciliation of the VistaPro manuals, MakePath guide, scr
|
|||||||
|
|
||||||
Status counts by normalized feature family:
|
Status counts by normalized feature family:
|
||||||
- Implemented: 7
|
- Implemented: 7
|
||||||
- Partial: 9
|
- Partial: 7
|
||||||
- Planned: 4
|
- Planned: 6
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- “Implemented” means the current codebase has a working, tested slice for that family.
|
- “Implemented” means the current codebase has a working, tested slice for that family.
|
||||||
@@ -22,21 +22,21 @@ Notes:
|
|||||||
| 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. |
|
| 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. |
|
| 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. |
|
| 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.orientation`, `Camera.near_range`, `Camera.far_range`), `src/render.rs` (orientation-aware CPU perspective raymarch), `src/app.rs` and `src/app_state.rs` (dockable controls). | The shell now exposes heading/pitch/bank plus lens and range sliders, but the camera model is still a simplified modern interpretation rather than a 1:1 legacy clone. |
|
| 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. |
|
| 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. | Partial | `src/scene.rs` (`Hydrology`), `src/app.rs` and `src/app_state.rs` (hydrology sliders), `src/colormap.rs` (water mask uses the hydrology overlay). | The shell exposes river, lake, and drainage controls, but it does not yet simulate flowing water or routed drainage. |
|
| 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. |
|
| 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. | Partial | `src/scene.rs` (`Scene.vertical_exaggeration`), `src/app.rs` (slider), `src/app_state.rs`, `src/render.rs` (top-down and perspective render scaling). | The shell now scales the preview terrain vertically, but it still uses a single global factor rather than the richer legacy exaggeration workflows. |
|
| 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/scene.rs` (`Palette`), `src/app.rs` (RGB sliders), `src/app_state.rs`, `src/colormap.rs` (palette-aware band lookup). | The shell now exposes an editable color map, but palette import/export and legacy texture loading remain open gaps. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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`, `src/app_state.rs` script preview wiring, `README.md` script section. | Parser exists, and the shell now routes script text into a runnable executor 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. | Partial | `src/script_exec.rs`, `src/app_state.rs` (`run_script_from_source`), `src/app.rs` Run script button, tests in `src/script_exec.rs`. | Executor slice is wired, but multi-frame animation sequencing is still open. |
|
| 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). | Partial | `src/path.rs`, `src/app_state.rs` (`make_path`), `src/app.rs` Make path button, tests in `src/path.rs`. | Demo path generation is wired, but the full MakePath motion-model matrix remains open. |
|
| 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. |
|
||||||
| Modern 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`, `docs/knowledgebase/ui-panel-map.md`. | Current UI is a docked egui CPU-preview shell — left scene/terrain/render controls, a right scripts/paths panel, a top project bar, a bottom status bar, and now visible camera/orientation, vertical exaggeration, palette, hydrology, and reserved legacy-dialog surfaces. Heightmap import, script run, Make Path, and file actions are still backend-driven entry points rather than full legacy dialogs. |
|
| 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/ui_shell.rs`, `src/bin/openvistapro_app.rs`. | OpenVistaPro now has a durable docked egui shell with stable navigation, sidebar, viewport, inspector, and status chrome; menus/dialogs/numeric gadgets still remain to be filled in. |
|
||||||
| 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. |
|
| 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. |
|
||||||
|
|
||||||
## Current reconciliation summary
|
## Current reconciliation summary
|
||||||
|
|
||||||
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 modernized docked shell work needed to replace the old dense UI/menu workflow.
|
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.
|
||||||
|
|||||||
@@ -0,0 +1,370 @@
|
|||||||
|
# Manual Feature List
|
||||||
|
|
||||||
|
A point-by-point inventory of features described in the three PDF manuals in
|
||||||
|
`manuals/`. This is a faithful extraction of what the legacy manuals document —
|
||||||
|
not a reconciliation against the OpenVistaPro codebase. For that, see
|
||||||
|
[feature-inventory.md](./feature-inventory.md).
|
||||||
|
|
||||||
|
Source documents:
|
||||||
|
|
||||||
|
- **`VistaPro2UserManual.pdf`** — Vista Pro 2 User Manual (Commodore Amiga edition).
|
||||||
|
- **`VPWGUIDE.PDF`** — Vistapro 3.0 / 3.1 for Windows, User Manual.
|
||||||
|
- **`MPWGUIDE.PDF`** — MakePath Flight Director for Windows, User Guide.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Vistapro (core program)
|
||||||
|
|
||||||
|
Vistapro is a 3-D landscape simulation/rendering program. It renders real
|
||||||
|
landscapes from USGS Digital Elevation Model (DEM) data and synthetic fractal
|
||||||
|
landscapes from a seed number. The two manuals describe the same program on two
|
||||||
|
platforms (Amiga = v2, Windows = v3); features below are merged, with
|
||||||
|
platform-specific items marked.
|
||||||
|
|
||||||
|
### 1.1 Terrain data sources
|
||||||
|
|
||||||
|
- Render real-world terrain from USGS DEM (Digital Elevation Model) files at
|
||||||
|
~30 m resolution.
|
||||||
|
- Generate fractal landscapes from a seed number — over 4 billion distinct
|
||||||
|
landscapes addressable.
|
||||||
|
- Landscape sizes: **Small** (258×258, ~65k points / 130k polygons), **Large**
|
||||||
|
(514×514), **Huge** (1026×1026, ~1M points / 2M+ polygons), **Mega**
|
||||||
|
(2050×2050, Windows v3 only).
|
||||||
|
- **Automatic** landscape size — sizes the topographic map to match the next
|
||||||
|
DEM loaded.
|
||||||
|
- Load multiple contiguous "X-series" DEM tiles into one Large/Huge/Mega region
|
||||||
|
(**Load Region** / DEM Region, with Manual or Auto placement).
|
||||||
|
- **Load Binary** — import raw signed 16-bit integer elevation grids (any 2-D
|
||||||
|
integer array up to 1024×1024 can be visualized).
|
||||||
|
- Edit/store landscape name and comments in the DEM file header; fractal
|
||||||
|
settings are saved in the header for regeneration.
|
||||||
|
|
||||||
|
### 1.2 Camera and target
|
||||||
|
|
||||||
|
- **Camera** and **Target** placement by clicking the topographic map or by
|
||||||
|
typing X/Y/Z coordinates directly.
|
||||||
|
- Camera Z auto-set 30 m above terrain when placed by mouse.
|
||||||
|
- **X / Y / Z locks** — constrain camera/target movement on individual axes.
|
||||||
|
- **dR / dX / dY / dZ** — camera-to-target distance and per-axis offsets,
|
||||||
|
directly editable.
|
||||||
|
- **Bank / Head / Pitch** — camera orientation (roll, heading, pitch) angles.
|
||||||
|
- **Range** — clip rendering beyond (positive value) or closer than (negative
|
||||||
|
value) a distance from the camera.
|
||||||
|
- **P (wire-frame perspective view)** — preview what the camera sees as a wire
|
||||||
|
frame; click to re-aim, arrow keys to zoom.
|
||||||
|
|
||||||
|
### 1.3 Camera lens
|
||||||
|
|
||||||
|
- **Wide** lens (90° field of view, focal length 16) and **Zoom** lens (~45°,
|
||||||
|
focal length 32).
|
||||||
|
- **FclLn** — arbitrary focal length value (1–30000); lower = wider, higher =
|
||||||
|
more magnification.
|
||||||
|
- **Port / Starboard / Forward** lens modes — render three 90° panorama
|
||||||
|
segments designed for a three-monitor setup.
|
||||||
|
- **Left / Right** lens offsets, **CamSep** (camera separation) and **ImgSep**
|
||||||
|
(image separation) — stereoscopic image-pair rendering.
|
||||||
|
|
||||||
|
### 1.4 Terrain feature controls
|
||||||
|
|
||||||
|
- **SeaLvl** — set sea level; floods/sinks terrain, optionally adds ocean waves.
|
||||||
|
- **TreeLn** — timber line: elevation above which trees do not grow ("fuzzy",
|
||||||
|
AI-adjusted for cliffs, ridges, valleys).
|
||||||
|
- **SnowLn** — snow line: lowest elevation covered with snow (AI-adjusted).
|
||||||
|
- **HazeDn** — atmospheric haze density; auto-computed from camera/target
|
||||||
|
distance or set manually.
|
||||||
|
- **Lake** — fill a basin with water from a clicked elevation point.
|
||||||
|
- **River** — generate a river that flows downhill, fills depressions into
|
||||||
|
ponds/lakes, widens when overlapped, and stops at sea level or map edge.
|
||||||
|
- **Stars** — render a randomly generated star field in the night sky
|
||||||
|
(optional double-width / double-height stars).
|
||||||
|
- **Sky** — toggle sky rendering.
|
||||||
|
- **Horizn** — toggle the distant horizon disk.
|
||||||
|
- **Tree** — open the Tree Control Panel and enable rendered trees.
|
||||||
|
- **Valley** (Windows v3) — control how far trees climb valley bottoms and snow
|
||||||
|
reaches into valleys; used by Stretch.
|
||||||
|
- **Cliffs** (Windows v3) — slope threshold above which terrain uses cliff
|
||||||
|
colors.
|
||||||
|
- **Clouds** (Windows v3) — open the Cloud Control Panel and enable clouds.
|
||||||
|
|
||||||
|
### 1.5 Terrain editing / transformation
|
||||||
|
|
||||||
|
- **VScale** — vertical re-scaling; values >1 exaggerate, 0–1 flatten, negative
|
||||||
|
flips the terrain (special effects: calderas, stepped peaks).
|
||||||
|
- **Enlarg** — blow up a sub-region to fill the whole topographic map, with
|
||||||
|
**Interpolate** (averaged) or **Duplicate** (stepped) infill.
|
||||||
|
- **Shrink** (Windows v3) — reduce Large/Huge landscapes to the next size down.
|
||||||
|
- **Smooth** — erode/smooth jagged terrain (repeatable); useful for fractal
|
||||||
|
terrain and snow-capped peaks.
|
||||||
|
- **Frctlz / Fractalize** — add fractal detail/roughness to existing terrain.
|
||||||
|
- **Strtch / Stretch** — vertically exaggerate existing features (peaks taller,
|
||||||
|
valleys deeper) at a scale set by the fractal divisor.
|
||||||
|
- **Random** fractal generation, with **Island** vs **Floating** edge modes,
|
||||||
|
**FrDim** (fractal dimension/roughness), **Fractal Divisor** (1/2/4/8 feature
|
||||||
|
scale), and the **Fractal Landscape Number** seed gadget.
|
||||||
|
- `SetAltitude` script command — directly modify the elevation of a single
|
||||||
|
terrain point.
|
||||||
|
|
||||||
|
### 1.6 Rendering controls
|
||||||
|
|
||||||
|
- **Render** — generate the image; **ReDraw** — redraw last image; **View** —
|
||||||
|
redisplay last image.
|
||||||
|
- **Poly** size 1 / 2 / 4 / 8 — polygon coarseness vs. render speed tradeoff.
|
||||||
|
- **Dither** — fuzziness of the boundaries between elevation color bands.
|
||||||
|
- **Texture** — Off / Low / Medium / High artificial detail, in two modes:
|
||||||
|
**Shading texture** (split polys, shaded separately) and **Altitude texture**
|
||||||
|
(fractalized polys for photo-realistic detail).
|
||||||
|
- **PixDth / PDithr** — pixel-level dithering (Ordered or Random) to simulate
|
||||||
|
more colors.
|
||||||
|
- **Bound** — restrict rendering to a marked rectangular sub-region.
|
||||||
|
- **BFCull** — back-face culling toggle.
|
||||||
|
- **Blend** — average each polygon's color with its neighbors to reduce
|
||||||
|
distant aliasing.
|
||||||
|
- **GShade** — Gouraud shading for smooth, painterly surfaces.
|
||||||
|
- **Range**-based culling for fast script test renders (see 1.2).
|
||||||
|
|
||||||
|
### 1.7 Lighting
|
||||||
|
|
||||||
|
- **N / S / E / W** quick light directions (sun 45° above horizon).
|
||||||
|
- **Custom** light direction/angle set by clicking the topographic map.
|
||||||
|
- **Azimuth** and **Declin** (declination) numeric light controls.
|
||||||
|
- **Exager** — exaggerated shading to bring out small terrain irregularities.
|
||||||
|
- **Rough** — adds random per-polygon shading variation for surface roughness.
|
||||||
|
- **Shadow** — cast terrain shadows from the sun direction (terrain only; trees
|
||||||
|
cast no shadows).
|
||||||
|
|
||||||
|
### 1.8 Trees (Tree Control Panel)
|
||||||
|
|
||||||
|
- Four tree types: **Pine, Oak, Palm, Cactus**; assignable per elevation zone
|
||||||
|
(Tree1–Tree4) or to **All** zones.
|
||||||
|
- Per-zone **Size** and **Density** (out of 256); **Mean Size** / **Mean
|
||||||
|
Density** to set all zones at once.
|
||||||
|
- 3-D detail levels: **Low / Medium / High / Ultra** (controls branch/leaf
|
||||||
|
levels); no detail level = 2-D trees.
|
||||||
|
- **Leaves** toggle (leafless trees for autumn/winter).
|
||||||
|
- **Texture** — fractal texturing on branches and leaves.
|
||||||
|
- When no tree type is selected, "under-tree" colors (–Tree1–4) color the
|
||||||
|
ground instead.
|
||||||
|
|
||||||
|
### 1.9 Clouds (Cloud Control Panel, Windows v3)
|
||||||
|
|
||||||
|
- **Fractal Detail** — add fractal detail for realistic clouds.
|
||||||
|
- **Density** (0–100), **Hardness** (fluffiness), **Altitude**, and **Cloud
|
||||||
|
Size** (S / M / L / X).
|
||||||
|
- **Generate Clouds** — random fractal cloud map (seedable via `CloudNumber`).
|
||||||
|
- **DEM → Clouds** — turn the current landscape into a cloud pattern (peaks
|
||||||
|
become clouds); enables "sky writing" from a text PCX-derived DEM.
|
||||||
|
- Save/load cloud maps (`.CLD`).
|
||||||
|
|
||||||
|
### 1.10 Colors (Color Control Panel)
|
||||||
|
|
||||||
|
- RGB and HSV (Hue/Saturation/Value) sliders for mixing colors.
|
||||||
|
- Editable color families: **Sky, Snow 1–4, Bare 1–4, –Tree 1–4, Horizon,
|
||||||
|
Cliff 1–4, Water 1–5, Beach, Tree 1–4, Bark 1–4, SkyHaze, Haze, House 1–4,
|
||||||
|
–House 1–4**.
|
||||||
|
- **Exposure** and **Contrast** controls.
|
||||||
|
- **Copy**, **Swap**, and **Spread** (smooth gradient between two colors)
|
||||||
|
palette operations.
|
||||||
|
- Save / load **ColorMap** files independently of the terrain.
|
||||||
|
- **Sound** option (Amiga v2 color panel).
|
||||||
|
|
||||||
|
### 1.11 Palette controls
|
||||||
|
|
||||||
|
- **NumClr** — limit Vistapro to fewer than 256 colors, reserving the rest.
|
||||||
|
- **RGBPal** — compute a palette from the colors actually in a 24-bit image.
|
||||||
|
- **LckPal / LockPalette** — lock the palette so it is reused across frames
|
||||||
|
(needed for VANIM animations).
|
||||||
|
- **Load Palette** — render using the palette from an external PCX file.
|
||||||
|
|
||||||
|
### 1.12 Image / display
|
||||||
|
|
||||||
|
- Image sizes from tiny (16×10 / 80×50) up to **4096×4096**, plus a Custom size.
|
||||||
|
- **Enable 24 bit** internal buffer for true-color (16M color) output.
|
||||||
|
- **Render Viewing Options** (Windows v3): view rendering in real time, every
|
||||||
|
N seconds, or only on completion (progress bar).
|
||||||
|
- Amiga display modes (v2): **Low Res** (32 colors), **HalfBrite** (64 colors),
|
||||||
|
**Hi Res** (16 colors), **HAM** (4096 colors), **DCTV**, **HAM-E**,
|
||||||
|
**Interlace**, **Overscan** (704/736/768), **Firecracker24** display board
|
||||||
|
(1- or 2-monitor).
|
||||||
|
- **Background** image — load a 24-bit image drawn behind the landscape (e.g.
|
||||||
|
a sky); **Foreground** image — overlay (black pixels treated as transparent,
|
||||||
|
e.g. a cockpit/dashboard).
|
||||||
|
|
||||||
|
### 1.13 File formats
|
||||||
|
|
||||||
|
- **Landscapes:** Vistapro DEM, binary, ASCII-Z (MathCAD-style text export,
|
||||||
|
Windows v3), Extended/Session DEM (saves DEM + every program setting for
|
||||||
|
resuming aborted animations).
|
||||||
|
- **Images:** PCX, Targa24 (TGA), BMP24 (Windows); IFF, IFF24, RGB
|
||||||
|
(Sculpt-Animate/Mimetics framebuffer triplets, Amiga).
|
||||||
|
- **3-D export:** DXF polyface mesh and Targa24 **texture maps** for 3D Studio
|
||||||
|
integration (Windows v3); **Turbo Silver** object export (Amiga v2).
|
||||||
|
- **Stereo:** **Load Stereo** merges a left/right Targa24 pair into a red/blue
|
||||||
|
anaglyph image.
|
||||||
|
- ColorMap and cloud-map (`.CLD`) files.
|
||||||
|
|
||||||
|
### 1.14 Import / convert (ExpImp / Property menu)
|
||||||
|
|
||||||
|
- **DEM ↔ PCX** — save the topographic map as a PCX contour image and reload it;
|
||||||
|
enables painting elevations between digitized contour lines.
|
||||||
|
- **Col ↔ PCX** — export/import Vistapro's internal polygon color (terrain
|
||||||
|
type) table; lets you hand-place trees, rivers, cliffs, **houses**, etc. in a
|
||||||
|
paint program.
|
||||||
|
- **View → DEM / Alt** — convert the rendered image into elevation data
|
||||||
|
(Intensity or Color mode).
|
||||||
|
- **View → Col** — convert the rendered image into the internal color table.
|
||||||
|
- **View → RGB** — convert the rendered image into the 24-bit buffer (used to
|
||||||
|
build background/foreground images).
|
||||||
|
- Map a satellite image or arbitrary picture onto the terrain via PCX → Col in
|
||||||
|
color mode.
|
||||||
|
|
||||||
|
### 1.15 Scripting and animation
|
||||||
|
|
||||||
|
- **Scripts** are landscape-independent lists of camera/target positions, used
|
||||||
|
mainly for animations and reusable across landscapes.
|
||||||
|
- **Generate** — quick straight-line camera→target path of N frames.
|
||||||
|
- **Create / Open / Add** — build a script by appending current camera/target
|
||||||
|
positions.
|
||||||
|
- **Preview** — show the script path on the topographic map in 2-D dots or 3-D
|
||||||
|
wire frame.
|
||||||
|
- **Run / Execute** — render every frame; output as **Run Targa24 / Run BMP24 /
|
||||||
|
Run PCX / Run VAN** (Windows) or IFF / IFF24 / RGB / VANIM (Amiga).
|
||||||
|
- Aborting and continuing (appending to) a partially rendered animation.
|
||||||
|
- **Batch files** — run a DOS batch file after each rendered frame.
|
||||||
|
- **VANIM** (`.VAN`) proprietary animation format — disk-streamed playback,
|
||||||
|
per-frame palette, forward/reverse/step playback; played by the bundled
|
||||||
|
**Animation Viewer**.
|
||||||
|
- Full **Vistapro Script Language** (Windows v3): ~100+ text commands covering
|
||||||
|
camera/target, colors (`SetColor*`), trees, clouds, lighting, sea/tree/snow
|
||||||
|
lines, fractal generation, texture/shading, image size, GrMode, default
|
||||||
|
directories, `Render`, `RewriteImage`, `Spawn` (run external programs),
|
||||||
|
`Smooth`, `Stretch`, `Enlarge`, `MakeLake`, `MakeRiver`, `SetAltitude`, etc.
|
||||||
|
- **ARexx** interface for all script functions (Amiga v2).
|
||||||
|
- **Quality** menu (Windows v3) — Low / Medium / High / Ultra / User presets
|
||||||
|
implemented as editable scripts in the `IQ` directory.
|
||||||
|
|
||||||
|
### 1.16 3D Studio integration (Windows v3)
|
||||||
|
|
||||||
|
- **Method 1** — export terrain as DXF + Vistapro-generated texture map; 3DS
|
||||||
|
renders everything.
|
||||||
|
- **Method 2** — export DEM as a DXF "matte" object; Vistapro renders the
|
||||||
|
landscape background and 3DS renders objects composited over it.
|
||||||
|
- `VUE2SCR` utility — convert 3D Studio camera VUE files into Vistapro scripts.
|
||||||
|
|
||||||
|
### 1.17 Bundled utilities
|
||||||
|
|
||||||
|
- **Animation Viewer** — plays VANIM (`.VAN`) files.
|
||||||
|
- **AVI Builder** (Windows v3) — assembles PCX/TGA/BMP frame sequences into AVI.
|
||||||
|
- Image **print** utility (Windows v3 prints PCX/TGA/BMP24; Amiga v2 prints the
|
||||||
|
View screen directly).
|
||||||
|
|
||||||
|
### 1.18 Status / workflow aids
|
||||||
|
|
||||||
|
- Live topographic map colored by elevation (green low → brown mid →
|
||||||
|
gray-white high).
|
||||||
|
- Status window showing pointer X/Y/Z and the current render phase
|
||||||
|
(Generate, Color, Cliffs, Shade, Tree, Sky, Horizon, Render).
|
||||||
|
- **About Vistapro / About DEM / About Image** info panels (image polygon
|
||||||
|
count and render time).
|
||||||
|
- Abort any long operation with Esc or the right mouse button.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. MakePath Flight Director
|
||||||
|
|
||||||
|
MakePath is a companion animation-path tool for Vistapro. It turns a few
|
||||||
|
control points into a smooth flight path and exports it as a Vistapro script.
|
||||||
|
|
||||||
|
### 2.1 Path creation
|
||||||
|
|
||||||
|
- Load any Vistapro DEM into a topographic map (same map style as Vistapro).
|
||||||
|
- Place **spline nodes** by clicking the map; the path is a smooth spline curve
|
||||||
|
through them (minimum four nodes).
|
||||||
|
- **MakePath** button — generate the smooth spline path from the current nodes
|
||||||
|
and motion settings.
|
||||||
|
- Spline curve type: **Bezier** (passes through nodes) or **BSpline** (smoother,
|
||||||
|
does not pass through nodes) via the **Use BSpline** option.
|
||||||
|
- **New Path** — clear nodes and path.
|
||||||
|
|
||||||
|
### 2.2 Node editing
|
||||||
|
|
||||||
|
- **Add Node** — insert a node between the nearest two, or append to an end.
|
||||||
|
- **Delete Node** — remove a node.
|
||||||
|
- Move a node by grabbing it with the mouse (lines rubber-band; right-click
|
||||||
|
aborts).
|
||||||
|
- **Edit Node** — numerically edit a node's X/Y/Z coordinates and its target;
|
||||||
|
step between nodes with Next/Previous.
|
||||||
|
- **Set Target / Unset Target** — attach (or remove) a look-at target point to a
|
||||||
|
node so the camera watches it while passing.
|
||||||
|
- Adjust node altitude in the **Altitude Profile** view.
|
||||||
|
|
||||||
|
### 2.3 Motion controls
|
||||||
|
|
||||||
|
- **Fly** vs **Drive** — air vehicle (ignores ground contour) vs land vehicle
|
||||||
|
(follows the ground).
|
||||||
|
- **Frames** vs **Speed** — generate a fixed number of frames, or as many as
|
||||||
|
needed to hold a given speed (km/h at 30 fps).
|
||||||
|
- **Smooth** — reduce how closely the path follows rugged terrain.
|
||||||
|
- **Loop** — close the path back to the first node for continuous looping.
|
||||||
|
- **Bank** — bank into turns (positive, plane-like) or out of turns (negative,
|
||||||
|
car-like).
|
||||||
|
- **Accel** — speed up on descents, slow on climbs.
|
||||||
|
- **Pitch** — pitch up on climbs / down on descents, plus a constant pitch
|
||||||
|
offset (e.g. –90° for a straight-down "glass bottom boat" view).
|
||||||
|
- **Height** — altitude of each node above the ground beneath it.
|
||||||
|
- **MinHgt** — minimum ground clearance for the path / ride height for ground
|
||||||
|
vehicles.
|
||||||
|
- **Avoid Collisions** — keep flying vehicles above the terrain (can be
|
||||||
|
disabled to fly through obstacles).
|
||||||
|
|
||||||
|
### 2.4 Vehicle motion models
|
||||||
|
|
||||||
|
Predefined parameter sets selectable from the Models menu:
|
||||||
|
|
||||||
|
- **Glider, Jet, Cruise Missile, Helicopter, Dune Buggy, Motorcycle** — and any
|
||||||
|
custom combination of the controls above.
|
||||||
|
|
||||||
|
### 2.5 Special path options
|
||||||
|
|
||||||
|
- **Add Barrel Roll** — add a roll over a chosen number of frames, clockwise or
|
||||||
|
counter-clockwise.
|
||||||
|
- **Make Spin Path** — circular path orbiting the landscape with the camera
|
||||||
|
fixed on its center (diameter set as a percentage of landscape size).
|
||||||
|
|
||||||
|
### 2.6 Preview
|
||||||
|
|
||||||
|
- **ViewPath** — wire-frame animation preview of the whole path.
|
||||||
|
- **ViewFrame** — wire-frame preview of a single frame.
|
||||||
|
- Animation controls (numeric keypad): step ±1 / ±10 frames, first/last frame,
|
||||||
|
freeze, play forward/backward.
|
||||||
|
- **View Options / Preview Controls** — preview **Area Size** (Tiny→Huge,
|
||||||
|
trading detail for constant speed), **Lens** (Wide/Medium/Zoom), and **Frame
|
||||||
|
Speed** (Fast 18 fps / Medium 9 fps / Slow 6 fps).
|
||||||
|
- **Show Crosshairs** — toggle the on-map position crosshairs.
|
||||||
|
|
||||||
|
### 2.7 Altitude Profile
|
||||||
|
|
||||||
|
- Side-profile view of node and camera altitudes (red) against the terrain
|
||||||
|
beneath the path (brown).
|
||||||
|
- Adjustable vertical scale (+ / = / –); horizontal slider to scroll long paths.
|
||||||
|
- Edit node altitude directly in the profile; small "wings" indicator shows
|
||||||
|
bank.
|
||||||
|
|
||||||
|
### 2.8 Files
|
||||||
|
|
||||||
|
- **Save Script** — export the path as a Vistapro script for rendering.
|
||||||
|
- **Save Session / Load Session** — save/restore the full MakePath editing
|
||||||
|
state (nodes, settings, and the associated landscape reference).
|
||||||
|
- **Load DEM** — load a landscape.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Cross-cutting notes
|
||||||
|
|
||||||
|
- Vistapro v2 (Amiga) and v3 (Windows) share the same core feature set; v3 adds
|
||||||
|
Mega landscapes, Valley/Cliffs/Clouds controls, the Quality menu, the full
|
||||||
|
scripting language, 3D Studio integration, stereoscopic anaglyph output, and
|
||||||
|
Windows image formats. v2 adds Amiga display hardware modes and Turbo Silver
|
||||||
|
export.
|
||||||
|
- Both Vistapro and MakePath emphasize the same workflow: rough fast preview
|
||||||
|
(large polygons, low detail) → tune camera, lighting, and feature lines →
|
||||||
|
full-detail final render.
|
||||||
@@ -183,11 +183,18 @@ Expected: image is created; no panic for reasonable scene defaults.
|
|||||||
## Phase 4: Formats, scripts, UI
|
## Phase 4: Formats, scripts, UI
|
||||||
|
|
||||||
- Add open DEM/HGT/GeoTIFF importers behind feature flags.
|
- Add open DEM/HGT/GeoTIFF importers behind feature flags.
|
||||||
|
- Current importer surface: project-owned `ovp-text` fixtures, script-level PNG heightmap input, feature-gated SRTM/HGT byte parsing, and feature-gated ESRI ASCII Grid parsing.
|
||||||
|
- GeoTIFF remains a strategy/research item until a separate feature slice chooses a dependency boundary.
|
||||||
- Define OpenVistaPro scene file format.
|
- Define OpenVistaPro scene file format.
|
||||||
- Initial implementation: `.ovp.toml` files with
|
- Initial implementation: `.ovp.toml` files with
|
||||||
`schema = "openvistapro.scene"`, `version = 1`, and a serialized `Scene`
|
`schema = "openvistapro.scene"`, `version = 1`, and a serialized `Scene`
|
||||||
payload. CLI support starts with
|
payload. CLI support starts with
|
||||||
`openvistapro scene export --output scene.ovp.toml` and
|
`openvistapro scene export --output scene.ovp.toml` and
|
||||||
`openvistapro render --preset hill --scene scene.ovp.toml --output out.png`.
|
`openvistapro render --preset hill --scene scene.ovp.toml --output out.png`.
|
||||||
- Implement a VistaPro-inspired script language, then a MakePath-like spline path generator.
|
- Implement a VistaPro-inspired script language and executor.
|
||||||
|
- Current CLI: `openvistapro script run --input examples/demo.ovps`.
|
||||||
|
- The syntax is project-owned and not legacy VistaPro-compatible.
|
||||||
|
- Add a MakePath-like spline path generator.
|
||||||
|
- Current module: `src/path.rs` with deterministic keyframe interpolation used as a foundation for future animation output.
|
||||||
- Build an interactive app with WGPU and egui after CLI renderer is stable.
|
- Build an interactive app with WGPU and egui after CLI renderer is stable.
|
||||||
|
- Current app shell: `cargo run --features app --bin openvistapro_app`; it uses `eframe`/`egui` scene controls and a CPU terrain preview while full WGPU terrain rendering remains future work.
|
||||||
|
|||||||
@@ -19,10 +19,14 @@ 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/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/colormap.rs`: deterministic elevation-band colors.
|
||||||
- `src/render.rs`: deterministic top-down PNG renderer plus spike-quality CPU perspective raymarcher.
|
- `src/render.rs`: deterministic top-down PNG renderer plus spike-quality CPU perspective raymarcher.
|
||||||
- `src/cli.rs`: `info`, `scene export`, and `render` commands.
|
- `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.
|
||||||
|
- `src/cli.rs`: `info`, `scene export`, `render`, and `script run` commands.
|
||||||
- `README.md`, `docs/legal/asset-policy.md`, `docs/research/reference-inventory.md`, and `docs/knowledgebase/*.md`: clean-room context and project constraints.
|
- `README.md`, `docs/legal/asset-policy.md`, `docs/research/reference-inventory.md`, and `docs/knowledgebase/*.md`: clean-room context and project constraints.
|
||||||
|
|
||||||
Phase 4 work should preserve this baseline and extend it in thin, tested slices.
|
Phase 4 thin slices have landed as implementation checkpoints, while this file remains a historical implementation plan for follow-on work and validation.
|
||||||
|
|
||||||
## Non-negotiable clean-room and repository hygiene rules
|
## Non-negotiable clean-room and repository hygiene rules
|
||||||
|
|
||||||
|
|||||||
@@ -8,20 +8,21 @@ Define the next terrain-generation workstream so future slices stay small, deter
|
|||||||
|
|
||||||
OpenVistaPro already has:
|
OpenVistaPro already has:
|
||||||
|
|
||||||
|
- `src/terrain_gen.rs` with `TerrainGenerationSpec` and `DeterministicTerrainGenerator`, plus `cargo test terrain_gen` coverage for the determinism/seed note.
|
||||||
|
|
||||||
- `src/terrain.rs` with immutable `HeightGrid` storage, safe indexing, min/max, and deterministic `plane` / `radial_hill` fixtures.
|
- `src/terrain.rs` with immutable `HeightGrid` storage, safe indexing, min/max, and deterministic `plane` / `radial_hill` fixtures.
|
||||||
- `src/terrain_gen.rs` with the procedural terrain generator slice: `TerrainGenerationSpec` (seed plus dimensions), the `TerrainGenerator` trait, and `DeterministicTerrainGenerator`, a deterministic seeded value-noise fBm generator that returns a plain `HeightGrid`.
|
|
||||||
- `src/render.rs` with a deterministic top-down preview and a CPU perspective spike that only depends on `HeightGrid` + `Scene`.
|
- `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/scene.rs` and `src/app_state.rs` for scene controls and preview wiring.
|
||||||
- `src/import.rs` for the open-format import boundary.
|
- `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.
|
- `docs/plans/initial-roadmap.md` and `docs/plans/phase-4-formats-scripts-ui.md` for the broader project sequence.
|
||||||
|
|
||||||
The first procedural slice has now landed in `src/terrain_gen.rs`: it produces richer synthetic landscapes without mixing algorithm state into `HeightGrid`. The remaining work is preset profiles and the later enhancements tracked in the roadmap slices below.
|
The missing piece is a dedicated procedural terrain generator pipeline that can produce richer synthetic landscapes without mixing algorithm state into `HeightGrid`.
|
||||||
|
|
||||||
## Decision summary
|
## Decision summary
|
||||||
|
|
||||||
### First generator family: seeded 2D value-noise fBm (landed)
|
### First generator family: seeded 2D value-noise fBm
|
||||||
|
|
||||||
This family has landed as `DeterministicTerrainGenerator` in `src/terrain_gen.rs`: a deterministic, seedable fractal terrain generator built from 2D value noise with fBm-style octaves.
|
Implement a deterministic, seedable fractal terrain family first, built from 2D value noise with fBm-style octaves.
|
||||||
|
|
||||||
Why this first:
|
Why this first:
|
||||||
|
|
||||||
@@ -39,7 +40,7 @@ Keep `HeightGrid` as pure data plus basic helpers.
|
|||||||
Recommended split:
|
Recommended split:
|
||||||
|
|
||||||
- `src/terrain.rs`: immutable grid storage, validation, indexing, min/max, and tiny deterministic fixtures like `plane` and `radial_hill`.
|
- `src/terrain.rs`: immutable grid storage, validation, indexing, min/max, and tiny deterministic fixtures like `plane` and `radial_hill`.
|
||||||
- Generator module (landed as `src/terrain_gen.rs`): procedural generation logic, seed handling, interpolation/noise helpers, and generator presets.
|
- 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.
|
- 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.
|
- Any generator metadata should live in a separate spec/config type, not in the grid itself.
|
||||||
|
|
||||||
@@ -54,8 +55,6 @@ If the implementation later needs richer provenance, add a lightweight wrapper b
|
|||||||
|
|
||||||
### Slice 1: generator module skeleton
|
### Slice 1: generator module skeleton
|
||||||
|
|
||||||
Status: landed in `src/terrain_gen.rs`.
|
|
||||||
|
|
||||||
Goal: introduce the procedural terrain namespace without changing renderer behavior.
|
Goal: introduce the procedural terrain namespace without changing renderer behavior.
|
||||||
|
|
||||||
Deliverables:
|
Deliverables:
|
||||||
@@ -71,8 +70,6 @@ Acceptance:
|
|||||||
|
|
||||||
### Slice 2: deterministic value-noise core
|
### Slice 2: deterministic value-noise core
|
||||||
|
|
||||||
Status: landed in `src/terrain_gen.rs`.
|
|
||||||
|
|
||||||
Goal: implement the smallest reusable noise primitive.
|
Goal: implement the smallest reusable noise primitive.
|
||||||
|
|
||||||
Deliverables:
|
Deliverables:
|
||||||
@@ -89,8 +86,6 @@ Acceptance:
|
|||||||
|
|
||||||
### Slice 3: fBm terrain composition
|
### Slice 3: fBm terrain composition
|
||||||
|
|
||||||
Status: landed in `src/terrain_gen.rs`.
|
|
||||||
|
|
||||||
Goal: layer octaves into usable synthetic landscapes.
|
Goal: layer octaves into usable synthetic landscapes.
|
||||||
|
|
||||||
Deliverables:
|
Deliverables:
|
||||||
@@ -106,8 +101,6 @@ Acceptance:
|
|||||||
|
|
||||||
### Slice 4: preset profiles
|
### Slice 4: preset profiles
|
||||||
|
|
||||||
Status: not started — the next slice.
|
|
||||||
|
|
||||||
Goal: provide a few named generator presets for common shapes.
|
Goal: provide a few named generator presets for common shapes.
|
||||||
|
|
||||||
Suggested first presets:
|
Suggested first presets:
|
||||||
@@ -158,16 +151,12 @@ Run these for each generator slice:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo fmt --check
|
cargo fmt --check
|
||||||
cargo test terrain_gen -- --nocapture
|
cargo test terrain
|
||||||
cargo test
|
cargo test
|
||||||
cargo clippy --all-targets -- -D warnings
|
cargo clippy --all-targets -- -D warnings
|
||||||
```
|
```
|
||||||
|
|
||||||
`cargo test terrain_gen -- --nocapture` is the feature-specific smoke command for
|
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.
|
||||||
the landed generator slice: it exercises `DeterministicTerrainGenerator` against
|
|
||||||
the `TerrainGenerationSpec` surface in `src/terrain_gen.rs` and proves a seeded
|
|
||||||
grid is reproducible. Future slices should add their own targeted smoke command
|
|
||||||
once they expose a new public entry point.
|
|
||||||
|
|
||||||
## Definition of done for the workstream
|
## Definition of done for the workstream
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# OpenVistaPro demo render script.
|
||||||
|
#
|
||||||
|
# Project-owned scripting syntax (see src/script.rs) — not legacy VistaPro.
|
||||||
|
# Run it with:
|
||||||
|
#
|
||||||
|
# cargo run --bin openvistapro -- script run --input examples/demo.ovps
|
||||||
|
#
|
||||||
|
# Paths are resolved relative to this script's directory, so the render is
|
||||||
|
# written next to this file as examples/demo-render.png.
|
||||||
|
|
||||||
|
use preset hill
|
||||||
|
set thresholds water=1.0 tree=4.0 snow=7.0
|
||||||
|
render output "demo-render.png"
|
||||||
+281
-115
@@ -1,16 +1,19 @@
|
|||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
use image::RgbImage;
|
use image::RgbImage;
|
||||||
|
|
||||||
use crate::app_state::{AppAction, AppData, RendererMode, TerrainPreset};
|
use crate::app_state::{AppAction, AppData, RendererMode, TerrainPreset};
|
||||||
use crate::scene::Vec3;
|
use crate::scene::Vec3;
|
||||||
|
use crate::ui_shell::{ShellSection, UiShellState};
|
||||||
|
|
||||||
pub const WINDOW_TITLE: &str = "OpenVistaPro";
|
pub const WINDOW_TITLE: &str = "OpenVistaPro";
|
||||||
|
|
||||||
|
/// Top-level egui application. Owns the renderable scene state ([`AppData`])
|
||||||
|
/// and the navigation state ([`UiShellState`]); the `update` body is a thin
|
||||||
|
/// view that renders durable panels around those two state objects.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct OpenVistaProApp {
|
pub struct OpenVistaProApp {
|
||||||
data: AppData,
|
data: AppData,
|
||||||
|
shell: UiShellState,
|
||||||
texture: Option<egui::TextureHandle>,
|
texture: Option<egui::TextureHandle>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,23 +22,6 @@ impl OpenVistaProApp {
|
|||||||
Self::default()
|
Self::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rebuild_texture(&mut self, ctx: &egui::Context) {
|
|
||||||
let Ok(preview) = self.data.render_preview() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let color_image = rgb_image_to_color_image(&preview);
|
|
||||||
match &mut self.texture {
|
|
||||||
Some(texture) => texture.set(color_image, egui::TextureOptions::NEAREST),
|
|
||||||
None => {
|
|
||||||
self.texture = Some(ctx.load_texture(
|
|
||||||
"openvistapro_cpu_preview",
|
|
||||||
color_image,
|
|
||||||
egui::TextureOptions::NEAREST,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_scene_path() -> String {
|
fn default_scene_path() -> String {
|
||||||
format!(
|
format!(
|
||||||
"{}/target/openvistapro-scene.ovp.toml",
|
"{}/target/openvistapro-scene.ovp.toml",
|
||||||
@@ -53,21 +39,69 @@ impl OpenVistaProApp {
|
|||||||
fn default_script_base_dir() -> &'static str {
|
fn default_script_base_dir() -> &'static str {
|
||||||
env!("CARGO_MANIFEST_DIR")
|
env!("CARGO_MANIFEST_DIR")
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
impl eframe::App for OpenVistaProApp {
|
fn rebuild_texture(&mut self, ctx: &egui::Context) {
|
||||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
let Ok(preview) = self.data.render_preview() else {
|
||||||
let mut changed = false;
|
return;
|
||||||
let mut action_note: Option<String> = None;
|
};
|
||||||
|
let color_image = rgb_image_to_color_image(&preview);
|
||||||
egui::SidePanel::left("scene_controls")
|
match &mut self.texture {
|
||||||
.resizable(true)
|
Some(texture) => texture.set(color_image, egui::TextureOptions::NEAREST),
|
||||||
.show(ctx, |ui| {
|
None => {
|
||||||
ui.heading("OpenVistaPro");
|
self.texture = Some(ctx.load_texture(
|
||||||
ui.label("CPU preview shell");
|
"openvistapro_cpu_preview",
|
||||||
|
color_image,
|
||||||
|
egui::TextureOptions::NEAREST,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Top command/navigation bar. Every section is always present so
|
||||||
|
/// navigation stays stable even where the surface is only a placeholder.
|
||||||
|
fn command_bar(&mut self, ctx: &egui::Context) {
|
||||||
|
egui::TopBottomPanel::top("command_bar").show(ctx, |ui| {
|
||||||
|
ui.horizontal_wrapped(|ui| {
|
||||||
|
ui.strong(WINDOW_TITLE);
|
||||||
ui.separator();
|
ui.separator();
|
||||||
ui.label("Terrain");
|
for §ion in self.shell.sections() {
|
||||||
|
let active = self.shell.is_active(section);
|
||||||
|
if ui.selectable_label(active, section.title()).clicked() {
|
||||||
|
self.shell.activate(section);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Left panel: controls contextual to the active section. Sections without
|
||||||
|
/// an implemented surface still show their labelled placeholder.
|
||||||
|
///
|
||||||
|
/// Returns `true` when an edit changed something the preview depends on.
|
||||||
|
fn controls_panel(&mut self, ctx: &egui::Context, action_note: &mut Option<String>) -> bool {
|
||||||
|
let mut changed = false;
|
||||||
|
egui::SidePanel::left("controls_panel")
|
||||||
|
.resizable(true)
|
||||||
|
.default_width(240.0)
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
ui.heading(self.shell.section_title());
|
||||||
|
ui.label(self.shell.section_summary());
|
||||||
|
ui.separator();
|
||||||
|
match self.shell.active_section {
|
||||||
|
ShellSection::Terrain => changed |= self.terrain_controls(ui),
|
||||||
|
ShellSection::Scene => changed |= self.scene_controls(ui),
|
||||||
|
ShellSection::Render => changed |= self.render_controls(ui),
|
||||||
|
ShellSection::Import => changed |= self.import_controls(ui, action_note),
|
||||||
|
ShellSection::Script => changed |= self.script_controls(ui, action_note),
|
||||||
|
ShellSection::Path => changed |= self.path_controls(ui, action_note),
|
||||||
|
ShellSection::Project => changed |= self.project_controls(ui, action_note),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
changed
|
||||||
|
}
|
||||||
|
|
||||||
|
fn terrain_controls(&mut self, ui: &mut egui::Ui) -> bool {
|
||||||
|
let mut changed = false;
|
||||||
let mut preset = self.data.terrain_preset;
|
let mut preset = self.data.terrain_preset;
|
||||||
changed |= ui
|
changed |= ui
|
||||||
.radio_value(&mut preset, TerrainPreset::RadialHill, "Radial hill")
|
.radio_value(&mut preset, TerrainPreset::RadialHill, "Radial hill")
|
||||||
@@ -78,9 +112,11 @@ impl eframe::App for OpenVistaProApp {
|
|||||||
if preset != self.data.terrain_preset {
|
if preset != self.data.terrain_preset {
|
||||||
self.data.apply(AppAction::SetTerrainPreset(preset));
|
self.data.apply(AppAction::SetTerrainPreset(preset));
|
||||||
}
|
}
|
||||||
|
changed
|
||||||
|
}
|
||||||
|
|
||||||
ui.separator();
|
fn render_controls(&mut self, ui: &mut egui::Ui) -> bool {
|
||||||
ui.label("Renderer");
|
let mut changed = false;
|
||||||
let mut renderer_mode = self.data.renderer_mode;
|
let mut renderer_mode = self.data.renderer_mode;
|
||||||
changed |= ui
|
changed |= ui
|
||||||
.radio_value(&mut renderer_mode, RendererMode::TopDown, "Top-down")
|
.radio_value(&mut renderer_mode, RendererMode::TopDown, "Top-down")
|
||||||
@@ -91,8 +127,11 @@ impl eframe::App for OpenVistaProApp {
|
|||||||
if renderer_mode != self.data.renderer_mode {
|
if renderer_mode != self.data.renderer_mode {
|
||||||
self.data.apply(AppAction::SetRendererMode(renderer_mode));
|
self.data.apply(AppAction::SetRendererMode(renderer_mode));
|
||||||
}
|
}
|
||||||
|
changed
|
||||||
|
}
|
||||||
|
|
||||||
ui.separator();
|
fn scene_controls(&mut self, ui: &mut egui::Ui) -> bool {
|
||||||
|
let mut changed = false;
|
||||||
ui.label("Scene bands");
|
ui.label("Scene bands");
|
||||||
let mut water = self.data.scene.water_level;
|
let mut water = self.data.scene.water_level;
|
||||||
let mut trees = self.data.scene.tree_line;
|
let mut trees = self.data.scene.tree_line;
|
||||||
@@ -124,39 +163,116 @@ impl eframe::App for OpenVistaProApp {
|
|||||||
self.data
|
self.data
|
||||||
.apply(AppAction::SetCameraPosition(camera_position));
|
.apply(AppAction::SetCameraPosition(camera_position));
|
||||||
self.data.apply(AppAction::SetCameraTarget(camera_target));
|
self.data.apply(AppAction::SetCameraTarget(camera_target));
|
||||||
});
|
|
||||||
|
|
||||||
egui::SidePanel::right("entry_points")
|
|
||||||
.resizable(true)
|
|
||||||
.show(ctx, |ui| {
|
|
||||||
ui.heading("Scripts / paths");
|
|
||||||
|
|
||||||
ui.separator();
|
ui.separator();
|
||||||
ui.label("Import terrain");
|
ui.label("Camera orientation and lens");
|
||||||
|
let mut orientation = self.data.scene.camera.orientation;
|
||||||
|
changed |= vec3_controls(ui, "Orientation", &mut orientation);
|
||||||
|
self.data
|
||||||
|
.apply(AppAction::SetCameraOrientation(orientation));
|
||||||
|
|
||||||
|
let mut fov_degrees = self.data.scene.camera.fov_degrees;
|
||||||
|
let mut near_range = self.data.scene.camera.near_range;
|
||||||
|
let mut far_range = self.data.scene.camera.far_range;
|
||||||
|
changed |= ui
|
||||||
|
.add(egui::Slider::new(&mut fov_degrees, 10.0..=170.0).text("FOV"))
|
||||||
|
.changed();
|
||||||
|
changed |= ui
|
||||||
|
.add(egui::Slider::new(&mut near_range, 0.1..=50.0).text("Near"))
|
||||||
|
.changed();
|
||||||
|
changed |= ui
|
||||||
|
.add(egui::Slider::new(&mut far_range, 1.0..=1000.0).text("Far"))
|
||||||
|
.changed();
|
||||||
|
self.data.apply(AppAction::SetCameraLens {
|
||||||
|
fov_degrees,
|
||||||
|
near_range,
|
||||||
|
far_range,
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.separator();
|
||||||
|
ui.label("Vertical exaggeration");
|
||||||
|
let mut vertical_exaggeration = self.data.scene.vertical_exaggeration;
|
||||||
|
changed |= ui
|
||||||
|
.add(egui::Slider::new(&mut vertical_exaggeration, 0.1..=10.0).text("Scale"))
|
||||||
|
.changed();
|
||||||
|
self.data
|
||||||
|
.apply(AppAction::SetVerticalExaggeration(vertical_exaggeration));
|
||||||
|
|
||||||
|
ui.separator();
|
||||||
|
ui.label("Hydrology");
|
||||||
|
let mut river_level = self.data.scene.hydrology.river_level;
|
||||||
|
let mut lake_level = self.data.scene.hydrology.lake_level;
|
||||||
|
let mut drainage = self.data.scene.hydrology.drainage;
|
||||||
|
changed |= ui
|
||||||
|
.add(egui::Slider::new(&mut river_level, -5.0..=10.0).text("River"))
|
||||||
|
.changed();
|
||||||
|
changed |= ui
|
||||||
|
.add(egui::Slider::new(&mut lake_level, -5.0..=10.0).text("Lake"))
|
||||||
|
.changed();
|
||||||
|
changed |= ui
|
||||||
|
.add(egui::Slider::new(&mut drainage, 0.0..=5.0).text("Drainage"))
|
||||||
|
.changed();
|
||||||
|
self.data.apply(AppAction::SetHydrology {
|
||||||
|
river_level,
|
||||||
|
lake_level,
|
||||||
|
drainage,
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.separator();
|
||||||
|
ui.label("Palette");
|
||||||
|
let mut palette = self.data.scene.palette;
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Water");
|
||||||
|
changed |= ui.color_edit_button_srgb(&mut palette.water).changed();
|
||||||
|
});
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Lowland");
|
||||||
|
changed |= ui.color_edit_button_srgb(&mut palette.lowland).changed();
|
||||||
|
});
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Highland");
|
||||||
|
changed |= ui.color_edit_button_srgb(&mut palette.highland).changed();
|
||||||
|
});
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Snow");
|
||||||
|
changed |= ui.color_edit_button_srgb(&mut palette.snow).changed();
|
||||||
|
});
|
||||||
|
self.data.apply(AppAction::SetPalette(palette));
|
||||||
|
|
||||||
|
changed
|
||||||
|
}
|
||||||
|
|
||||||
|
fn import_controls(&mut self, ui: &mut egui::Ui, action_note: &mut Option<String>) -> bool {
|
||||||
|
let mut changed = false;
|
||||||
|
ui.label("Import terrain");
|
||||||
let import_path = self
|
let import_path = self
|
||||||
.data
|
.data
|
||||||
.import_path
|
.import_path
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(Self::default_import_path);
|
.unwrap_or_else(Self::default_import_path);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
ui.monospace(import_path.as_str());
|
ui.monospace(import_path.as_str());
|
||||||
if ui.button("Import heightmap…").clicked() {
|
if ui.button("Import heightmap…").clicked() {
|
||||||
let path = Path::new(import_path.as_str());
|
let path = std::path::Path::new(import_path.as_str());
|
||||||
match self.data.import_heightmap_from_path(path) {
|
match self.data.import_heightmap_from_path(path) {
|
||||||
Ok(()) => changed = true,
|
Ok(()) => {
|
||||||
Err(error) => action_note = Some(format!("import failed: {error}")),
|
changed = true;
|
||||||
|
*action_note = Some(format!("imported heightmap from {import_path}"));
|
||||||
|
}
|
||||||
|
Err(error) => *action_note = Some(format!("import failed: {error}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if let Some(grid) = self.data.imported_grid.as_ref() {
|
if let Some(grid) = self.data.imported_grid.as_ref() {
|
||||||
ui.label(format!("Imported grid: {}×{}", grid.width(), grid.height()));
|
ui.label(format!("Imported grid: {}×{}", grid.width(), grid.height()));
|
||||||
} else {
|
} else {
|
||||||
ui.label(
|
ui.label(self.shell.placeholder_label());
|
||||||
"Legacy import surfaces remain planned; the shell shows the entry point.",
|
}
|
||||||
);
|
changed
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.separator();
|
fn script_controls(&mut self, ui: &mut egui::Ui, action_note: &mut Option<String>) -> bool {
|
||||||
|
let mut changed = false;
|
||||||
ui.label("Script source");
|
ui.label("Script source");
|
||||||
changed |= ui
|
changed |= ui
|
||||||
.add(
|
.add(
|
||||||
@@ -168,23 +284,28 @@ impl eframe::App for OpenVistaProApp {
|
|||||||
.changed();
|
.changed();
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
if ui.button("Run script").clicked() {
|
if ui.button("Run script").clicked() {
|
||||||
let base_dir = Path::new(Self::default_script_base_dir());
|
let base_dir = std::path::Path::new(Self::default_script_base_dir());
|
||||||
match self.data.run_script_from_source(base_dir) {
|
match self.data.run_script_from_source(base_dir) {
|
||||||
Ok(report) => {
|
Ok(report) => {
|
||||||
if !report.outputs.is_empty() {
|
changed |= !report.outputs.is_empty();
|
||||||
changed = true;
|
*action_note =
|
||||||
|
Some(format!("script wrote {} output(s)", report.outputs.len()));
|
||||||
}
|
}
|
||||||
}
|
Err(error) => *action_note = Some(format!("script run failed: {error}")),
|
||||||
Err(error) => action_note = Some(format!("script run failed: {error}")),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ui.label("Parser + executor slice; output writes to disk.");
|
ui.label("Parser + executor slice; output writes to disk.");
|
||||||
});
|
});
|
||||||
if let Some(report) = self.data.last_script_run.as_ref() {
|
if let Some(report) = self.data.last_script_run.as_ref() {
|
||||||
ui.label(format!("Last run wrote {} output(s)", report.outputs.len()));
|
ui.label(format!("Last run wrote {} output(s)", report.outputs.len()));
|
||||||
|
} else {
|
||||||
|
ui.label(self.shell.placeholder_label());
|
||||||
|
}
|
||||||
|
changed
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.separator();
|
fn path_controls(&mut self, ui: &mut egui::Ui, action_note: &mut Option<String>) -> bool {
|
||||||
|
let mut changed = false;
|
||||||
ui.label("Path tools");
|
ui.label("Path tools");
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
let path_target = self
|
let path_target = self
|
||||||
@@ -194,96 +315,95 @@ impl eframe::App for OpenVistaProApp {
|
|||||||
.unwrap_or("No path target selected");
|
.unwrap_or("No path target selected");
|
||||||
ui.monospace(path_target);
|
ui.monospace(path_target);
|
||||||
if ui.button("Make path").clicked() {
|
if ui.button("Make path").clicked() {
|
||||||
self.data.make_path();
|
let path = self.data.make_path();
|
||||||
changed = true;
|
changed = true;
|
||||||
|
*action_note = Some(format!(
|
||||||
|
"generated {}",
|
||||||
|
self.data
|
||||||
|
.path_target
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| format!("{} keyframes", path.keyframes().len()))
|
||||||
|
));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if let Some(path) = self.data.generated_path.as_ref() {
|
if let Some(path) = self.data.generated_path.as_ref() {
|
||||||
ui.label(format!("Generated path: {}", path.summary()));
|
ui.label(format!(
|
||||||
|
"Generated path: {} keyframes",
|
||||||
|
path.keyframes().len()
|
||||||
|
));
|
||||||
} else {
|
} else {
|
||||||
ui.label("Path generation is now wired to the backend demo path builder.");
|
ui.label(self.shell.placeholder_label());
|
||||||
}
|
}
|
||||||
});
|
changed
|
||||||
|
|
||||||
if changed || self.texture.is_none() {
|
|
||||||
self.rebuild_texture(ctx);
|
|
||||||
ctx.request_repaint();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let snapshot = self.data.ui_snapshot();
|
fn project_controls(&mut self, ui: &mut egui::Ui, action_note: &mut Option<String>) -> bool {
|
||||||
|
let mut changed = false;
|
||||||
egui::TopBottomPanel::top("project_bar").show(ctx, |ui| {
|
ui.label("Scene file");
|
||||||
ui.horizontal_wrapped(|ui| {
|
let scene_path = self
|
||||||
ui.label(snapshot.scene_file_label.as_str());
|
|
||||||
match snapshot.scene_file_path.as_deref() {
|
|
||||||
Some(path) => ui.monospace(path),
|
|
||||||
None => ui.weak("No scene file loaded"),
|
|
||||||
};
|
|
||||||
ui.separator();
|
|
||||||
if ui.button("New").clicked() {
|
|
||||||
let path = self
|
|
||||||
.data
|
.data
|
||||||
.loaded_scene_path
|
.loaded_scene_path
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(Self::default_scene_path);
|
.unwrap_or_else(Self::default_scene_path);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.monospace(scene_path.as_str());
|
||||||
|
if ui.button("New").clicked() {
|
||||||
self.data.reset_scene();
|
self.data.reset_scene();
|
||||||
self.data.loaded_scene_path = Some(path);
|
self.data.loaded_scene_path = Some(scene_path.clone());
|
||||||
changed = true;
|
changed = true;
|
||||||
|
*action_note = Some(format!("reset scene and kept {scene_path}"));
|
||||||
}
|
}
|
||||||
if ui.button("Open…").clicked() {
|
if ui.button("Open…").clicked() {
|
||||||
let path = self
|
let path = std::path::Path::new(&scene_path);
|
||||||
.data
|
match self.data.open_scene(path) {
|
||||||
.loaded_scene_path
|
Ok(()) => {
|
||||||
.clone()
|
changed = true;
|
||||||
.unwrap_or_else(Self::default_scene_path);
|
*action_note = Some(format!("opened scene from {scene_path}"));
|
||||||
match self.data.open_scene(Path::new(&path)) {
|
}
|
||||||
Ok(()) => changed = true,
|
Err(error) => *action_note = Some(format!("open failed: {error}")),
|
||||||
Err(error) => action_note = Some(format!("open failed: {error}")),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ui.button("Save").clicked() {
|
if ui.button("Save").clicked() {
|
||||||
let path = self
|
let path = std::path::Path::new(&scene_path);
|
||||||
.data
|
match self.data.save_scene(path) {
|
||||||
.loaded_scene_path
|
Ok(()) => *action_note = Some(format!("saved scene to {scene_path}")),
|
||||||
.clone()
|
Err(error) => *action_note = Some(format!("save failed: {error}")),
|
||||||
.unwrap_or_else(Self::default_scene_path);
|
|
||||||
match self.data.save_scene(Path::new(&path)) {
|
|
||||||
Ok(()) => action_note = Some(format!("saved scene to {path}")),
|
|
||||||
Err(error) => action_note = Some(format!("save failed: {error}")),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
ui.label(self.shell.placeholder_label());
|
||||||
|
changed
|
||||||
|
}
|
||||||
|
|
||||||
egui::TopBottomPanel::bottom("status_bar").show(ctx, |ui| {
|
/// Right panel: durable inspector summarising current scene state.
|
||||||
ui.horizontal_wrapped(|ui| {
|
fn inspector_panel(&self, ctx: &egui::Context) {
|
||||||
ui.label(snapshot.status_line.as_str());
|
egui::SidePanel::right("inspector_panel")
|
||||||
|
.resizable(true)
|
||||||
|
.default_width(220.0)
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
ui.heading("Inspector");
|
||||||
ui.separator();
|
ui.separator();
|
||||||
ui.monospace(format!(
|
let info = self.shell.active_info();
|
||||||
"scripts: {} cmd / {} render / {} import",
|
ui.label(format!("Section: {}", info.title));
|
||||||
snapshot.script_preview.command_count,
|
ui.label(info.summary);
|
||||||
snapshot.script_preview.render_commands,
|
|
||||||
snapshot.script_preview.import_commands,
|
|
||||||
));
|
|
||||||
if let Some(error) = snapshot.script_preview.error.as_deref() {
|
|
||||||
ui.colored_label(ui.visuals().error_fg_color, error);
|
|
||||||
}
|
|
||||||
if let Some(note) = action_note.as_deref() {
|
|
||||||
ui.separator();
|
ui.separator();
|
||||||
ui.label(note);
|
ui.label(format!("Terrain: {:?}", self.data.terrain_preset));
|
||||||
|
ui.label(format!("Renderer: {:?}", self.data.renderer_mode));
|
||||||
|
let (width, height) = self.data.preview_size;
|
||||||
|
ui.label(format!("Preview: {width} x {height}"));
|
||||||
|
ui.label(format!("Water level: {:.2}", self.data.scene.water_level));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
|
/// Central viewport: shows the CPU preview, with a placeholder banner for
|
||||||
|
/// sections whose dedicated surface is not built yet.
|
||||||
|
fn viewport_panel(&self, ctx: &egui::Context) {
|
||||||
egui::CentralPanel::default().show(ctx, |ui| {
|
egui::CentralPanel::default().show(ctx, |ui| {
|
||||||
ui.vertical_centered(|ui| {
|
ui.heading(format!("{} viewport", self.shell.section_title()));
|
||||||
ui.heading("Preview");
|
if section_is_placeholder(self.shell.active_section) {
|
||||||
ui.label(format!(
|
ui.label(self.shell.placeholder_label());
|
||||||
"{} · {}",
|
|
||||||
snapshot.terrain_preset_label, snapshot.renderer_mode_label
|
|
||||||
));
|
|
||||||
});
|
|
||||||
ui.separator();
|
ui.separator();
|
||||||
|
}
|
||||||
if let Some(texture) = &self.texture {
|
if let Some(texture) = &self.texture {
|
||||||
ui.image((texture.id(), texture.size_vec2()));
|
ui.image((texture.id(), texture.size_vec2()));
|
||||||
} else {
|
} else {
|
||||||
@@ -291,6 +411,52 @@ impl eframe::App for OpenVistaProApp {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bottom panel: durable status bar.
|
||||||
|
fn status_panel(&self, ctx: &egui::Context, action_note: Option<&str>) {
|
||||||
|
egui::TopBottomPanel::bottom("status_panel").show(ctx, |ui| {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Ready");
|
||||||
|
ui.separator();
|
||||||
|
ui.label(format!("Active: {}", self.shell.section_title()));
|
||||||
|
ui.separator();
|
||||||
|
let scene_label = match &self.data.loaded_scene_path {
|
||||||
|
Some(path) => format!("Scene: {path}"),
|
||||||
|
None => "Scene: unsaved".to_owned(),
|
||||||
|
};
|
||||||
|
ui.label(scene_label);
|
||||||
|
if let Some(note) = action_note {
|
||||||
|
ui.separator();
|
||||||
|
ui.label(note);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl eframe::App for OpenVistaProApp {
|
||||||
|
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||||
|
let mut action_note: Option<String> = None;
|
||||||
|
self.command_bar(ctx);
|
||||||
|
let changed = self.controls_panel(ctx, &mut action_note);
|
||||||
|
self.inspector_panel(ctx);
|
||||||
|
self.status_panel(ctx, action_note.as_deref());
|
||||||
|
|
||||||
|
if changed || self.texture.is_none() {
|
||||||
|
self.rebuild_texture(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.viewport_panel(ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sections whose dedicated workspace is not built yet — they render a
|
||||||
|
/// placeholder banner over the shared preview rather than being hidden.
|
||||||
|
fn section_is_placeholder(section: ShellSection) -> bool {
|
||||||
|
matches!(
|
||||||
|
section,
|
||||||
|
ShellSection::Import | ShellSection::Script | ShellSection::Path | ShellSection::Project
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn vec3_controls(ui: &mut egui::Ui, label: &str, value: &mut Vec3) -> bool {
|
fn vec3_controls(ui: &mut egui::Ui, label: &str, value: &mut Vec3) -> bool {
|
||||||
|
|||||||
+6
-1
@@ -252,7 +252,12 @@ impl AppData {
|
|||||||
|
|
||||||
pub fn make_path(&mut self) -> CameraPath {
|
pub fn make_path(&mut self) -> CameraPath {
|
||||||
let path = build_demo_path(&self.scene);
|
let path = build_demo_path(&self.scene);
|
||||||
self.path_target = Some(path.summary());
|
self.path_target = Some(format!(
|
||||||
|
"{} keyframes · {:.1}s → {:.1}s",
|
||||||
|
path.keyframes().len(),
|
||||||
|
path.start_time(),
|
||||||
|
path.end_time()
|
||||||
|
));
|
||||||
self.generated_path = Some(path.clone());
|
self.generated_path = Some(path.clone());
|
||||||
path
|
path
|
||||||
}
|
}
|
||||||
|
|||||||
+180
-11
@@ -6,6 +6,7 @@ use image::ImageError;
|
|||||||
use crate::render::{demo_camera_for, render_perspective_to_path, render_top_down_to_path};
|
use crate::render::{demo_camera_for, render_perspective_to_path, render_top_down_to_path};
|
||||||
use crate::scene::Scene;
|
use crate::scene::Scene;
|
||||||
use crate::scene_file::{self, SceneFileError};
|
use crate::scene_file::{self, SceneFileError};
|
||||||
|
use crate::script_exec::{self, ScriptError};
|
||||||
use crate::terrain::{HeightGrid, TerrainError};
|
use crate::terrain::{HeightGrid, TerrainError};
|
||||||
|
|
||||||
const HILL_PEAK_HEIGHT: f32 = 10.0;
|
const HILL_PEAK_HEIGHT: f32 = 10.0;
|
||||||
@@ -29,6 +30,8 @@ pub enum Command {
|
|||||||
Render(RenderArgs),
|
Render(RenderArgs),
|
||||||
/// Work with OpenVistaPro scene files.
|
/// Work with OpenVistaPro scene files.
|
||||||
Scene(SceneArgs),
|
Scene(SceneArgs),
|
||||||
|
/// Work with OpenVistaPro render scripts.
|
||||||
|
Script(ScriptArgs),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Args)]
|
#[derive(Debug, Clone, Args)]
|
||||||
@@ -73,6 +76,25 @@ pub struct ExportArgs {
|
|||||||
pub output: PathBuf,
|
pub output: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Args)]
|
||||||
|
pub struct ScriptArgs {
|
||||||
|
#[command(subcommand)]
|
||||||
|
pub action: ScriptAction,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Subcommand)]
|
||||||
|
pub enum ScriptAction {
|
||||||
|
/// Parse and execute a script file, writing each `render output` to disk.
|
||||||
|
Run(ScriptRunArgs),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Args)]
|
||||||
|
pub struct ScriptRunArgs {
|
||||||
|
/// Path to the OpenVistaPro script file to execute.
|
||||||
|
#[arg(long)]
|
||||||
|
pub input: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
|
||||||
pub enum Preset {
|
pub enum Preset {
|
||||||
Plane,
|
Plane,
|
||||||
@@ -84,6 +106,7 @@ pub enum CliError {
|
|||||||
Terrain(TerrainError),
|
Terrain(TerrainError),
|
||||||
Image(ImageError),
|
Image(ImageError),
|
||||||
SceneFile(SceneFileError),
|
SceneFile(SceneFileError),
|
||||||
|
Script(ScriptError),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for CliError {
|
impl std::fmt::Display for CliError {
|
||||||
@@ -92,6 +115,7 @@ impl std::fmt::Display for CliError {
|
|||||||
CliError::Terrain(e) => write!(f, "terrain error: {e}"),
|
CliError::Terrain(e) => write!(f, "terrain error: {e}"),
|
||||||
CliError::Image(e) => write!(f, "image error: {e}"),
|
CliError::Image(e) => write!(f, "image error: {e}"),
|
||||||
CliError::SceneFile(e) => write!(f, "scene file error: {e}"),
|
CliError::SceneFile(e) => write!(f, "scene file error: {e}"),
|
||||||
|
CliError::Script(e) => write!(f, "{e}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -116,26 +140,80 @@ impl From<SceneFileError> for CliError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<ScriptError> for CliError {
|
||||||
|
fn from(e: ScriptError) -> Self {
|
||||||
|
CliError::Script(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn supported_presets() -> &'static [&'static str] {
|
pub fn supported_presets() -> &'static [&'static str] {
|
||||||
&["plane", "hill"]
|
&["plane", "hill"]
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn supported_importers() -> &'static [&'static str] {
|
pub fn supported_importers() -> &'static [&'static str] {
|
||||||
#[cfg(all(feature = "hgt", feature = "import-geotiff"))]
|
#[cfg(all(
|
||||||
|
feature = "hgt",
|
||||||
|
feature = "ascii-grid-import",
|
||||||
|
feature = "import-geotiff"
|
||||||
|
))]
|
||||||
{
|
{
|
||||||
&["hgt", "geotiff"]
|
&["heightmap", "hgt", "esri-ascii-grid", "geotiff"]
|
||||||
}
|
}
|
||||||
#[cfg(all(feature = "hgt", not(feature = "import-geotiff")))]
|
#[cfg(all(
|
||||||
|
feature = "hgt",
|
||||||
|
feature = "ascii-grid-import",
|
||||||
|
not(feature = "import-geotiff")
|
||||||
|
))]
|
||||||
{
|
{
|
||||||
&["hgt"]
|
&["heightmap", "hgt", "esri-ascii-grid"]
|
||||||
}
|
}
|
||||||
#[cfg(all(not(feature = "hgt"), feature = "import-geotiff"))]
|
#[cfg(all(
|
||||||
|
feature = "hgt",
|
||||||
|
not(feature = "ascii-grid-import"),
|
||||||
|
feature = "import-geotiff"
|
||||||
|
))]
|
||||||
{
|
{
|
||||||
&["geotiff"]
|
&["heightmap", "hgt", "geotiff"]
|
||||||
}
|
}
|
||||||
#[cfg(all(not(feature = "hgt"), not(feature = "import-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",
|
||||||
|
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"),
|
||||||
|
feature = "import-geotiff"
|
||||||
|
))]
|
||||||
|
{
|
||||||
|
&["heightmap", "geotiff"]
|
||||||
|
}
|
||||||
|
#[cfg(all(
|
||||||
|
not(feature = "hgt"),
|
||||||
|
not(feature = "ascii-grid-import"),
|
||||||
|
not(feature = "import-geotiff")
|
||||||
|
))]
|
||||||
|
{
|
||||||
|
&["heightmap"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,6 +263,15 @@ pub fn execute(cli: Cli) -> Result<(), CliError> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
Command::Script(args) => match args.action {
|
||||||
|
ScriptAction::Run(run) => {
|
||||||
|
let report = script_exec::run_script_file(&run.input)?;
|
||||||
|
for output in &report.outputs {
|
||||||
|
println!("rendered {}", output.display());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,9 +359,14 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[cfg(all(not(feature = "hgt"), not(feature = "import-geotiff")))]
|
fn supported_importers_lists_heightmap() {
|
||||||
fn supported_importers_is_empty_for_now() {
|
assert!(supported_importers().contains(&"heightmap"));
|
||||||
assert!(supported_importers().is_empty());
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "ascii-grid-import")]
|
||||||
|
#[test]
|
||||||
|
fn supported_importers_lists_esri_ascii_grid_with_feature() {
|
||||||
|
assert!(supported_importers().contains(&"esri-ascii-grid"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -691,6 +783,83 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_script_run_subcommand() {
|
||||||
|
let cli = Cli::try_parse_from(["openvistapro", "script", "run", "--input", "demo.ovps"])
|
||||||
|
.expect("script run should parse");
|
||||||
|
match cli.command {
|
||||||
|
Command::Script(ScriptArgs {
|
||||||
|
action: ScriptAction::Run(ScriptRunArgs { input }),
|
||||||
|
}) => {
|
||||||
|
assert_eq!(input, PathBuf::from("demo.ovps"));
|
||||||
|
}
|
||||||
|
_ => panic!("expected script run subcommand"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn script_run_requires_input_flag() {
|
||||||
|
let err = Cli::try_parse_from(["openvistapro", "script", "run"]);
|
||||||
|
assert!(err.is_err(), "script run must require --input");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn temp_script_dir(tag: &str) -> PathBuf {
|
||||||
|
let mut dir = std::env::temp_dir();
|
||||||
|
dir.push(format!(
|
||||||
|
"openvistapro-cli-script-{}-{}",
|
||||||
|
tag,
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(&dir).expect("create temp script dir");
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_script_run_renders_png_output() {
|
||||||
|
let dir = temp_script_dir("run");
|
||||||
|
let script_path = dir.join("run.ovps");
|
||||||
|
std::fs::write(
|
||||||
|
&script_path,
|
||||||
|
"use preset hill\nset thresholds water=1.0 tree=4.0 snow=7.0\nrender output \"cli-demo.png\"",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let cli = Cli::try_parse_from([
|
||||||
|
"openvistapro",
|
||||||
|
"script",
|
||||||
|
"run",
|
||||||
|
"--input",
|
||||||
|
script_path.to_str().unwrap(),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
execute(cli).expect("script run should succeed");
|
||||||
|
let bytes = std::fs::read(dir.join("cli-demo.png")).expect("rendered png should exist");
|
||||||
|
assert!(bytes.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]));
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_script_run_reports_missing_file() {
|
||||||
|
let missing = std::env::temp_dir().join(format!(
|
||||||
|
"openvistapro-cli-missing-script-{}.ovps",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
let _ = std::fs::remove_file(&missing);
|
||||||
|
let cli = Cli::try_parse_from([
|
||||||
|
"openvistapro",
|
||||||
|
"script",
|
||||||
|
"run",
|
||||||
|
"--input",
|
||||||
|
missing.to_str().unwrap(),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
let err = execute(cli).expect_err("missing script file should error");
|
||||||
|
assert!(
|
||||||
|
matches!(err, CliError::Script(_)),
|
||||||
|
"expected CliError::Script, got: {err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn execute_render_rejects_zero_dimensions() {
|
fn execute_render_rejects_zero_dimensions() {
|
||||||
let path = temp_output_path("zero");
|
let path = temp_output_path("zero");
|
||||||
|
|||||||
+227
-1
@@ -34,10 +34,37 @@
|
|||||||
//!
|
//!
|
||||||
//! # 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.
|
||||||
|
//!
|
||||||
//! # The `geotiff` format
|
//! # The `geotiff` format
|
||||||
//!
|
//!
|
||||||
//! With the optional `import-geotiff` Cargo feature enabled, [`geotiff::parse_geotiff_bytes`]
|
//! With the optional `import-geotiff` Cargo feature enabled, [`geotiff::parse_geotiff_bytes`]
|
||||||
@@ -361,6 +388,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::*;
|
||||||
@@ -652,4 +822,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)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,3 +12,4 @@ pub mod script;
|
|||||||
pub mod script_exec;
|
pub mod script_exec;
|
||||||
pub mod terrain;
|
pub mod terrain;
|
||||||
pub mod terrain_gen;
|
pub mod terrain_gen;
|
||||||
|
pub mod ui_shell;
|
||||||
|
|||||||
+357
-41
@@ -1,59 +1,172 @@
|
|||||||
|
//! Deterministic camera animation paths.
|
||||||
|
//!
|
||||||
|
//! A [`CameraPath`] is an ordered list of [`CameraKeyframe`]s, each binding a
|
||||||
|
//! [`Camera`] to a moment in time. Sampling the path at an arbitrary time
|
||||||
|
//! produces a smoothly interpolated camera, which lets callers fly the scene
|
||||||
|
//! camera along a designed trajectory.
|
||||||
|
//!
|
||||||
|
//! # Interpolation
|
||||||
|
//!
|
||||||
|
//! `position` and `target` are interpolated with a **uniform Catmull-Rom
|
||||||
|
//! spline**. Catmull-Rom is an interpolating cubic spline: it passes exactly
|
||||||
|
//! through every keyframe and is C1-continuous, so the motion has no visible
|
||||||
|
//! kinks at keyframes. Each segment between keyframe `i` and `i + 1` is
|
||||||
|
//! evaluated with a local parameter `u = (t - t_i) / (t_{i+1} - t_i)` in
|
||||||
|
//! `[0, 1]` — that is, the spline runs over **time-normalized segments**, so
|
||||||
|
//! keyframes may be spaced unevenly in time.
|
||||||
|
//!
|
||||||
|
//! Catmull-Rom needs two neighbouring control points per segment. At the path
|
||||||
|
//! boundaries the missing outer control point is supplied by **duplicating the
|
||||||
|
//! endpoint keyframe** (a standard "clamped endpoint" boundary condition).
|
||||||
|
//!
|
||||||
|
//! `fov_degrees` is interpolated **linearly** rather than with the spline: a
|
||||||
|
//! cubic spline can overshoot, and an overshoot on field of view could produce
|
||||||
|
//! a non-positive or absurdly wide angle. A linear ramp keeps the FOV strictly
|
||||||
|
//! within the bracketing keyframe values.
|
||||||
|
//!
|
||||||
|
//! Sampling is fully deterministic: it performs only `f32` arithmetic with no
|
||||||
|
//! I/O, randomness, or global state, so the same path and time always yield a
|
||||||
|
//! bit-identical [`Camera`].
|
||||||
|
|
||||||
use crate::scene::{Camera, Scene, Vec3};
|
use crate::scene::{Camera, Scene, Vec3};
|
||||||
|
|
||||||
/// A single camera pose in a generated camera path.
|
/// A single camera pose pinned to a point in time on a [`CameraPath`].
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
pub struct CameraKeyframe {
|
pub struct CameraKeyframe {
|
||||||
|
/// The time at which the camera takes this pose. Times along a path must
|
||||||
|
/// be strictly increasing.
|
||||||
pub time: f32,
|
pub time: f32,
|
||||||
|
/// The camera pose at [`CameraKeyframe::time`].
|
||||||
pub camera: Camera,
|
pub camera: Camera,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CameraKeyframe {
|
impl CameraKeyframe {
|
||||||
|
/// Create a keyframe binding `camera` to `time`.
|
||||||
pub const fn new(time: f32, camera: Camera) -> Self {
|
pub const fn new(time: f32, camera: Camera) -> Self {
|
||||||
Self { time, camera }
|
CameraKeyframe { time, camera }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A lightweight generated camera path used by the shell's Make Path action.
|
/// An error produced while building a [`CameraPath`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum PathError {
|
||||||
|
/// `try_new` was called with no keyframes.
|
||||||
|
Empty,
|
||||||
|
/// Keyframe times were not strictly increasing. `index` is the position of
|
||||||
|
/// the offending keyframe — its time is not greater than its predecessor's.
|
||||||
|
NonMonotonicTime { index: usize },
|
||||||
|
/// A keyframe time was not a finite number. `index` is its position.
|
||||||
|
NonFiniteTime { index: usize },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for PathError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
PathError::Empty => write!(f, "camera path must have at least one keyframe"),
|
||||||
|
PathError::NonMonotonicTime { index } => write!(
|
||||||
|
f,
|
||||||
|
"keyframe time at index {index} is not strictly greater than the previous keyframe"
|
||||||
|
),
|
||||||
|
PathError::NonFiniteTime { index } => {
|
||||||
|
write!(f, "keyframe time at index {index} is not a finite number")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for PathError {}
|
||||||
|
|
||||||
|
/// A time-ordered sequence of camera keyframes that can be sampled to obtain a
|
||||||
|
/// smoothly interpolated [`Camera`].
|
||||||
|
///
|
||||||
|
/// Construct one with [`CameraPath::try_new`]; the constructor validates that
|
||||||
|
/// keyframe times are finite and strictly increasing.
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct CameraPath {
|
pub struct CameraPath {
|
||||||
keyframes: Vec<CameraKeyframe>,
|
keyframes: Vec<CameraKeyframe>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CameraPath {
|
impl CameraPath {
|
||||||
pub fn new(keyframes: Vec<CameraKeyframe>) -> Self {
|
/// Build a path from `keyframes`.
|
||||||
Self { keyframes }
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`PathError::Empty`] if `keyframes` is empty,
|
||||||
|
/// [`PathError::NonFiniteTime`] if any keyframe time is NaN or infinite,
|
||||||
|
/// and [`PathError::NonMonotonicTime`] if the times are not strictly
|
||||||
|
/// increasing.
|
||||||
|
pub fn try_new(keyframes: Vec<CameraKeyframe>) -> Result<Self, PathError> {
|
||||||
|
if keyframes.is_empty() {
|
||||||
|
return Err(PathError::Empty);
|
||||||
|
}
|
||||||
|
for (index, kf) in keyframes.iter().enumerate() {
|
||||||
|
if !kf.time.is_finite() {
|
||||||
|
return Err(PathError::NonFiniteTime { index });
|
||||||
|
}
|
||||||
|
if index > 0 && kf.time <= keyframes[index - 1].time {
|
||||||
|
return Err(PathError::NonMonotonicTime { index });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(CameraPath { keyframes })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The keyframes of this path, in time order.
|
||||||
pub fn keyframes(&self) -> &[CameraKeyframe] {
|
pub fn keyframes(&self) -> &[CameraKeyframe] {
|
||||||
&self.keyframes
|
&self.keyframes
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn summary(&self) -> String {
|
/// The time of the first keyframe.
|
||||||
match (self.keyframes.first(), self.keyframes.last()) {
|
pub fn start_time(&self) -> f32 {
|
||||||
(Some(first), Some(last)) => format!(
|
self.keyframes[0].time
|
||||||
"{} keyframes · {:.1}s → {:.1}s",
|
}
|
||||||
self.keyframes.len(),
|
|
||||||
first.time,
|
/// The time of the last keyframe.
|
||||||
last.time
|
pub fn end_time(&self) -> f32 {
|
||||||
),
|
self.keyframes[self.keyframes.len() - 1].time
|
||||||
_ => "empty camera path".to_string(),
|
}
|
||||||
|
|
||||||
|
/// Sample the camera at `time`.
|
||||||
|
///
|
||||||
|
/// Times outside `[start_time, end_time]` are clamped to the nearest
|
||||||
|
/// endpoint keyframe. Sampling exactly at a keyframe time returns that
|
||||||
|
/// keyframe's camera unchanged.
|
||||||
|
pub fn sample(&self, time: f32) -> Camera {
|
||||||
|
let kf = &self.keyframes;
|
||||||
|
|
||||||
|
// Endpoint clamping: a path with a single keyframe is constant, and any
|
||||||
|
// time at or outside the boundaries snaps to the boundary keyframe.
|
||||||
|
if kf.len() == 1 || time <= self.start_time() {
|
||||||
|
return kf[0].camera;
|
||||||
|
}
|
||||||
|
if time >= self.end_time() {
|
||||||
|
return kf[kf.len() - 1].camera;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Locate the segment [i, i + 1] that brackets `time`.
|
||||||
|
let mut i = 0;
|
||||||
|
while i + 1 < kf.len() && kf[i + 1].time <= time {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (t1, t2) = (kf[i].time, kf[i + 1].time);
|
||||||
|
let u = (time - t1) / (t2 - t1);
|
||||||
|
|
||||||
|
// Catmull-Rom control points, duplicating endpoints at the boundaries.
|
||||||
|
let p0 = kf[i.saturating_sub(1)].camera;
|
||||||
|
let p1 = kf[i].camera;
|
||||||
|
let p2 = kf[i + 1].camera;
|
||||||
|
let p3 = kf[(i + 2).min(kf.len() - 1)].camera;
|
||||||
|
|
||||||
|
Camera {
|
||||||
|
position: catmull_rom_vec3(p0.position, p1.position, p2.position, p3.position, u),
|
||||||
|
target: catmull_rom_vec3(p0.target, p1.target, p2.target, p3.target, u),
|
||||||
|
fov_degrees: lerp(p1.fov_degrees, p2.fov_degrees, u),
|
||||||
|
..p1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn vec3_add(a: Vec3, b: Vec3) -> Vec3 {
|
/// Build a small orbit-style demo path around the current scene target.
|
||||||
Vec3::new(a.x + b.x, a.y + b.y, a.z + b.z)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn vec3_from_angle(radius: f32, height: f32, angle_radians: f32) -> Vec3 {
|
|
||||||
Vec3::new(
|
|
||||||
radius * angle_radians.cos(),
|
|
||||||
height,
|
|
||||||
radius * angle_radians.sin(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build a small orbit-style path around the current scene target.
|
|
||||||
pub fn build_demo_path(scene: &Scene) -> CameraPath {
|
pub fn build_demo_path(scene: &Scene) -> CameraPath {
|
||||||
let focus = scene.camera.target;
|
let focus = scene.camera.target;
|
||||||
let eye = scene.camera.position;
|
let eye = scene.camera.position;
|
||||||
@@ -66,7 +179,7 @@ pub fn build_demo_path(scene: &Scene) -> CameraPath {
|
|||||||
CameraKeyframe::new(
|
CameraKeyframe::new(
|
||||||
0.0,
|
0.0,
|
||||||
Camera {
|
Camera {
|
||||||
position: vec3_add(focus, vec3_from_angle(radius, base_y, -0.6)),
|
position: Vec3::new(focus.x + radius * 0.8, base_y, focus.z - radius * 0.2),
|
||||||
target: focus,
|
target: focus,
|
||||||
..scene.camera
|
..scene.camera
|
||||||
},
|
},
|
||||||
@@ -74,7 +187,7 @@ pub fn build_demo_path(scene: &Scene) -> CameraPath {
|
|||||||
CameraKeyframe::new(
|
CameraKeyframe::new(
|
||||||
3.0,
|
3.0,
|
||||||
Camera {
|
Camera {
|
||||||
position: vec3_add(focus, vec3_from_angle(radius * 1.1, base_y + 8.0, 0.0)),
|
position: Vec3::new(focus.x, base_y + 8.0, focus.z + radius * 0.9),
|
||||||
target: focus,
|
target: focus,
|
||||||
..scene.camera
|
..scene.camera
|
||||||
},
|
},
|
||||||
@@ -82,32 +195,235 @@ pub fn build_demo_path(scene: &Scene) -> CameraPath {
|
|||||||
CameraKeyframe::new(
|
CameraKeyframe::new(
|
||||||
6.0,
|
6.0,
|
||||||
Camera {
|
Camera {
|
||||||
position: vec3_add(focus, vec3_from_angle(radius, base_y, 0.6)),
|
position: Vec3::new(focus.x - radius * 0.8, base_y, focus.z - radius * 0.2),
|
||||||
target: focus,
|
target: focus,
|
||||||
..scene.camera
|
..scene.camera
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
CameraPath::new(keyframes)
|
CameraPath::try_new(keyframes).expect("demo path must be valid")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Linear interpolation from `a` to `b` by `u` in `[0, 1]`.
|
||||||
|
fn lerp(a: f32, b: f32, u: f32) -> f32 {
|
||||||
|
a + (b - a) * u
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Uniform Catmull-Rom basis evaluated for one scalar component.
|
||||||
|
///
|
||||||
|
/// `p1` and `p2` bracket the segment; `p0` and `p3` are the neighbouring
|
||||||
|
/// control points. `u` is the local segment parameter in `[0, 1]`.
|
||||||
|
fn catmull_rom(p0: f32, p1: f32, p2: f32, p3: f32, u: f32) -> f32 {
|
||||||
|
let u2 = u * u;
|
||||||
|
let u3 = u2 * u;
|
||||||
|
0.5 * ((2.0 * p1)
|
||||||
|
+ (-p0 + p2) * u
|
||||||
|
+ (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * u2
|
||||||
|
+ (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * u3)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply [`catmull_rom`] component-wise to a [`Vec3`].
|
||||||
|
fn catmull_rom_vec3(p0: Vec3, p1: Vec3, p2: Vec3, p3: Vec3, u: f32) -> Vec3 {
|
||||||
|
Vec3::new(
|
||||||
|
catmull_rom(p0.x, p1.x, p2.x, p3.x, u),
|
||||||
|
catmull_rom(p0.y, p1.y, p2.y, p3.y, u),
|
||||||
|
catmull_rom(p0.z, p1.z, p2.z, p3.z, u),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
/// Build a keyframe with the given time, position, target and FOV.
|
||||||
fn demo_path_builds_three_keyframes() {
|
fn kf(time: f32, pos: [f32; 3], target: [f32; 3], fov: f32) -> CameraKeyframe {
|
||||||
let path = build_demo_path(&Scene::default());
|
CameraKeyframe::new(
|
||||||
assert_eq!(path.keyframes().len(), 3);
|
time,
|
||||||
assert!(path.summary().contains("3 keyframes"));
|
Camera {
|
||||||
|
position: Vec3::new(pos[0], pos[1], pos[2]),
|
||||||
|
target: Vec3::new(target[0], target[1], target[2]),
|
||||||
|
fov_degrees: fov,
|
||||||
|
..Camera::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn keyframe_constructor_preserves_fields() {
|
fn try_new_rejects_empty_keyframes() {
|
||||||
let camera = Camera::default();
|
assert_eq!(CameraPath::try_new(vec![]), Err(PathError::Empty));
|
||||||
let keyframe = CameraKeyframe::new(1.25, camera);
|
}
|
||||||
assert_eq!(keyframe.time, 1.25);
|
|
||||||
assert_eq!(keyframe.camera, camera);
|
#[test]
|
||||||
|
fn try_new_rejects_non_monotonic_time() {
|
||||||
|
let frames = vec![
|
||||||
|
kf(0.0, [0.0, 0.0, 0.0], [0.0, 0.0, 0.0], 60.0),
|
||||||
|
kf(2.0, [1.0, 0.0, 0.0], [0.0, 0.0, 0.0], 60.0),
|
||||||
|
kf(1.0, [2.0, 0.0, 0.0], [0.0, 0.0, 0.0], 60.0),
|
||||||
|
];
|
||||||
|
assert_eq!(
|
||||||
|
CameraPath::try_new(frames),
|
||||||
|
Err(PathError::NonMonotonicTime { index: 2 })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn try_new_rejects_equal_adjacent_times() {
|
||||||
|
let frames = vec![
|
||||||
|
kf(0.0, [0.0, 0.0, 0.0], [0.0, 0.0, 0.0], 60.0),
|
||||||
|
kf(1.0, [1.0, 0.0, 0.0], [0.0, 0.0, 0.0], 60.0),
|
||||||
|
kf(1.0, [2.0, 0.0, 0.0], [0.0, 0.0, 0.0], 60.0),
|
||||||
|
];
|
||||||
|
assert_eq!(
|
||||||
|
CameraPath::try_new(frames),
|
||||||
|
Err(PathError::NonMonotonicTime { index: 2 })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn try_new_rejects_non_finite_time() {
|
||||||
|
let frames = vec![kf(f32::NAN, [0.0, 0.0, 0.0], [0.0, 0.0, 0.0], 60.0)];
|
||||||
|
assert_eq!(
|
||||||
|
CameraPath::try_new(frames),
|
||||||
|
Err(PathError::NonFiniteTime { index: 0 })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn try_new_accepts_strictly_increasing_times() {
|
||||||
|
let frames = vec![
|
||||||
|
kf(0.0, [0.0, 0.0, 0.0], [0.0, 0.0, 0.0], 60.0),
|
||||||
|
kf(1.5, [1.0, 0.0, 0.0], [0.0, 0.0, 0.0], 60.0),
|
||||||
|
kf(4.0, [2.0, 0.0, 0.0], [0.0, 0.0, 0.0], 60.0),
|
||||||
|
];
|
||||||
|
let path = CameraPath::try_new(frames).expect("monotonic times must be accepted");
|
||||||
|
assert_eq!(path.start_time(), 0.0);
|
||||||
|
assert_eq!(path.end_time(), 4.0);
|
||||||
|
assert_eq!(path.keyframes().len(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sample_returns_endpoints_exactly() {
|
||||||
|
let first = kf(0.0, [0.0, 10.0, 0.0], [1.0, 0.0, 0.0], 50.0);
|
||||||
|
let last = kf(3.0, [9.0, 4.0, 2.0], [0.0, 1.0, 0.0], 70.0);
|
||||||
|
let path = CameraPath::try_new(vec![
|
||||||
|
first,
|
||||||
|
kf(1.0, [3.0, 8.0, 1.0], [0.5, 0.5, 0.0], 55.0),
|
||||||
|
kf(2.0, [6.0, 6.0, 1.5], [0.2, 0.8, 0.0], 62.0),
|
||||||
|
last,
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(path.sample(0.0), first.camera);
|
||||||
|
assert_eq!(path.sample(3.0), last.camera);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sample_clamps_outside_the_time_range() {
|
||||||
|
let first = kf(1.0, [0.0, 10.0, 0.0], [1.0, 0.0, 0.0], 50.0);
|
||||||
|
let last = kf(4.0, [9.0, 4.0, 2.0], [0.0, 1.0, 0.0], 70.0);
|
||||||
|
let path = CameraPath::try_new(vec![first, last]).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(path.sample(-100.0), first.camera);
|
||||||
|
assert_eq!(path.sample(0.5), first.camera);
|
||||||
|
assert_eq!(path.sample(4.0), last.camera);
|
||||||
|
assert_eq!(path.sample(999.0), last.camera);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sample_passes_through_interior_keyframes() {
|
||||||
|
// Catmull-Rom is interpolating: sampling at an interior keyframe time
|
||||||
|
// must return that keyframe's camera unchanged.
|
||||||
|
let interior = kf(2.0, [5.0, 7.0, -3.0], [1.0, 2.0, 3.0], 48.0);
|
||||||
|
let path = CameraPath::try_new(vec![
|
||||||
|
kf(0.0, [0.0, 0.0, 0.0], [0.0, 0.0, 0.0], 60.0),
|
||||||
|
kf(1.0, [1.0, 9.0, 4.0], [9.0, 0.0, 0.0], 90.0),
|
||||||
|
interior,
|
||||||
|
kf(3.0, [8.0, 1.0, 1.0], [0.0, 0.0, 9.0], 30.0),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(path.sample(2.0), interior.camera);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sample_interpolates_collinear_segment_linearly() {
|
||||||
|
// Equally spaced, collinear control points: on an interior segment the
|
||||||
|
// Catmull-Rom spline reduces to a straight line, so the midpoint is the
|
||||||
|
// exact arithmetic mean of the bracketing keyframes.
|
||||||
|
let path = CameraPath::try_new(vec![
|
||||||
|
kf(0.0, [0.0, 0.0, 0.0], [0.0, 0.0, 0.0], 10.0),
|
||||||
|
kf(1.0, [10.0, 0.0, 0.0], [0.0, 0.0, 0.0], 20.0),
|
||||||
|
kf(2.0, [20.0, 0.0, 0.0], [0.0, 0.0, 0.0], 30.0),
|
||||||
|
kf(3.0, [30.0, 0.0, 0.0], [0.0, 0.0, 0.0], 40.0),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mid = path.sample(1.5);
|
||||||
|
assert_eq!(mid.position, Vec3::new(15.0, 0.0, 0.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sample_interpolates_position_strictly_between_keyframes() {
|
||||||
|
// On the first segment the duplicated endpoint changes the tangent, so
|
||||||
|
// the midpoint is not the arithmetic mean — but it must still lie
|
||||||
|
// strictly between the bracketing keyframe positions.
|
||||||
|
let path = CameraPath::try_new(vec![
|
||||||
|
kf(0.0, [0.0, 0.0, 0.0], [0.0, 0.0, 0.0], 60.0),
|
||||||
|
kf(1.0, [10.0, 0.0, 0.0], [0.0, 0.0, 0.0], 60.0),
|
||||||
|
kf(2.0, [20.0, 0.0, 0.0], [0.0, 0.0, 0.0], 60.0),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mid = path.sample(0.5);
|
||||||
|
assert!(
|
||||||
|
mid.position.x > 0.0 && mid.position.x < 10.0,
|
||||||
|
"expected 0 < x < 10, got {}",
|
||||||
|
mid.position.x
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sample_interpolates_fov_linearly() {
|
||||||
|
// FOV is a linear ramp, independent of the spline, so the midpoint of a
|
||||||
|
// segment is the exact mean regardless of segment position.
|
||||||
|
let path = CameraPath::try_new(vec![
|
||||||
|
kf(0.0, [0.0, 0.0, 0.0], [0.0, 0.0, 0.0], 40.0),
|
||||||
|
kf(1.0, [10.0, 0.0, 0.0], [0.0, 0.0, 0.0], 80.0),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(path.sample(0.25).fov_degrees, 50.0);
|
||||||
|
assert_eq!(path.sample(0.5).fov_degrees, 60.0);
|
||||||
|
assert_eq!(path.sample(0.75).fov_degrees, 70.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sample_is_deterministic() {
|
||||||
|
let path = CameraPath::try_new(vec![
|
||||||
|
kf(0.0, [0.0, 0.0, 0.0], [1.0, 0.0, 0.0], 60.0),
|
||||||
|
kf(1.0, [4.0, 9.0, -2.0], [0.0, 1.0, 0.0], 75.0),
|
||||||
|
kf(2.7, [11.0, 3.0, 5.0], [0.0, 0.0, 1.0], 42.0),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Repeated sampling at the same time is bit-identical.
|
||||||
|
let a = path.sample(1.3);
|
||||||
|
let b = path.sample(1.3);
|
||||||
|
assert_eq!(a, b);
|
||||||
|
|
||||||
|
// And a clone of the path produces the identical sample.
|
||||||
|
let clone = path.clone();
|
||||||
|
assert_eq!(clone.sample(1.3), a);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn single_keyframe_path_is_constant() {
|
||||||
|
let only = kf(5.0, [1.0, 2.0, 3.0], [4.0, 5.0, 6.0], 33.0);
|
||||||
|
let path = CameraPath::try_new(vec![only]).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(path.sample(-10.0), only.camera);
|
||||||
|
assert_eq!(path.sample(5.0), only.camera);
|
||||||
|
assert_eq!(path.sample(1000.0), only.camera);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+147
-31
@@ -1,4 +1,21 @@
|
|||||||
use std::fs;
|
//! Executes a parsed OpenVistaPro [`Script`] into rendered PNG files.
|
||||||
|
//!
|
||||||
|
//! The parser in [`crate::script`] turns script text into a [`Script`] AST but
|
||||||
|
//! deliberately does no I/O. This module is the integration slice that *runs*
|
||||||
|
//! that AST: it walks the commands in order, building a [`HeightGrid`] from
|
||||||
|
//! presets or imported heightmaps, adjusting the [`Scene`], and writing each
|
||||||
|
//! `render output` command to a PNG on disk.
|
||||||
|
//!
|
||||||
|
//! Execution is a small linear interpreter with no nesting:
|
||||||
|
//!
|
||||||
|
//! - `use preset <name>` replaces the current terrain with a built-in preset.
|
||||||
|
//! - `import heightmap "<path>"` replaces the terrain with a grayscale PNG.
|
||||||
|
//! - `set thresholds ...` updates the active scene's elevation bands.
|
||||||
|
//! - `render output "<path>"` writes the current terrain + scene to a PNG.
|
||||||
|
//!
|
||||||
|
//! Relative paths in a script are resolved against a caller-supplied base
|
||||||
|
//! directory (for [`run_script_file`], the directory containing the script).
|
||||||
|
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
@@ -10,23 +27,35 @@ use crate::scene::Scene;
|
|||||||
use crate::script::{Command, ParseError, PresetName, Script, parse_script};
|
use crate::script::{Command, ParseError, PresetName, Script, parse_script};
|
||||||
use crate::terrain::{HeightGrid, TerrainError};
|
use crate::terrain::{HeightGrid, TerrainError};
|
||||||
|
|
||||||
|
/// Edge length of the terrain grid generated for `use preset` commands.
|
||||||
const PRESET_SIZE: u32 = 64;
|
const PRESET_SIZE: u32 = 64;
|
||||||
|
/// Peak elevation of the `hill` preset, matching the CLI `render` default.
|
||||||
const HILL_PEAK_HEIGHT: f32 = 10.0;
|
const HILL_PEAK_HEIGHT: f32 = 10.0;
|
||||||
|
/// Elevation that a fully white (255) heightmap pixel maps to.
|
||||||
|
const HEIGHTMAP_PEAK_HEIGHT: f32 = 10.0;
|
||||||
|
|
||||||
/// Summary of a successful script run.
|
/// Summary of a successful script run.
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
pub struct ExecReport {
|
pub struct ExecReport {
|
||||||
|
/// Absolute-or-base-relative paths written by each `render output` command,
|
||||||
|
/// in source order.
|
||||||
pub outputs: Vec<PathBuf>,
|
pub outputs: Vec<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Errors produced while loading or executing a script.
|
/// An error produced while loading or executing a script.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum ScriptError {
|
pub enum ScriptError {
|
||||||
|
/// Failed to read the script file or create an output directory.
|
||||||
Io(io::Error),
|
Io(io::Error),
|
||||||
|
/// The script text could not be parsed.
|
||||||
Parse(ParseError),
|
Parse(ParseError),
|
||||||
Import(crate::import::ImportError),
|
/// Building a terrain grid failed.
|
||||||
Terrain(TerrainError),
|
Terrain(TerrainError),
|
||||||
|
/// Importing an open-format terrain source failed.
|
||||||
|
Import(crate::import::ImportError),
|
||||||
|
/// Decoding a heightmap or writing a render failed.
|
||||||
Image(ImageError),
|
Image(ImageError),
|
||||||
|
/// A `render output` command ran before any terrain was established.
|
||||||
RenderWithoutTerrain,
|
RenderWithoutTerrain,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,8 +64,8 @@ impl std::fmt::Display for ScriptError {
|
|||||||
match self {
|
match self {
|
||||||
ScriptError::Io(e) => write!(f, "script I/O error: {e}"),
|
ScriptError::Io(e) => write!(f, "script I/O error: {e}"),
|
||||||
ScriptError::Parse(e) => write!(f, "script parse error: {e}"),
|
ScriptError::Parse(e) => write!(f, "script parse error: {e}"),
|
||||||
ScriptError::Import(e) => write!(f, "script import error: {e}"),
|
|
||||||
ScriptError::Terrain(e) => write!(f, "script terrain error: {e}"),
|
ScriptError::Terrain(e) => write!(f, "script terrain error: {e}"),
|
||||||
|
ScriptError::Import(e) => write!(f, "script import error: {e}"),
|
||||||
ScriptError::Image(e) => write!(f, "script image error: {e}"),
|
ScriptError::Image(e) => write!(f, "script image error: {e}"),
|
||||||
ScriptError::RenderWithoutTerrain => write!(
|
ScriptError::RenderWithoutTerrain => write!(
|
||||||
f,
|
f,
|
||||||
@@ -54,27 +83,30 @@ impl From<ParseError> for ScriptError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<crate::import::ImportError> for ScriptError {
|
|
||||||
fn from(e: crate::import::ImportError) -> Self {
|
|
||||||
ScriptError::Import(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<TerrainError> for ScriptError {
|
impl From<TerrainError> for ScriptError {
|
||||||
fn from(e: TerrainError) -> Self {
|
fn from(e: TerrainError) -> Self {
|
||||||
ScriptError::Terrain(e)
|
ScriptError::Terrain(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<crate::import::ImportError> for ScriptError {
|
||||||
|
fn from(e: crate::import::ImportError) -> Self {
|
||||||
|
ScriptError::Import(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl From<ImageError> for ScriptError {
|
impl From<ImageError> for ScriptError {
|
||||||
fn from(e: ImageError) -> Self {
|
fn from(e: ImageError) -> Self {
|
||||||
ScriptError::Image(e)
|
ScriptError::Image(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read, parse, and execute a script file.
|
/// Read, parse, and execute the OpenVistaPro script file at `path`.
|
||||||
|
///
|
||||||
|
/// Relative paths inside the script are resolved against the script file's
|
||||||
|
/// own directory, so a script and its assets can move together.
|
||||||
pub fn run_script_file(path: &Path) -> Result<ExecReport, ScriptError> {
|
pub fn run_script_file(path: &Path) -> Result<ExecReport, ScriptError> {
|
||||||
let source = fs::read_to_string(path).map_err(ScriptError::Io)?;
|
let source = std::fs::read_to_string(path).map_err(ScriptError::Io)?;
|
||||||
let script = parse_script(&source)?;
|
let script = parse_script(&source)?;
|
||||||
let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
|
let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
|
||||||
run_script(&script, base_dir)
|
run_script(&script, base_dir)
|
||||||
@@ -86,7 +118,8 @@ pub fn run_script_source(source: &str, base_dir: &Path) -> Result<ExecReport, Sc
|
|||||||
run_script(&script, base_dir)
|
run_script(&script, base_dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Execute an already-parsed script.
|
/// Execute an already-parsed [`Script`], resolving relative paths against
|
||||||
|
/// `base_dir`.
|
||||||
pub fn run_script(script: &Script, base_dir: &Path) -> Result<ExecReport, ScriptError> {
|
pub fn run_script(script: &Script, base_dir: &Path) -> Result<ExecReport, ScriptError> {
|
||||||
let mut scene = Scene::default();
|
let mut scene = Scene::default();
|
||||||
let mut grid: Option<HeightGrid> = None;
|
let mut grid: Option<HeightGrid> = None;
|
||||||
@@ -117,7 +150,7 @@ pub fn run_script(script: &Script, base_dir: &Path) -> Result<ExecReport, Script
|
|||||||
let output = resolve(base_dir, path);
|
let output = resolve(base_dir, path);
|
||||||
if let Some(parent) = output.parent() {
|
if let Some(parent) = output.parent() {
|
||||||
if !parent.as_os_str().is_empty() {
|
if !parent.as_os_str().is_empty() {
|
||||||
fs::create_dir_all(parent).map_err(ScriptError::Io)?;
|
std::fs::create_dir_all(parent).map_err(ScriptError::Io)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
render_top_down_to_path(grid, &scene, &output)?;
|
render_top_down_to_path(grid, &scene, &output)?;
|
||||||
@@ -129,12 +162,27 @@ pub fn run_script(script: &Script, base_dir: &Path) -> Result<ExecReport, Script
|
|||||||
Ok(report)
|
Ok(report)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Load an imported heightmap, accepting either the project-owned `ovp-text`
|
||||||
|
/// fixture format or a grayscale image file.
|
||||||
fn load_heightmap(path: &Path) -> Result<HeightGrid, ScriptError> {
|
fn load_heightmap(path: &Path) -> Result<HeightGrid, ScriptError> {
|
||||||
let source = fs::read_to_string(path).map_err(ScriptError::Io)?;
|
let bytes = std::fs::read(path).map_err(ScriptError::Io)?;
|
||||||
let imported = import_ovp_text(&source)?;
|
if let Ok(source) = std::str::from_utf8(&bytes) {
|
||||||
Ok(imported.into_grid())
|
if let Ok(imported) = import_ovp_text(source) {
|
||||||
|
return Ok(imported.into_grid());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let image = image::load_from_memory(&bytes)?.to_luma8();
|
||||||
|
let width = image.width();
|
||||||
|
let height = image.height();
|
||||||
|
let samples = image
|
||||||
|
.pixels()
|
||||||
|
.map(|pixel| pixel[0] as f32 / 255.0 * HEIGHTMAP_PEAK_HEIGHT)
|
||||||
|
.collect();
|
||||||
|
Ok(HeightGrid::new(width, height, samples)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve a script-relative path against `base_dir`; absolute paths pass through.
|
||||||
fn resolve(base_dir: &Path, path: &str) -> PathBuf {
|
fn resolve(base_dir: &Path, path: &str) -> PathBuf {
|
||||||
let candidate = Path::new(path);
|
let candidate = Path::new(path);
|
||||||
if candidate.is_absolute() {
|
if candidate.is_absolute() {
|
||||||
@@ -193,27 +241,95 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn run_script_rejects_render_before_terrain() {
|
fn run_script_rejects_render_before_terrain() {
|
||||||
let dir = temp_dir("missing-terrain");
|
let dir = temp_dir("no-terrain");
|
||||||
let script = parse_script("render output \"demo.png\"").unwrap();
|
let script = parse_script("render output \"x.png\"").unwrap();
|
||||||
let err = run_script(&script, &dir).expect_err("render without terrain must fail");
|
let err = run_script(&script, &dir).expect_err("render without terrain must fail");
|
||||||
assert!(matches!(err, ScriptError::RenderWithoutTerrain));
|
assert!(matches!(err, ScriptError::RenderWithoutTerrain));
|
||||||
std::fs::remove_dir_all(&dir).ok();
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn run_script_loads_open_heightmap_from_text_fixture() {
|
fn run_script_thresholds_change_render_output() {
|
||||||
let dir = temp_dir("import");
|
// Flooding the scene above the hill peak forces an all-water render
|
||||||
let fixture = Path::new(env!("CARGO_MANIFEST_DIR"))
|
// that must differ from a normally-banded render of the same terrain.
|
||||||
.join("tests/fixtures/open/tiny-heightfield.ovptext");
|
let dir = temp_dir("thresholds");
|
||||||
let script = parse_script(&format!(
|
let flooded = parse_script(
|
||||||
"import heightmap \"{}\"\nrender output \"demo.png\"",
|
"use preset hill\nset thresholds water=100.0 tree=101.0 snow=102.0\nrender output \"flooded.png\"",
|
||||||
fixture.display()
|
)
|
||||||
))
|
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let report = run_script(&script, &dir).expect("script should execute");
|
let dry = parse_script(
|
||||||
assert_eq!(report.outputs.len(), 1);
|
"use preset hill\nset thresholds water=1.0 tree=4.0 snow=7.0\nrender output \"dry.png\"",
|
||||||
let bytes = std::fs::read(&report.outputs[0]).expect("output png should exist");
|
)
|
||||||
assert!(bytes.starts_with(&PNG_MAGIC));
|
.unwrap();
|
||||||
|
let mut a = run_script(&flooded, &dir).unwrap();
|
||||||
|
let mut b = run_script(&dry, &dir).unwrap();
|
||||||
|
let flooded_png = std::fs::read(a.outputs.remove(0)).unwrap();
|
||||||
|
let dry_png = std::fs::read(b.outputs.remove(0)).unwrap();
|
||||||
|
assert_ne!(
|
||||||
|
flooded_png, dry_png,
|
||||||
|
"set thresholds should change the rendered output"
|
||||||
|
);
|
||||||
std::fs::remove_dir_all(&dir).ok();
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn run_script_imports_heightmap_and_renders() {
|
||||||
|
let dir = temp_dir("import");
|
||||||
|
// Write a tiny 4x4 grayscale PNG to import as terrain.
|
||||||
|
let mut heightmap = image::GrayImage::new(4, 4);
|
||||||
|
for (i, pixel) in heightmap.pixels_mut().enumerate() {
|
||||||
|
*pixel = image::Luma([(i as u32 * 16) as u8]);
|
||||||
|
}
|
||||||
|
heightmap.save(dir.join("height.png")).unwrap();
|
||||||
|
|
||||||
|
let script =
|
||||||
|
parse_script("import heightmap \"height.png\"\nrender output \"imported.png\"")
|
||||||
|
.unwrap();
|
||||||
|
let report = run_script(&script, &dir).expect("import + render should succeed");
|
||||||
|
let rendered = image::open(&report.outputs[0])
|
||||||
|
.expect("rendered file should be readable")
|
||||||
|
.to_rgb8();
|
||||||
|
assert_eq!(rendered.width(), 4, "render should match heightmap width");
|
||||||
|
assert_eq!(rendered.height(), 4, "render should match heightmap height");
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn run_script_file_reads_parses_and_executes() {
|
||||||
|
let dir = temp_dir("file");
|
||||||
|
let script_path = dir.join("demo.ovps");
|
||||||
|
std::fs::write(
|
||||||
|
&script_path,
|
||||||
|
"use preset hill\nrender output \"file-demo.png\"",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let report = run_script_file(&script_path).expect("run_script_file should succeed");
|
||||||
|
assert_eq!(report.outputs.len(), 1);
|
||||||
|
assert!(
|
||||||
|
report.outputs[0].exists(),
|
||||||
|
"render output should be written next to the script"
|
||||||
|
);
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn run_script_file_reports_parse_errors() {
|
||||||
|
let dir = temp_dir("parse-err");
|
||||||
|
let script_path = dir.join("bad.ovps");
|
||||||
|
std::fs::write(&script_path, "spin camera 90").unwrap();
|
||||||
|
let err = run_script_file(&script_path).expect_err("invalid script must fail");
|
||||||
|
assert!(matches!(err, ScriptError::Parse(_)));
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn run_script_file_reports_missing_file() {
|
||||||
|
let missing = std::env::temp_dir().join(format!(
|
||||||
|
"openvistapro-script-missing-{}.ovps",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
let _ = std::fs::remove_file(&missing);
|
||||||
|
let err = run_script_file(&missing).expect_err("missing script must fail");
|
||||||
|
assert!(matches!(err, ScriptError::Io(_)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+224
@@ -0,0 +1,224 @@
|
|||||||
|
//! Pure state model for the OpenVistaPro UI shell.
|
||||||
|
//!
|
||||||
|
//! This module owns the navigation model — which feature family is currently
|
||||||
|
//! active — plus the static metadata describing every section. It deliberately
|
||||||
|
//! contains no egui types so the shell can be exercised by unit tests without a
|
||||||
|
//! windowing backend, and so the rendering code in `app.rs` stays a thin view
|
||||||
|
//! over this state.
|
||||||
|
|
||||||
|
/// One feature family surfaced by the shell's command/navigation bar.
|
||||||
|
///
|
||||||
|
/// The ordering of these variants is the canonical tab order used throughout
|
||||||
|
/// the UI; see [`UiShellState::sections`].
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum ShellSection {
|
||||||
|
#[default]
|
||||||
|
Terrain,
|
||||||
|
Scene,
|
||||||
|
Render,
|
||||||
|
Import,
|
||||||
|
Script,
|
||||||
|
Path,
|
||||||
|
Project,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stable, ordered list of every section the shell exposes.
|
||||||
|
///
|
||||||
|
/// Navigation is intentionally static: unimplemented surfaces stay visible as
|
||||||
|
/// placeholders rather than being hidden, so this list never changes at
|
||||||
|
/// runtime.
|
||||||
|
const SECTIONS: [ShellSection; 7] = [
|
||||||
|
ShellSection::Terrain,
|
||||||
|
ShellSection::Scene,
|
||||||
|
ShellSection::Render,
|
||||||
|
ShellSection::Import,
|
||||||
|
ShellSection::Script,
|
||||||
|
ShellSection::Path,
|
||||||
|
ShellSection::Project,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Static description of a single section, used to render its nav entry and
|
||||||
|
/// its placeholder surface.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct SectionInfo {
|
||||||
|
/// The section this metadata describes.
|
||||||
|
pub section: ShellSection,
|
||||||
|
/// Short human-readable label shown in the nav bar and panel headings.
|
||||||
|
pub title: &'static str,
|
||||||
|
/// One-line description of what the section is for.
|
||||||
|
pub summary: &'static str,
|
||||||
|
/// Label shown on the central viewport when the section has no
|
||||||
|
/// implemented surface yet.
|
||||||
|
pub placeholder: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ShellSection {
|
||||||
|
/// Returns the static metadata for this section.
|
||||||
|
pub fn info(self) -> SectionInfo {
|
||||||
|
match self {
|
||||||
|
ShellSection::Terrain => SectionInfo {
|
||||||
|
section: self,
|
||||||
|
title: "Terrain",
|
||||||
|
summary: "Generate and sculpt terrain height fields.",
|
||||||
|
placeholder: "Terrain workspace coming soon",
|
||||||
|
},
|
||||||
|
ShellSection::Scene => SectionInfo {
|
||||||
|
section: self,
|
||||||
|
title: "Scene",
|
||||||
|
summary: "Tune scene bands, lighting, and camera framing.",
|
||||||
|
placeholder: "Scene workspace coming soon",
|
||||||
|
},
|
||||||
|
ShellSection::Render => SectionInfo {
|
||||||
|
section: self,
|
||||||
|
title: "Render",
|
||||||
|
summary: "Configure renderer mode and preview output.",
|
||||||
|
placeholder: "Render workspace coming soon",
|
||||||
|
},
|
||||||
|
ShellSection::Import => SectionInfo {
|
||||||
|
section: self,
|
||||||
|
title: "Import",
|
||||||
|
summary: "Bring in external height data and imagery.",
|
||||||
|
placeholder: "Import workspace coming soon",
|
||||||
|
},
|
||||||
|
ShellSection::Script => SectionInfo {
|
||||||
|
section: self,
|
||||||
|
title: "Script",
|
||||||
|
summary: "Automate scene setup with scripted commands.",
|
||||||
|
placeholder: "Script workspace coming soon",
|
||||||
|
},
|
||||||
|
ShellSection::Path => SectionInfo {
|
||||||
|
section: self,
|
||||||
|
title: "Path",
|
||||||
|
summary: "Lay out camera paths and animation keyframes.",
|
||||||
|
placeholder: "Path workspace coming soon",
|
||||||
|
},
|
||||||
|
ShellSection::Project => SectionInfo {
|
||||||
|
section: self,
|
||||||
|
title: "Project",
|
||||||
|
summary: "Manage project files, scenes, and settings.",
|
||||||
|
placeholder: "Project workspace coming soon",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Short label for the nav bar and panel headings.
|
||||||
|
pub fn title(self) -> &'static str {
|
||||||
|
self.info().title
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One-line description of the section.
|
||||||
|
pub fn summary(self) -> &'static str {
|
||||||
|
self.info().summary
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Placeholder label for the section's not-yet-built surface.
|
||||||
|
pub fn placeholder(self) -> &'static str {
|
||||||
|
self.info().placeholder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigation state for the shell: the single source of truth for which
|
||||||
|
/// section the UI is currently showing.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub struct UiShellState {
|
||||||
|
pub active_section: ShellSection,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UiShellState {
|
||||||
|
/// Creates a shell at its default section ([`ShellSection::Terrain`]).
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the canonical, ordered list of all sections.
|
||||||
|
pub fn sections(&self) -> &'static [ShellSection] {
|
||||||
|
&SECTIONS
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Switches the active section. Navigation never fails — every section is
|
||||||
|
/// always reachable, even if its surface is only a placeholder.
|
||||||
|
pub fn activate(&mut self, section: ShellSection) {
|
||||||
|
self.active_section = section;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` if `section` is the one currently shown.
|
||||||
|
pub fn is_active(&self, section: ShellSection) -> bool {
|
||||||
|
self.active_section == section
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Metadata for the currently active section.
|
||||||
|
pub fn active_info(&self) -> SectionInfo {
|
||||||
|
self.active_section.info()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Title of the active section.
|
||||||
|
pub fn section_title(&self) -> &'static str {
|
||||||
|
self.active_section.title()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One-line summary of the active section.
|
||||||
|
pub fn section_summary(&self) -> &'static str {
|
||||||
|
self.active_section.summary()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Placeholder label for the active section's surface.
|
||||||
|
pub fn placeholder_label(&self) -> &'static str {
|
||||||
|
self.active_section.placeholder()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ui_shell_sections_cover_the_major_feature_families() {
|
||||||
|
let shell = UiShellState::new();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
shell.sections(),
|
||||||
|
&[
|
||||||
|
ShellSection::Terrain,
|
||||||
|
ShellSection::Scene,
|
||||||
|
ShellSection::Render,
|
||||||
|
ShellSection::Import,
|
||||||
|
ShellSection::Script,
|
||||||
|
ShellSection::Path,
|
||||||
|
ShellSection::Project,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ui_shell_active_section_has_a_named_placeholder() {
|
||||||
|
let shell = UiShellState::new();
|
||||||
|
|
||||||
|
assert_eq!(shell.section_title(), "Terrain");
|
||||||
|
assert!(shell.section_summary().contains("terrain"));
|
||||||
|
assert!(shell.placeholder_label().contains("coming soon"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ui_shell_starts_on_terrain_and_navigates_between_sections() {
|
||||||
|
let mut shell = UiShellState::new();
|
||||||
|
assert!(shell.is_active(ShellSection::Terrain));
|
||||||
|
|
||||||
|
shell.activate(ShellSection::Render);
|
||||||
|
assert!(shell.is_active(ShellSection::Render));
|
||||||
|
assert!(!shell.is_active(ShellSection::Terrain));
|
||||||
|
assert_eq!(shell.section_title(), "Render");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_section_has_non_empty_metadata() {
|
||||||
|
let shell = UiShellState::new();
|
||||||
|
|
||||||
|
for §ion in shell.sections() {
|
||||||
|
let info = section.info();
|
||||||
|
assert_eq!(info.section, section);
|
||||||
|
assert!(!info.title.is_empty());
|
||||||
|
assert!(!info.summary.is_empty());
|
||||||
|
assert!(info.placeholder.contains("coming soon"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
#[test]
|
||||||
|
fn readme_script_section_matches_executable_script_cli() {
|
||||||
|
let readme = include_str!("../README.md");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
readme.contains("cargo run --bin openvistapro -- script run --input examples/demo.ovps"),
|
||||||
|
"README should show the executable script-run command used by examples/demo.ovps"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
readme.contains("execut") && readme.contains("writes each `render output`"),
|
||||||
|
"README should describe current script execution behavior, not parser-only behavior"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!readme.contains("only parses scripts into an AST"),
|
||||||
|
"README must not retain the pre-execution script MVP wording"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn readme_lists_current_importer_surface() {
|
||||||
|
let readme = include_str!("../README.md");
|
||||||
|
|
||||||
|
for importer in ["`ovp-text`", "`hgt`", "`esri-ascii-grid`"] {
|
||||||
|
assert!(
|
||||||
|
readme.contains(importer),
|
||||||
|
"README should mention current importer {importer}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
readme.contains("cargo test ascii_grid")
|
||||||
|
&& readme.contains("cargo test ascii_grid --features ascii-grid-import"),
|
||||||
|
"README should document the ASCII-grid validation commands"
|
||||||
|
);
|
||||||
|
}
|
||||||
+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