feat: wire shell placeholders to backend actions #12

Merged
moldybits merged 11 commits from feat/terrain-gen-abstraction into main 2026-05-17 19:30:57 -04:00
19 changed files with 2031 additions and 410 deletions
Showing only changes of commit 08c42806d7 - Show all commits
+1
View File
@@ -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]
+19 -36
View File
@@ -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
+10 -21
View File
@@ -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
+11 -11
View File
@@ -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 VistaPros 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 VistaPros 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.
+370
View File
@@ -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 (130000); 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, 01 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
(Tree1Tree4) 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 (Tree14) color the
ground instead.
### 1.9 Clouds (Cloud Control Panel, Windows v3)
- **Fractal Detail** — add fractal detail for realistic clouds.
- **Density** (0100), **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 14, Bare 14, Tree 14, Horizon,
Cliff 14, Water 15, Beach, Tree 14, Bark 14, SkyHaze, Haze, House 14,
House 14**.
- **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.
+8 -1
View File
@@ -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.
+6 -2
View File
@@ -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 -19
View File
@@ -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
+13
View File
@@ -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"
+401 -235
View File
@@ -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,237 +39,371 @@ 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,
ui.separator(); egui::TextureOptions::NEAREST,
ui.label("Terrain"); ));
let mut preset = self.data.terrain_preset; }
changed |= ui
.radio_value(&mut preset, TerrainPreset::RadialHill, "Radial hill")
.changed();
changed |= ui
.radio_value(&mut preset, TerrainPreset::Plane, "Plane")
.changed();
if preset != self.data.terrain_preset {
self.data.apply(AppAction::SetTerrainPreset(preset));
}
ui.separator();
ui.label("Renderer");
let mut renderer_mode = self.data.renderer_mode;
changed |= ui
.radio_value(&mut renderer_mode, RendererMode::TopDown, "Top-down")
.changed();
changed |= ui
.radio_value(&mut renderer_mode, RendererMode::Perspective, "Perspective")
.changed();
if renderer_mode != self.data.renderer_mode {
self.data.apply(AppAction::SetRendererMode(renderer_mode));
}
ui.separator();
ui.label("Scene bands");
let mut water = self.data.scene.water_level;
let mut trees = self.data.scene.tree_line;
let mut snow = self.data.scene.snow_line;
let mut haze = self.data.scene.haze;
changed |= ui
.add(egui::Slider::new(&mut water, -5.0..=10.0).text("Water"))
.changed();
changed |= ui
.add(egui::Slider::new(&mut trees, -5.0..=12.0).text("Trees"))
.changed();
changed |= ui
.add(egui::Slider::new(&mut snow, -5.0..=15.0).text("Snow"))
.changed();
changed |= ui
.add(egui::Slider::new(&mut haze, 0.0..=1.0).text("Haze"))
.changed();
self.data.apply(AppAction::SetWaterLevel(water));
self.data.apply(AppAction::SetTreeLine(trees));
self.data.apply(AppAction::SetSnowLine(snow));
self.data.apply(AppAction::SetHaze(haze));
ui.separator();
ui.label("Camera");
let mut camera_position = self.data.scene.camera.position;
let mut camera_target = self.data.scene.camera.target;
changed |= vec3_controls(ui, "Position", &mut camera_position);
changed |= vec3_controls(ui, "Target", &mut camera_target);
self.data
.apply(AppAction::SetCameraPosition(camera_position));
self.data.apply(AppAction::SetCameraTarget(camera_target));
});
egui::SidePanel::right("entry_points")
.resizable(true)
.show(ctx, |ui| {
ui.heading("Scripts / paths");
ui.separator();
ui.label("Import terrain");
ui.horizontal(|ui| {
let import_path = self
.data
.import_path
.clone()
.unwrap_or_else(Self::default_import_path);
ui.monospace(import_path.as_str());
if ui.button("Import heightmap…").clicked() {
let path = Path::new(import_path.as_str());
match self.data.import_heightmap_from_path(path) {
Ok(()) => changed = true,
Err(error) => action_note = Some(format!("import failed: {error}")),
}
}
});
if let Some(grid) = self.data.imported_grid.as_ref() {
ui.label(format!("Imported grid: {}×{}", grid.width(), grid.height()));
} else {
ui.label(
"Legacy import surfaces remain planned; the shell shows the entry point.",
);
}
ui.separator();
ui.label("Script source");
changed |= ui
.add(
egui::TextEdit::multiline(&mut self.data.script_source)
.desired_rows(8)
.lock_focus(true)
.desired_width(f32::INFINITY),
)
.changed();
ui.horizontal(|ui| {
if ui.button("Run script").clicked() {
let base_dir = Path::new(Self::default_script_base_dir());
match self.data.run_script_from_source(base_dir) {
Ok(report) => {
if !report.outputs.is_empty() {
changed = true;
}
}
Err(error) => action_note = Some(format!("script run failed: {error}")),
}
}
ui.label("Parser + executor slice; output writes to disk.");
});
if let Some(report) = self.data.last_script_run.as_ref() {
ui.label(format!("Last run wrote {} output(s)", report.outputs.len()));
}
ui.separator();
ui.label("Path tools");
ui.horizontal(|ui| {
let path_target = self
.data
.path_target
.as_deref()
.unwrap_or("No path target selected");
ui.monospace(path_target);
if ui.button("Make path").clicked() {
self.data.make_path();
changed = true;
}
});
if let Some(path) = self.data.generated_path.as_ref() {
ui.label(format!("Generated path: {}", path.summary()));
} else {
ui.label("Path generation is now wired to the backend demo path builder.");
}
});
if changed || self.texture.is_none() {
self.rebuild_texture(ctx);
ctx.request_repaint();
} }
}
let snapshot = self.data.ui_snapshot(); /// Top command/navigation bar. Every section is always present so
/// navigation stays stable even where the surface is only a placeholder.
egui::TopBottomPanel::top("project_bar").show(ctx, |ui| { fn command_bar(&mut self, ctx: &egui::Context) {
egui::TopBottomPanel::top("command_bar").show(ctx, |ui| {
ui.horizontal_wrapped(|ui| { ui.horizontal_wrapped(|ui| {
ui.label(snapshot.scene_file_label.as_str()); ui.strong(WINDOW_TITLE);
match snapshot.scene_file_path.as_deref() {
Some(path) => ui.monospace(path),
None => ui.weak("No scene file loaded"),
};
ui.separator(); ui.separator();
if ui.button("New").clicked() { for &section in self.shell.sections() {
let path = self let active = self.shell.is_active(section);
.data if ui.selectable_label(active, section.title()).clicked() {
.loaded_scene_path self.shell.activate(section);
.clone()
.unwrap_or_else(Self::default_scene_path);
self.data.reset_scene();
self.data.loaded_scene_path = Some(path);
changed = true;
}
if ui.button("Open…").clicked() {
let path = self
.data
.loaded_scene_path
.clone()
.unwrap_or_else(Self::default_scene_path);
match self.data.open_scene(Path::new(&path)) {
Ok(()) => changed = true,
Err(error) => action_note = Some(format!("open failed: {error}")),
}
}
if ui.button("Save").clicked() {
let path = self
.data
.loaded_scene_path
.clone()
.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}")),
} }
} }
}); });
}); });
}
egui::TopBottomPanel::bottom("status_bar").show(ctx, |ui| { /// Left panel: controls contextual to the active section. Sections without
ui.horizontal_wrapped(|ui| { /// an implemented surface still show their labelled placeholder.
ui.label(snapshot.status_line.as_str()); ///
/// 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(); ui.separator();
ui.monospace(format!( match self.shell.active_section {
"scripts: {} cmd / {} render / {} import", ShellSection::Terrain => changed |= self.terrain_controls(ui),
snapshot.script_preview.command_count, ShellSection::Scene => changed |= self.scene_controls(ui),
snapshot.script_preview.render_commands, ShellSection::Render => changed |= self.render_controls(ui),
snapshot.script_preview.import_commands, 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;
changed |= ui
.radio_value(&mut preset, TerrainPreset::RadialHill, "Radial hill")
.changed();
changed |= ui
.radio_value(&mut preset, TerrainPreset::Plane, "Plane")
.changed();
if preset != self.data.terrain_preset {
self.data.apply(AppAction::SetTerrainPreset(preset));
}
changed
}
fn render_controls(&mut self, ui: &mut egui::Ui) -> bool {
let mut changed = false;
let mut renderer_mode = self.data.renderer_mode;
changed |= ui
.radio_value(&mut renderer_mode, RendererMode::TopDown, "Top-down")
.changed();
changed |= ui
.radio_value(&mut renderer_mode, RendererMode::Perspective, "Perspective")
.changed();
if renderer_mode != self.data.renderer_mode {
self.data.apply(AppAction::SetRendererMode(renderer_mode));
}
changed
}
fn scene_controls(&mut self, ui: &mut egui::Ui) -> bool {
let mut changed = false;
ui.label("Scene bands");
let mut water = self.data.scene.water_level;
let mut trees = self.data.scene.tree_line;
let mut snow = self.data.scene.snow_line;
let mut haze = self.data.scene.haze;
changed |= ui
.add(egui::Slider::new(&mut water, -5.0..=10.0).text("Water"))
.changed();
changed |= ui
.add(egui::Slider::new(&mut trees, -5.0..=12.0).text("Trees"))
.changed();
changed |= ui
.add(egui::Slider::new(&mut snow, -5.0..=15.0).text("Snow"))
.changed();
changed |= ui
.add(egui::Slider::new(&mut haze, 0.0..=1.0).text("Haze"))
.changed();
self.data.apply(AppAction::SetWaterLevel(water));
self.data.apply(AppAction::SetTreeLine(trees));
self.data.apply(AppAction::SetSnowLine(snow));
self.data.apply(AppAction::SetHaze(haze));
ui.separator();
ui.label("Camera");
let mut camera_position = self.data.scene.camera.position;
let mut camera_target = self.data.scene.camera.target;
changed |= vec3_controls(ui, "Position", &mut camera_position);
changed |= vec3_controls(ui, "Target", &mut camera_target);
self.data
.apply(AppAction::SetCameraPosition(camera_position));
self.data.apply(AppAction::SetCameraTarget(camera_target));
ui.separator();
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.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
.data
.import_path
.clone()
.unwrap_or_else(Self::default_import_path);
ui.horizontal(|ui| {
ui.monospace(import_path.as_str());
if ui.button("Import heightmap…").clicked() {
let path = std::path::Path::new(import_path.as_str());
match self.data.import_heightmap_from_path(path) {
Ok(()) => {
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() {
ui.label(format!("Imported grid: {}×{}", grid.width(), grid.height()));
} else {
ui.label(self.shell.placeholder_label());
}
changed
}
fn script_controls(&mut self, ui: &mut egui::Ui, action_note: &mut Option<String>) -> bool {
let mut changed = false;
ui.label("Script source");
changed |= ui
.add(
egui::TextEdit::multiline(&mut self.data.script_source)
.desired_rows(8)
.lock_focus(true)
.desired_width(f32::INFINITY),
)
.changed();
ui.horizontal(|ui| {
if ui.button("Run script").clicked() {
let base_dir = std::path::Path::new(Self::default_script_base_dir());
match self.data.run_script_from_source(base_dir) {
Ok(report) => {
changed |= !report.outputs.is_empty();
*action_note =
Some(format!("script wrote {} output(s)", report.outputs.len()));
}
Err(error) => *action_note = Some(format!("script run failed: {error}")),
}
}
ui.label("Parser + executor slice; output writes to disk.");
});
if let Some(report) = self.data.last_script_run.as_ref() {
ui.label(format!("Last run wrote {} output(s)", report.outputs.len()));
} else {
ui.label(self.shell.placeholder_label());
}
changed
}
fn path_controls(&mut self, ui: &mut egui::Ui, action_note: &mut Option<String>) -> bool {
let mut changed = false;
ui.label("Path tools");
ui.horizontal(|ui| {
let path_target = self
.data
.path_target
.as_deref()
.unwrap_or("No path target selected");
ui.monospace(path_target);
if ui.button("Make path").clicked() {
let path = self.data.make_path();
changed = true;
*action_note = Some(format!(
"generated {}",
self.data
.path_target
.clone()
.unwrap_or_else(|| format!("{} keyframes", path.keyframes().len()))
)); ));
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.label(note);
}
});
}); });
if let Some(path) = self.data.generated_path.as_ref() {
ui.label(format!(
"Generated path: {} keyframes",
path.keyframes().len()
));
} else {
ui.label(self.shell.placeholder_label());
}
changed
}
fn project_controls(&mut self, ui: &mut egui::Ui, action_note: &mut Option<String>) -> bool {
let mut changed = false;
ui.label("Scene file");
let scene_path = self
.data
.loaded_scene_path
.clone()
.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.loaded_scene_path = Some(scene_path.clone());
changed = true;
*action_note = Some(format!("reset scene and kept {scene_path}"));
}
if ui.button("Open…").clicked() {
let path = std::path::Path::new(&scene_path);
match self.data.open_scene(path) {
Ok(()) => {
changed = true;
*action_note = Some(format!("opened scene from {scene_path}"));
}
Err(error) => *action_note = Some(format!("open failed: {error}")),
}
}
if ui.button("Save").clicked() {
let path = std::path::Path::new(&scene_path);
match self.data.save_scene(path) {
Ok(()) => *action_note = Some(format!("saved scene to {scene_path}")),
Err(error) => *action_note = Some(format!("save failed: {error}")),
}
}
});
ui.label(self.shell.placeholder_label());
changed
}
/// Right panel: durable inspector summarising current scene state.
fn inspector_panel(&self, ctx: &egui::Context) {
egui::SidePanel::right("inspector_panel")
.resizable(true)
.default_width(220.0)
.show(ctx, |ui| {
ui.heading("Inspector");
ui.separator();
let info = self.shell.active_info();
ui.label(format!("Section: {}", info.title));
ui.label(info.summary);
ui.separator();
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());
"{} · {}", ui.separator();
snapshot.terrain_preset_label, snapshot.renderer_mode_label }
));
});
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
View File
@@ -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
View File
@@ -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
View File
@@ -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)));
}
} }
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 &section 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"));
}
}
}
+34
View File
@@ -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
View File
@@ -0,0 +1,8 @@
ncols 3
nrows 2
xllcorner 100.0
yllcorner 200.0
cellsize 30.0
NODATA_value -9999
1 2 3
4 5 6