feat: expand light direction and custom lighting controls #16

Merged
moldybits merged 1 commits from feat/terrain-gen-abstraction into main 2026-05-25 02:27:06 -04:00
8 changed files with 556 additions and 31 deletions
+16 -10
View File
@@ -12,8 +12,8 @@ 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, 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 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, a narrow GeoTIFF importer behind `import-geotiff`, and a deterministic terrain-generation module in `src/terrain_gen.rs` with `TerrainGenerationSpec` / `TerrainGenerationSettings` / `DeterministicTerrainGenerator` (see `cargo test terrain_gen` and its determinism/seed note). The UI shell and CLI both expose a seeded `fractal` preset alongside the legacy `plane` and `hill` fixtures.
- A project-owned script parser + executor in `src/script.rs` / `src/script_exec.rs`, MakePath-inspired camera path generation in `src/path.rs`, a project-owned color-map model with editable thresholds/bands in `src/scene.rs`, and an `app` feature shell with working import/script/path/project controls in `src/app_state.rs`, `src/app.rs`, and `src/ui_shell.rs`. - A project-owned script parser + executor in `src/script.rs` / `src/script_exec.rs`, MakePath-inspired camera path generation in `src/path.rs`, a project-owned color-map model with editable thresholds/bands in `src/scene.rs`, a light model with azimuth/elevation/intensity controls, and an `app` feature shell with top-level menus, about/help dialog surfacing, and working import/script/path/project/lighting controls in `src/app_state.rs`, `src/app.rs`, and `src/ui_shell.rs`.
## Development ## Development
@@ -23,6 +23,7 @@ cargo test
cargo run -- info cargo run -- info
cargo run -- scene export --output /tmp/openvistapro-default.ovp.toml cargo run -- scene export --output /tmp/openvistapro-default.ovp.toml
cargo run -- render --preset hill --width 256 --height 256 --output /tmp/openvistapro-hill.png cargo run -- render --preset hill --width 256 --height 256 --output /tmp/openvistapro-hill.png
cargo run -- render --preset fractal --seed 1337 --width 256 --height 256 --output /tmp/openvistapro-fractal.png
cargo run -- render --preset hill --scene /tmp/openvistapro-default.ovp.toml --width 256 --height 256 --output /tmp/openvistapro-hill-from-scene.png cargo run -- render --preset hill --scene /tmp/openvistapro-default.ovp.toml --width 256 --height 256 --output /tmp/openvistapro-hill-from-scene.png
cargo run -- render --preset hill --camera-demo --width 256 --height 192 --output /tmp/openvistapro-perspective.png cargo run -- render --preset hill --camera-demo --width 256 --height 192 --output /tmp/openvistapro-perspective.png
cargo run -- render --preset hill --quality final --output /tmp/openvistapro-renders/ cargo run -- render --preset hill --quality final --output /tmp/openvistapro-renders/
@@ -30,16 +31,18 @@ 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` with scene controls and a CPU-rendered terrain preview. It opens an `eframe`/`egui` window titled `OpenVistaPro` with scene controls, top-level menus/dialogs, and a CPU-rendered terrain preview.
Importer status: Importer status:
- `heightmap`: script execution can import grayscale PNG heightmaps with `import heightmap "path.png"` and map brightness to elevation. - `heightmap`: project-owned script input that imports grayscale PNG heightmaps with `import heightmap "path.png"` and maps brightness to elevation. This is a terrain-ingest convenience path, not a legacy VistaPro compatibility claim.
- `ovp-text`: project-owned plain-text heightfield fixture format used for import-boundary tests. - `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. - `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.
These importers all terminate at the same internal `HeightGrid` model. Legacy VistaPro landscape/image compatibility is intentionally out of scope for the clean-room project unless it returns as a separately reviewed compatibility plan.
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. 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:
@@ -68,22 +71,24 @@ model.
Scene files use the project-owned `.ovp.toml` format. Version 1 stores a Scene files use the project-owned `.ovp.toml` format. Version 1 stores a
top-level `schema = "openvistapro.scene"`, `version = 1`, and a serialized top-level `schema = "openvistapro.scene"`, `version = 1`, and a serialized
`Scene` payload containing camera position/target, camera heading-pitch-bank, `Scene` payload containing camera position/target, camera heading-pitch-bank,
lens/FOV/clip ranges, light, water, tree-line, snow-line, haze, and hydrology lens/FOV/clip ranges, light direction plus the UI-facing azimuth/elevation
overlays/settings. The format is intentionally human-readable while the data controls, water, tree-line, snow-line, haze, and hydrology overlays/settings.
model is still evolving. The format is intentionally human-readable while the data model is still evolving.
## Script language (MVP) ## Script language (MVP)
OpenVistaPro includes a small, line-oriented scripting language for driving OpenVistaPro includes a small, line-oriented scripting language for driving
terrain and render jobs from a plain-text file (`src/script.rs`). The grammar terrain and render jobs from a plain-text file (`src/script.rs`). The grammar
is **clean-room and project-owned**: it is **not VistaPro-compatible** and is **clean-room and project-owned**: it is **not VistaPro-compatible** and
deliberately does not mirror the legacy VistaPro scripting syntax. deliberately does not mirror the legacy VistaPro scripting syntax. Its only
terrain-ingest command today is `import heightmap`, which loads grayscale PNG
input for the project-owned clean-room pipeline.
Each line is a blank line, a `#` comment (also usable as a trailing comment), Each line is a blank line, a `#` comment (also usable as a trailing comment),
or one command: or one command:
```text ```text
use preset hill # `hill` or `plane` use preset fractal # `fractal`, `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" # optional grayscale PNG terrain input import heightmap "data/demo-height.png" # optional grayscale PNG terrain input
render output "out/demo.png" render output "out/demo.png"
@@ -96,7 +101,8 @@ cargo run --bin openvistapro -- script run --input examples/demo.ovps
``` ```
Script paths are resolved relative to the script file. `use preset` and Script paths are resolved relative to the script file. `use preset` and
`import heightmap` select the active terrain, `set thresholds` updates scene `import heightmap` select the active terrain; `use preset fractal` (or the CLI
`--preset fractal --seed ...`) drives the seeded procedural generator, `set thresholds` updates scene
bands, and execution writes each `render output` to a deterministic PNG. bands, and execution writes each `render output` to a deterministic PNG.
## Project principles ## Project principles
+22 -8
View File
@@ -3,9 +3,9 @@
This is a normalized reconciliation of the VistaPro manuals, MakePath guide, screenshots, and current OpenVistaPro implementation. This is a normalized reconciliation of the VistaPro manuals, MakePath guide, screenshots, and current OpenVistaPro implementation.
Status counts by normalized feature family: Status counts by normalized feature family:
- Implemented: 12 - Implemented: 14
- Partial: 5 - Partial: 3
- Planned: 2 - Planned: 0
- Not planned: 1 - Not planned: 1
Notes: Notes:
@@ -18,16 +18,17 @@ Notes:
| Feature family | Manual / reference evidence | OpenVistaPro status | Implementation evidence | Gap / next step | | Feature family | Manual / reference evidence | OpenVistaPro status | Implementation evidence | Gap / next step |
|---|---|---|---|---| |---|---|---|---|---|
| Terrain sources and compatibility boundary | VistaPro 2 manual: Load Landscape / Save Landscape; VistaPro 3 manual: Load, Save, Exp/Imp menus and DEM/PCX/Targa24 references. | Partial | `src/import.rs`, `src/cli.rs` (`supported_importers()`), `README.md` importer status. | OpenVistaPro intentionally keeps the clean internal model separate and only claims open/synthetic importers plus project-owned exports. Legacy VistaPro format compatibility remains out of scope unless it gets a separately reviewed clean-room plan. | | Terrain sources and compatibility boundary | VistaPro 2 manual: Load Landscape / Save Landscape; VistaPro 3 manual: Load, Save, Exp/Imp menus and DEM/PCX/Targa24 references. | Partial | `src/import.rs`, `src/cli.rs` (`supported_importers()`), `README.md` importer status. | OpenVistaPro currently supports only project-owned `ovp-text`, script-level PNG heightmaps, and open terrain sources (`hgt`, ESRI ASCII Grid, GeoTIFF) that feed the same internal `HeightGrid` model. Anything that would claim direct legacy VistaPro file compatibility stays out of scope and would need a separately reviewed clean-room plan. |
| Project-owned plain-text heightfields (`ovp-text`) | Clean-room project fixture format, not part of the legacy manuals; used to model the import boundary safely. | Implemented | `src/import.rs` (`import_ovp_text`), tests in `src/import.rs`, fixture in `tests/fixtures/open/`. | No gap for the MVP slice; this is the project-owned test/import path. | | Project-owned plain-text heightfields (`ovp-text`) | Clean-room project fixture format, not part of the legacy manuals; used to model the import boundary safely. | Implemented | `src/import.rs` (`import_ovp_text`), tests in `src/import.rs`, fixture in `tests/fixtures/open/`. | No gap for the MVP slice; this is the project-owned test/import path. |
| SRTM / HGT terrain import | VistaPro manuals describe loading DEM-like landscape data; the open equivalent is the SRTM/HGT family. | Implemented | `src/import.rs` (`import_hgt` behind `hgt`), `README.md`, tests in `src/import.rs`. | Still only the open SRTM slice; broader compatibility formats remain separate. | | SRTM / HGT terrain import | VistaPro manuals describe loading DEM-like landscape data; the open equivalent is the SRTM/HGT family. | Implemented | `src/import.rs` (`import_hgt` behind `hgt`), `README.md`, tests in `src/import.rs`. | Still only the open SRTM slice; broader compatibility formats remain separate. |
| GeoTIFF terrain import | Modern open terrain source, not a legacy VistaPro format. | Implemented | `src/import/geotiff.rs` behind `import-geotiff`, tests in that module. | Deliberately narrow subset: tiny synthetic single-band raster support only. | | 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. | Implemented | `src/terrain_gen.rs`, `src/app_state.rs`, `src/app.rs`, `src/cli.rs`, `tests/terrain_gen.rs`, `tests/script_exec.rs`, `tests/cli.rs`, `README.md`. | Seeded `TerrainGenerationSpec` plus `TerrainGenerationSettings` and the shipped `fractal` preset now provide the first clean-room procedural terrain slice; later noise variants can build on the same boundary without changing `HeightGrid`. |
| 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. | Implemented | `src/scene.rs` (`Camera.orientation`, `fov_degrees`, `near_range`, `far_range`), `src/render.rs` perspective renderer, `src/app.rs` explicit heading/pitch/bank and lens/range controls. | The modern shell keeps the camera model explicit while staying intentionally simpler than the legacy lens matrix and stereo workflows. | | Lens / range / orientation controls | VistaPro manuals describe lens/range, bank, heading, and pitch controls. | Implemented | `src/scene.rs` (`Camera.orientation`, `fov_degrees`, `near_range`, `far_range`), `src/render.rs` perspective renderer, `src/app.rs` explicit heading/pitch/bank and lens/range controls. | The modern shell keeps the camera model explicit while staying intentionally simpler than the legacy lens matrix and stereo workflows. |
| 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`. | The core elevation-band controls are present and still feed the renderer. | | 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`. | The core elevation-band controls are present and still feed the renderer. |
| Rivers and lakes | VistaPro manuals explicitly mention rivers and lakes as adjustable landscape features. | Implemented | `src/scene.rs` (`Hydrology` lake/river overlays), `src/app.rs` hydrology controls + reset button, `src/app_state.rs`, `src/render.rs`, `src/scene_file.rs`, tests in `src/scene.rs`, `src/render.rs`, and `src/app_state.rs`. | The first clean-room slice now exposes editable lake/river overlays; routed-water simulation and drainage maps can grow later. | | Rivers and lakes | VistaPro manuals explicitly mention rivers and lakes as adjustable landscape features. | Implemented | `src/scene.rs` (`Hydrology` lake/river overlays), `src/app.rs` hydrology controls + reset button, `src/app_state.rs`, `src/render.rs`, `src/scene_file.rs`, tests in `src/scene.rs`, `src/render.rs`, and `src/app_state.rs`. | The first clean-room slice now exposes editable lake/river overlays; routed-water simulation and drainage maps can grow later. |
| 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. | Implemented | `src/scene.rs` (`Light` azimuth/elevation helpers), `src/render.rs` (directional shading), `src/app.rs` (azimuth/elevation/intensity sliders), tests in `src/scene.rs`, `src/render.rs`, and `src/app_state.rs`. | The lighting slice now exposes explicit azimuth/elevation controls in the shell and uses the light direction during deterministic CPU rendering; future work can layer in more VistaPro-like shading refinements if needed. |
| Vertical exaggeration | VistaPro manuals describe vertical scaling / scene exaggeration controls. | Implemented | `src/scene.rs` (`Scene.vertical_exaggeration`), `src/app.rs`, `src/app_state.rs`, `src/render.rs`, tests in `src/render.rs`, and scene-file round-trips in `src/scene_file.rs`. | The basic scene-level exaggeration slice is now wired through the shell, renderer, and scene files; future work can explore per-tool or legacy-style exaggeration workflows. | | Vertical exaggeration | VistaPro manuals describe vertical scaling / scene exaggeration controls. | Implemented | `src/scene.rs` (`Scene.vertical_exaggeration`), `src/app.rs`, `src/app_state.rs`, `src/render.rs`, tests in `src/render.rs`, and scene-file round-trips in `src/scene_file.rs`. | The basic scene-level exaggeration slice is now wired through the shell, renderer, and scene files; future work can explore per-tool or legacy-style exaggeration workflows. |
| Color maps / palettes / texture image loading | VistaPro 3 manual includes loading PCX images, adding texture, and saving/loading color maps. | Implemented | `src/scene.rs` (`Palette` thresholds/bands + colors), `src/app.rs` color-map editor, `src/colormap.rs`, `src/render.rs`, `src/script_exec.rs`, `src/scene_file.rs`. | Palette import/export and PCX/texture loading remain future clean-room work. | | Color maps / palettes / texture image loading | VistaPro 3 manual includes loading PCX images, adding texture, and saving/loading color maps. | Implemented | `src/scene.rs` (`Palette` thresholds/bands + colors), `src/app.rs` color-map editor, `src/colormap.rs`, `src/render.rs`, `src/script_exec.rs`, `src/scene_file.rs`. | Palette import/export and PCX/texture loading remain future clean-room work. |
| 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. |
@@ -36,9 +37,22 @@ Notes:
| Script language parser | MakePath guide and VistaPro manual describe scripts and “Run Script” workflows. | Implemented | `src/script.rs`, `src/script_exec.rs`, `src/cli.rs` (`script run`), `src/app_state.rs` script preview, `README.md` script section, tests in `src/script.rs` and `src/script_exec.rs`. | The project-owned grammar is now parsed and executed; any future work should focus on richer syntax or animation export, not basic parser support. | | Script language parser | MakePath guide and VistaPro manual describe scripts and “Run Script” workflows. | Implemented | `src/script.rs`, `src/script_exec.rs`, `src/cli.rs` (`script run`), `src/app_state.rs` script preview, `README.md` script section, tests in `src/script.rs` and `src/script_exec.rs`. | The project-owned grammar is now parsed and executed; any future work should focus on richer syntax or animation export, not basic parser support. |
| Script execution and animation frames | MakePath guide says scripts should render full animations and VistaPro can run scripts from the Script menu. | Implemented | `src/script_exec.rs` (`run_script` / `run_script_source`), `src/cli.rs` (`script run`), `src/app.rs` script controls, `src/app_state.rs`, tests in `src/script_exec.rs`. | The executor now runs preset/import/render slices; animation-frame sequencing is still a future extension. | | Script execution and animation frames | MakePath guide says scripts should render full animations and VistaPro can run scripts from the Script menu. | Implemented | `src/script_exec.rs` (`run_script` / `run_script_source`), `src/cli.rs` (`script run`), `src/app.rs` script controls, `src/app_state.rs`, tests in `src/script_exec.rs`. | The executor now runs preset/import/render slices; animation-frame sequencing is still a future extension. |
| 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). | Implemented | `src/path.rs`, `src/app_state.rs` (`make_path`), `src/app.rs` path controls, tests in `src/path.rs` and `src/app_state.rs`. | Core camera-path generation is now present; editable nodes, vehicle-specific motion models, and script export remain future expansion points. | | 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). | Implemented | `src/path.rs`, `src/app_state.rs` (`make_path`), `src/app.rs` path controls, tests in `src/path.rs` and `src/app_state.rs`. | Core camera-path generation is now present; editable nodes, vehicle-specific motion models, and script export remain future expansion points. |
| 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`, plus the `app` feature shell tests. | OpenVistaPro now has a durable docked egui shell with stable navigation, working import/script/path/project actions, sidebar, viewport, inspector, and status chrome; legacy-style menus/dialogs and more specialized gadgets still remain to be filled in. | | 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`, plus the `app` feature shell tests. | OpenVistaPro now has a durable docked egui shell with stable navigation, working import/script/path/project actions, top-level menus/help dialog surfacing, lighting numeric controls, sidebar, viewport, inspector, and status chrome; the remaining slice is the more specialized legacy-style menu/dialog workflow and the last set of numeric gadget affordances the manuals call out. |
| Legacy image / landscape export formats | VistaPro manuals mention saving rendered images and landscapes in formats like IFF/IFF24/RGB and DEM/binary landscape files. | Not planned | Current output is PNG plus project-owned `.ovp.toml` scenes. | Direct compatibility with legacy VistaPro export formats stays out of scope for the clean-room project; if it is ever reconsidered, it should come back through a separately reviewed investigation. | | Legacy image / landscape export formats | VistaPro manuals mention saving rendered images and landscapes in formats like IFF/IFF24/RGB and DEM/binary landscape files. | Not planned | Current output is PNG plus project-owned `.ovp.toml` scenes. | Direct compatibility with legacy VistaPro export formats stays out of scope for the clean-room project; if it is ever reconsidered, it should come back through a separately reviewed investigation. |
## Current reconciliation summary ## Current reconciliation summary
OpenVistaPro already covers the core clean-room pipeline: terrain grids, open importers, scene state, preview/final rendering, quality-profile tradeoffs, project-owned scene files, script execution, MakePath-style path generation, editable color-map thresholds/bands, and scene-level vertical exaggeration. The remaining VistaPro-specific gaps cluster around richer scene controls, animation-frame export, and the old dense UI/menu workflow; direct legacy export-format compatibility is intentionally not part of the current scope. OpenVistaPro already covers the core clean-room pipeline: terrain grids, project-owned and open terrain importers, scene state, preview/final rendering, quality-profile tradeoffs, project-owned scene files, script execution, MakePath-style path generation, editable color-map thresholds/bands, vertical exaggeration, and directional lighting. The remaining VistaPro-specific gaps now cluster around two Partial families: terrain-source compatibility boundary and the dense UI/menu/dialog/numeric-gadget workflow. Direct legacy image/landscape export compatibility remains Not planned.
## Next-wave implementation handoff
Use the following slice-by-slice scope when handing work to implementation workers:
| Slice | Scope to implement next | Evidence files to verify | Docs to update after the slice |
|---|---|---|---|
| Terrain-source compatibility boundary | Keep the supported source list explicit: project-owned `ovp-text`, script-level PNG heightmaps, and open SRTM/HGT, ESRI ASCII Grid, and GeoTIFF imports. Any legacy VistaPro compatibility claim must stay separate. | `src/import.rs`, `src/cli.rs`, `README.md` | `docs/knowledgebase/feature-inventory.md`, `docs/knowledgebase/ui-panel-map.md`, and `docs/legal/asset-policy.md` if the boundary wording changes. |
| Fractal / synthetic terrain generation | Completed slice: seeded fractal terrain generation is now implemented in the app shell, CLI, and script executor. | `src/terrain_gen.rs`, `src/app_state.rs`, `src/app.rs`, `src/cli.rs`, `tests/terrain_gen.rs`, `tests/script_exec.rs`, `tests/cli.rs`, `README.md` | Future procedural families can extend the shared settings/boundary without changing `HeightGrid`. |
| UI shell / menus / dialogs / numeric gadgets | Fill in the most valuable shell chrome and numeric-control workflows while keeping the modern docked shell. | `src/app.rs`, `src/ui_shell.rs`, shell tests, README UI notes | `docs/knowledgebase/feature-inventory.md`, `docs/knowledgebase/ui-panel-map.md`, and the README shell section. |
After each slice, update the implementation evidence column in this inventory and keep the UI panel map aligned so future workers do not have to rediscover the current shell coverage.
+189 -3
View File
@@ -15,6 +15,7 @@ pub struct OpenVistaProApp {
data: AppData, data: AppData,
shell: UiShellState, shell: UiShellState,
texture: Option<egui::TextureHandle>, texture: Option<egui::TextureHandle>,
show_about_dialog: bool,
} }
impl OpenVistaProApp { impl OpenVistaProApp {
@@ -59,10 +60,120 @@ impl OpenVistaProApp {
/// Top command/navigation bar. Every section is always present so /// Top command/navigation bar. Every section is always present so
/// navigation stays stable even where the surface is only a placeholder. /// navigation stays stable even where the surface is only a placeholder.
fn command_bar(&mut self, ctx: &egui::Context) { fn command_bar(&mut self, ctx: &egui::Context, action_note: &mut Option<String>) -> bool {
let scene_path = self
.data
.loaded_scene_path
.clone()
.unwrap_or_else(Self::default_scene_path);
let import_path = self
.data
.import_path
.clone()
.unwrap_or_else(Self::default_import_path);
let mut changed = false;
egui::TopBottomPanel::top("command_bar").show(ctx, |ui| { egui::TopBottomPanel::top("command_bar").show(ctx, |ui| {
ui.horizontal_wrapped(|ui| { ui.horizontal_wrapped(|ui| {
ui.strong(WINDOW_TITLE); ui.strong(WINDOW_TITLE);
ui.separator();
ui.menu_button("File", |ui| {
if ui.button("New scene").clicked() {
self.data.reset_scene();
self.data.loaded_scene_path = Some(scene_path.clone());
*action_note = Some(format!("reset scene and kept {scene_path}"));
changed = true;
ui.close();
}
if ui.button("Open scene…").clicked() {
let path = std::path::Path::new(&scene_path);
match self.data.open_scene(path) {
Ok(()) => {
*action_note = Some(format!("opened scene from {scene_path}"));
changed = true;
}
Err(error) => *action_note = Some(format!("open failed: {error}")),
}
ui.close();
}
if ui.button("Save scene").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.close();
}
if ui.button("Import heightmap…").clicked() {
let path = std::path::Path::new(&import_path);
match self.data.import_heightmap_from_path(path) {
Ok(()) => {
*action_note =
Some(format!("imported heightmap from {import_path}"));
changed = true;
}
Err(error) => *action_note = Some(format!("import failed: {error}")),
}
ui.close();
}
});
ui.menu_button("Scene", |ui| {
if ui.button("Top-down preview").clicked() {
self.data
.apply(AppAction::SetRendererMode(RendererMode::TopDown));
*action_note = Some("switched to top-down preview".to_string());
ui.close();
}
if ui.button("Perspective preview").clicked() {
self.data
.apply(AppAction::SetRendererMode(RendererMode::Perspective));
*action_note = Some("switched to perspective preview".to_string());
ui.close();
}
if ui.button("Reset hydrology").clicked() {
self.data.apply(AppAction::ResetHydrology);
*action_note = Some("reset hydrology overlays".to_string());
ui.close();
}
});
ui.menu_button("Tools", |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) => {
*action_note = Some(format!(
"script wrote {} output(s)",
report.outputs.len()
));
}
Err(error) => {
*action_note = Some(format!("script run failed: {error}"))
}
}
ui.close();
}
if ui.button("Make path").clicked() {
let path = self.data.make_path();
*action_note = Some(format!(
"generated {}",
self.data
.path_target
.clone()
.unwrap_or_else(|| format!("{} keyframes", path.keyframes().len()))
));
ui.close();
}
});
ui.menu_button("Help", |ui| {
if ui.button("About OpenVistaPro").clicked() {
self.show_about_dialog = true;
ui.close();
}
});
ui.separator(); ui.separator();
for &section in self.shell.sections() { for &section in self.shell.sections() {
let active = self.shell.is_active(section); let active = self.shell.is_active(section);
@@ -72,6 +183,7 @@ impl OpenVistaProApp {
} }
}); });
}); });
changed
} }
/// Left panel: controls contextual to the active section. Sections without /// Left panel: controls contextual to the active section. Sections without
@@ -103,6 +215,15 @@ impl OpenVistaProApp {
fn terrain_controls(&mut self, ui: &mut egui::Ui) -> bool { fn terrain_controls(&mut self, ui: &mut egui::Ui) -> bool {
let mut changed = false; let mut changed = false;
let mut preset = self.data.terrain_preset; let mut preset = self.data.terrain_preset;
changed |= ui
.radio_value(
&mut preset,
TerrainPreset::Fractal {
seed: self.data.terrain_seed,
},
"Fractal noise",
)
.changed();
changed |= ui changed |= ui
.radio_value(&mut preset, TerrainPreset::RadialHill, "Radial hill") .radio_value(&mut preset, TerrainPreset::RadialHill, "Radial hill")
.changed(); .changed();
@@ -112,6 +233,18 @@ impl OpenVistaProApp {
if preset != self.data.terrain_preset { if preset != self.data.terrain_preset {
self.data.apply(AppAction::SetTerrainPreset(preset)); self.data.apply(AppAction::SetTerrainPreset(preset));
} }
ui.separator();
ui.label("Fractal seed");
let mut seed = self.data.terrain_seed;
changed |= ui.add(egui::DragValue::new(&mut seed).speed(1.0)).changed();
if seed != self.data.terrain_seed {
self.data.apply(AppAction::SetTerrainSeed(seed));
if matches!(self.data.terrain_preset, TerrainPreset::Fractal { .. }) {
self.data
.apply(AppAction::SetTerrainPreset(TerrainPreset::Fractal { seed }));
}
}
changed changed
} }
@@ -219,6 +352,30 @@ impl OpenVistaProApp {
self.data self.data
.apply(AppAction::SetVerticalExaggeration(vertical_exaggeration)); .apply(AppAction::SetVerticalExaggeration(vertical_exaggeration));
ui.separator();
ui.label("Lighting");
let current_light = self.data.scene.light;
let mut azimuth = current_light.azimuth_degrees();
let mut elevation = current_light.elevation_degrees();
let mut light_intensity = current_light.intensity;
changed |= ui
.add(egui::Slider::new(&mut azimuth, -180.0..=180.0).text("Azimuth (°)"))
.changed();
changed |= ui
.add(egui::Slider::new(&mut elevation, -89.0..=89.0).text("Elevation (°)"))
.changed();
changed |= ui
.add(egui::Slider::new(&mut light_intensity, 0.0..=3.0).text("Intensity"))
.changed();
let mut updated_light = current_light;
updated_light.set_azimuth_elevation(azimuth, elevation);
updated_light.intensity = light_intensity.max(0.0);
self.data
.apply(AppAction::SetLightDirection(updated_light.direction));
self.data
.apply(AppAction::SetLightIntensity(updated_light.intensity));
ui.small("Azimuth rotates around the horizon; elevation tilts the light up and down.");
ui.separator(); ui.separator();
ui.label("Hydrology"); ui.label("Hydrology");
ui.horizontal(|ui| { ui.horizontal(|ui| {
@@ -467,6 +624,16 @@ impl OpenVistaProApp {
ui.label(format!("Water level: {:.2}", self.data.scene.water_level)); ui.label(format!("Water level: {:.2}", self.data.scene.water_level));
ui.label(format!("Tree line: {:.2}", self.data.scene.tree_line)); ui.label(format!("Tree line: {:.2}", self.data.scene.tree_line));
ui.label(format!("Snow line: {:.2}", self.data.scene.snow_line)); ui.label(format!("Snow line: {:.2}", self.data.scene.snow_line));
ui.label(format!(
"Light dir: ({:.2}, {:.2}, {:.2})",
self.data.scene.light.direction.x,
self.data.scene.light.direction.y,
self.data.scene.light.direction.z
));
ui.label(format!(
"Light intensity: {:.2}",
self.data.scene.light.intensity
));
}); });
} }
@@ -507,15 +674,34 @@ impl OpenVistaProApp {
}); });
}); });
} }
fn about_dialog(&mut self, ctx: &egui::Context) {
if !self.show_about_dialog {
return;
}
egui::Window::new("About OpenVistaPro")
.open(&mut self.show_about_dialog)
.resizable(false)
.collapsible(false)
.show(ctx, |ui| {
ui.label("OpenVistaPro is a clean-room egui terrain shell.");
ui.label("The menu bar exposes the same backend actions as the docks.");
ui.label("Scene lighting, camera numbers, and file/project workflow stay editable from the shell.");
ui.separator();
ui.label("Use the top menus for quick actions and the left dock for detailed controls.");
});
}
} }
impl eframe::App for OpenVistaProApp { impl eframe::App for OpenVistaProApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
let mut action_note: Option<String> = None; let mut action_note: Option<String> = None;
self.command_bar(ctx); let mut changed = self.command_bar(ctx, &mut action_note);
let changed = self.controls_panel(ctx, &mut action_note); changed |= self.controls_panel(ctx, &mut action_note);
self.inspector_panel(ctx); self.inspector_panel(ctx);
self.status_panel(ctx, action_note.as_deref()); self.status_panel(ctx, action_note.as_deref());
self.about_dialog(ctx);
if changed || self.texture.is_none() { if changed || self.texture.is_none() {
self.rebuild_texture(ctx); self.rebuild_texture(ctx);
+63 -1
View File
@@ -11,11 +11,13 @@ use crate::scene_file::{self, SceneFileError};
use crate::script::{Command, parse_script}; use crate::script::{Command, parse_script};
use crate::script_exec::{self, ExecReport, ScriptError}; use crate::script_exec::{self, ExecReport, ScriptError};
use crate::terrain::{HeightGrid, TerrainError}; use crate::terrain::{HeightGrid, TerrainError};
use crate::terrain_gen::{DeterministicTerrainGenerator, TerrainGenerationSpec, TerrainGenerator};
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TerrainPreset { pub enum TerrainPreset {
Plane, Plane,
RadialHill, RadialHill,
Fractal { seed: u64 },
} }
impl TerrainPreset { impl TerrainPreset {
@@ -23,6 +25,10 @@ impl TerrainPreset {
match self { match self {
TerrainPreset::Plane => HeightGrid::plane(width, height), TerrainPreset::Plane => HeightGrid::plane(width, height),
TerrainPreset::RadialHill => HeightGrid::radial_hill(width, height, 10.0), TerrainPreset::RadialHill => HeightGrid::radial_hill(width, height, 10.0),
TerrainPreset::Fractal { seed } => {
let spec = TerrainGenerationSpec::new(seed, width, height)?;
DeterministicTerrainGenerator::new().generate(&spec)
}
} }
} }
@@ -31,6 +37,7 @@ impl TerrainPreset {
match self { match self {
TerrainPreset::Plane => "Plane", TerrainPreset::Plane => "Plane",
TerrainPreset::RadialHill => "Radial hill", TerrainPreset::RadialHill => "Radial hill",
TerrainPreset::Fractal { .. } => "Fractal noise",
} }
} }
} }
@@ -139,6 +146,7 @@ pub struct UiShellSnapshot {
pub scene_controls_label: String, pub scene_controls_label: String,
pub palette_label: String, pub palette_label: String,
pub hydrology_label: String, pub hydrology_label: String,
pub lighting_label: String,
pub legacy_dialogs_label: String, pub legacy_dialogs_label: String,
pub scene_file_path: Option<String>, pub scene_file_path: Option<String>,
pub import_path: Option<String>, pub import_path: Option<String>,
@@ -151,6 +159,7 @@ pub struct UiShellSnapshot {
pub struct AppData { pub struct AppData {
pub scene: Scene, pub scene: Scene,
pub terrain_preset: TerrainPreset, pub terrain_preset: TerrainPreset,
pub terrain_seed: u64,
pub renderer_mode: RendererMode, pub renderer_mode: RendererMode,
pub render_quality: RenderQuality, pub render_quality: RenderQuality,
pub preview_size: (u32, u32), pub preview_size: (u32, u32),
@@ -178,6 +187,7 @@ impl Default for AppData {
Self { Self {
scene: Scene::default(), scene: Scene::default(),
terrain_preset: TerrainPreset::RadialHill, terrain_preset: TerrainPreset::RadialHill,
terrain_seed: 1337,
renderer_mode: RendererMode::TopDown, renderer_mode: RendererMode::TopDown,
render_quality: RenderQuality::Preview, render_quality: RenderQuality::Preview,
preview_size: (256, 256), preview_size: (256, 256),
@@ -198,6 +208,7 @@ impl AppData {
pub fn apply(&mut self, action: AppAction) { pub fn apply(&mut self, action: AppAction) {
match action { match action {
AppAction::SetTerrainPreset(preset) => self.terrain_preset = preset, AppAction::SetTerrainPreset(preset) => self.terrain_preset = preset,
AppAction::SetTerrainSeed(seed) => self.terrain_seed = seed,
AppAction::SetRendererMode(mode) => self.renderer_mode = mode, AppAction::SetRendererMode(mode) => self.renderer_mode = mode,
AppAction::SetRenderQuality(quality) => self.render_quality = quality, AppAction::SetRenderQuality(quality) => self.render_quality = quality,
AppAction::SetWaterLevel(value) => self.scene.water_level = value, AppAction::SetWaterLevel(value) => self.scene.water_level = value,
@@ -212,6 +223,22 @@ impl AppData {
AppAction::SetCameraOrientation(orientation) => { AppAction::SetCameraOrientation(orientation) => {
self.scene.camera.orientation = orientation self.scene.camera.orientation = orientation
} }
AppAction::SetLightDirection(direction) => {
let length_sq = direction.x * direction.x
+ direction.y * direction.y
+ direction.z * direction.z;
if length_sq > f32::EPSILON {
let length = length_sq.sqrt();
self.scene.light.direction = Vec3::new(
direction.x / length,
direction.y / length,
direction.z / length,
);
}
}
AppAction::SetLightIntensity(intensity) => {
self.scene.light.intensity = intensity.max(0.0)
}
AppAction::SetCameraLens { AppAction::SetCameraLens {
fov_degrees, fov_degrees,
near_range, near_range,
@@ -260,6 +287,7 @@ impl AppData {
self.generated_path = None; self.generated_path = None;
self.last_script_run = None; self.last_script_run = None;
self.terrain_preset = TerrainPreset::RadialHill; self.terrain_preset = TerrainPreset::RadialHill;
self.terrain_seed = 1337;
self.renderer_mode = RendererMode::TopDown; self.renderer_mode = RendererMode::TopDown;
self.render_quality = RenderQuality::Preview; self.render_quality = RenderQuality::Preview;
} }
@@ -320,6 +348,12 @@ impl AppData {
/// Build a pure snapshot of the UI shell state for the egui app to render. /// Build a pure snapshot of the UI shell state for the egui app to render.
pub fn ui_snapshot(&self) -> UiShellSnapshot { pub fn ui_snapshot(&self) -> UiShellSnapshot {
let (width, height) = self.preview_size; let (width, height) = self.preview_size;
let terrain_status = match self.terrain_preset {
TerrainPreset::Fractal { seed } => {
format!("{} seed {seed}", self.terrain_preset.label())
}
_ => self.terrain_preset.label().to_string(),
};
UiShellSnapshot { UiShellSnapshot {
terrain_preset_label: self.terrain_preset.label().to_string(), terrain_preset_label: self.terrain_preset.label().to_string(),
renderer_mode_label: self.renderer_mode.label().to_string(), renderer_mode_label: self.renderer_mode.label().to_string(),
@@ -330,13 +364,14 @@ impl AppData {
scene_controls_label: "Scene / camera / color map".to_string(), scene_controls_label: "Scene / camera / color map".to_string(),
palette_label: "Color map".to_string(), palette_label: "Color map".to_string(),
hydrology_label: "Hydrology".to_string(), hydrology_label: "Hydrology".to_string(),
lighting_label: "Lighting".to_string(),
legacy_dialogs_label: "Legacy dialogs".to_string(), legacy_dialogs_label: "Legacy dialogs".to_string(),
scene_file_path: self.loaded_scene_path.clone(), scene_file_path: self.loaded_scene_path.clone(),
import_path: self.import_path.clone(), import_path: self.import_path.clone(),
path_target: self.path_target.clone(), path_target: self.path_target.clone(),
status_line: format!( status_line: format!(
"CPU preview · {} · {} · {} · exag {:.2} · {width}×{height}", "CPU preview · {} · {} · {} · exag {:.2} · {width}×{height}",
self.terrain_preset.label(), terrain_status,
self.renderer_mode.label(), self.renderer_mode.label(),
self.render_quality.label(), self.render_quality.label(),
self.scene.vertical_exaggeration, self.scene.vertical_exaggeration,
@@ -371,6 +406,7 @@ impl AppData {
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub enum AppAction { pub enum AppAction {
SetTerrainPreset(TerrainPreset), SetTerrainPreset(TerrainPreset),
SetTerrainSeed(u64),
SetRendererMode(RendererMode), SetRendererMode(RendererMode),
SetRenderQuality(RenderQuality), SetRenderQuality(RenderQuality),
SetWaterLevel(f32), SetWaterLevel(f32),
@@ -381,6 +417,8 @@ pub enum AppAction {
SetCameraPosition(Vec3), SetCameraPosition(Vec3),
SetCameraTarget(Vec3), SetCameraTarget(Vec3),
SetCameraOrientation(Vec3), SetCameraOrientation(Vec3),
SetLightDirection(Vec3),
SetLightIntensity(f32),
SetCameraLens { SetCameraLens {
fov_degrees: f32, fov_degrees: f32,
near_range: f32, near_range: f32,
@@ -418,6 +456,7 @@ mod tests {
assert_eq!(app.scene, Scene::default()); assert_eq!(app.scene, Scene::default());
assert_eq!(app.terrain_preset, TerrainPreset::RadialHill); assert_eq!(app.terrain_preset, TerrainPreset::RadialHill);
assert_eq!(app.terrain_seed, 1337);
assert_eq!(app.renderer_mode, RendererMode::TopDown); assert_eq!(app.renderer_mode, RendererMode::TopDown);
assert_eq!(app.preview_size, (256, 256)); assert_eq!(app.preview_size, (256, 256));
assert!(app.loaded_scene_path.is_some()); assert!(app.loaded_scene_path.is_some());
@@ -485,13 +524,35 @@ mod tests {
assert_eq!(app.renderer_mode, RendererMode::Perspective); assert_eq!(app.renderer_mode, RendererMode::Perspective);
} }
#[test]
fn reducer_updates_lighting_and_normalizes_direction() {
let mut app = AppData::default();
app.apply(AppAction::SetLightDirection(Vec3::new(10.0, -10.0, 0.0)));
app.apply(AppAction::SetLightIntensity(-1.0));
let direction = app.scene.light.direction;
let length =
(direction.x * direction.x + direction.y * direction.y + direction.z * direction.z)
.sqrt();
assert!((length - 1.0).abs() < 1e-5);
assert!(app.scene.light.direction.y < 0.0);
assert_eq!(app.scene.light.intensity, 0.0);
}
#[test] #[test]
fn terrain_preset_builds_expected_height_grid() { fn terrain_preset_builds_expected_height_grid() {
let plane = TerrainPreset::Plane.build_grid(8, 4).unwrap(); let plane = TerrainPreset::Plane.build_grid(8, 4).unwrap();
let hill = TerrainPreset::RadialHill.build_grid(9, 9).unwrap(); let hill = TerrainPreset::RadialHill.build_grid(9, 9).unwrap();
let fractal = TerrainPreset::Fractal { seed: 1337 }
.build_grid(8, 4)
.unwrap();
assert_eq!(plane.min_max(), Some((0.0, 0.0))); assert_eq!(plane.min_max(), Some((0.0, 0.0)));
assert!(hill.min_max().unwrap().1 > 0.0); assert!(hill.min_max().unwrap().1 > 0.0);
assert!(fractal.min_max().unwrap().1 <= 1.0);
assert_ne!(fractal.sample(0, 0), plane.sample(0, 0));
} }
#[test] #[test]
@@ -535,6 +596,7 @@ mod tests {
assert_eq!(shell.script_label, "Scripts / paths"); assert_eq!(shell.script_label, "Scripts / paths");
assert_eq!(shell.path_label, "Path tools"); assert_eq!(shell.path_label, "Path tools");
assert_eq!(shell.palette_label, "Color map"); assert_eq!(shell.palette_label, "Color map");
assert_eq!(shell.lighting_label, "Lighting");
assert!(shell.scene_file_path.is_some()); assert!(shell.scene_file_path.is_some());
assert!(shell.import_path.is_some()); assert!(shell.import_path.is_some());
assert!(shell.path_target.is_none()); assert!(shell.path_target.is_none());
+59 -1
View File
@@ -13,6 +13,7 @@ use crate::scene::Scene;
use crate::scene_file::{self, SceneFileError}; use crate::scene_file::{self, SceneFileError};
use crate::script_exec::{self, ScriptError}; use crate::script_exec::{self, ScriptError};
use crate::terrain::{HeightGrid, TerrainError}; use crate::terrain::{HeightGrid, TerrainError};
use crate::terrain_gen::{DeterministicTerrainGenerator, TerrainGenerationSpec, TerrainGenerator};
const HILL_PEAK_HEIGHT: f32 = 10.0; const HILL_PEAK_HEIGHT: f32 = 10.0;
@@ -63,6 +64,9 @@ pub struct RenderArgs {
/// Render-quality preset for the CPU spike. /// Render-quality preset for the CPU spike.
#[arg(long, value_enum, default_value_t = RenderQualityPreset::Preview)] #[arg(long, value_enum, default_value_t = RenderQualityPreset::Preview)]
pub quality: RenderQualityPreset, pub quality: RenderQualityPreset,
/// Seed for the procedural fractal preset.
#[arg(long, default_value_t = 1337)]
pub seed: u64,
} }
#[derive(Debug, Clone, Args)] #[derive(Debug, Clone, Args)]
@@ -107,6 +111,7 @@ pub struct ScriptRunArgs {
pub enum Preset { pub enum Preset {
Plane, Plane,
Hill, Hill,
Fractal,
} }
#[derive(Debug)] #[derive(Debug)]
@@ -159,12 +164,13 @@ impl Preset {
match self { match self {
Preset::Plane => "plane", Preset::Plane => "plane",
Preset::Hill => "hill", Preset::Hill => "hill",
Preset::Fractal => "fractal",
} }
} }
} }
pub fn supported_presets() -> &'static [&'static str] { pub fn supported_presets() -> &'static [&'static str] {
&["plane", "hill"] &["plane", "hill", "fractal"]
} }
pub fn supported_quality_presets() -> &'static [&'static str] { pub fn supported_quality_presets() -> &'static [&'static str] {
@@ -286,6 +292,10 @@ pub fn execute(cli: Cli) -> Result<(), CliError> {
let grid = match args.preset { let grid = match args.preset {
Preset::Plane => HeightGrid::plane(args.width, args.height)?, Preset::Plane => HeightGrid::plane(args.width, args.height)?,
Preset::Hill => HeightGrid::radial_hill(args.width, args.height, HILL_PEAK_HEIGHT)?, Preset::Hill => HeightGrid::radial_hill(args.width, args.height, HILL_PEAK_HEIGHT)?,
Preset::Fractal => {
let spec = TerrainGenerationSpec::new(args.seed, args.width, args.height)?;
DeterministicTerrainGenerator::new().generate(&spec)?
}
}; };
let output = resolve_render_output_path(&args.output, args.preset, args.quality); let output = resolve_render_output_path(&args.output, args.preset, args.quality);
let mut scene = if let Some(path) = args.scene.as_deref() { let mut scene = if let Some(path) = args.scene.as_deref() {
@@ -364,6 +374,28 @@ mod tests {
} }
} }
#[test]
fn parses_render_with_fractal_preset_and_seed() {
let cli = Cli::try_parse_from([
"openvistapro",
"render",
"--preset",
"fractal",
"--seed",
"42",
"--output",
"/tmp/out.png",
])
.unwrap();
match cli.command {
Command::Render(args) => {
assert_eq!(args.preset, Preset::Fractal);
assert_eq!(args.seed, 42);
}
_ => panic!("expected render"),
}
}
#[test] #[test]
fn parses_render_with_plane_preset_and_default_dimensions() { fn parses_render_with_plane_preset_and_default_dimensions() {
let cli = Cli::try_parse_from([ let cli = Cli::try_parse_from([
@@ -409,6 +441,7 @@ mod tests {
let presets = supported_presets(); let presets = supported_presets();
assert!(presets.contains(&"plane")); assert!(presets.contains(&"plane"));
assert!(presets.contains(&"hill")); assert!(presets.contains(&"hill"));
assert!(presets.contains(&"fractal"));
} }
#[test] #[test]
@@ -472,6 +505,7 @@ mod tests {
let text = info_text(); let text = info_text();
assert!(text.contains("plane")); assert!(text.contains("plane"));
assert!(text.contains("hill")); assert!(text.contains("hill"));
assert!(text.contains("fractal"));
} }
#[test] #[test]
@@ -541,6 +575,30 @@ mod tests {
std::fs::remove_file(&path).ok(); std::fs::remove_file(&path).ok();
} }
#[test]
fn execute_render_fractal_writes_png() {
let path = temp_output_path("fractal");
let cli = Cli::try_parse_from([
"openvistapro",
"render",
"--preset",
"fractal",
"--seed",
"42",
"--width",
"8",
"--height",
"8",
"--output",
path.to_str().unwrap(),
])
.unwrap();
execute(cli).expect("execute should succeed");
let bytes = std::fs::read(&path).expect("file should exist");
assert!(bytes.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]));
std::fs::remove_file(&path).ok();
}
#[test] #[test]
fn parses_render_with_camera_demo_flag() { fn parses_render_with_camera_demo_flag() {
let cli = Cli::try_parse_from([ let cli = Cli::try_parse_from([
+97 -7
View File
@@ -64,7 +64,8 @@ pub fn render_top_down(grid: &HeightGrid, scene: &Scene) -> RgbImage {
for x in 0..w { for x in 0..w {
let elevation = grid.sample(x, y).unwrap_or(0.0) * vertical_exaggeration; let elevation = grid.sample(x, y).unwrap_or(0.0) * vertical_exaggeration;
let (nx, nz) = normalized_coords(x, y, w, h); let (nx, nz) = normalized_coords(x, y, w, h);
let color = surface_color(scene, elevation, nx, nz); let normal = top_down_surface_normal(grid, scene, x, y);
let color = surface_color(scene, elevation, nx, nz, normal);
img.put_pixel(x, y, Rgb(color)); img.put_pixel(x, y, Rgb(color));
} }
} }
@@ -194,12 +195,64 @@ fn hydrology_overlay_color(scene: &Scene, nx: f32, nz: f32) -> Option<[u8; 3]> {
}) })
} }
fn surface_color(scene: &Scene, elevation: f32, nx: f32, nz: f32) -> [u8; 3] { fn scale_color(color: [u8; 3], factor: f32) -> [u8; 3] {
let factor = factor.max(0.0);
[
(color[0] as f32 * factor).clamp(0.0, 255.0) as u8,
(color[1] as f32 * factor).clamp(0.0, 255.0) as u8,
(color[2] as f32 * factor).clamp(0.0, 255.0) as u8,
]
}
fn surface_lighting(scene: &Scene, normal: Vec3) -> f32 {
let normal = v_normalize(normal);
let incoming = v_scale(scene.light.normalized_direction(), -1.0);
let default_incoming = v_scale(crate::scene::Light::default().normalized_direction(), -1.0);
let diffuse = v_dot(normal, incoming).max(0.0);
let default_diffuse = v_dot(normal, default_incoming).max(0.0);
let intensity_delta = (scene.light.intensity.max(0.0) - 1.0) * 0.4;
(1.0 + (diffuse - default_diffuse) * 0.9 + intensity_delta).clamp(0.2, 2.0)
}
fn top_down_surface_normal(grid: &HeightGrid, scene: &Scene, x: u32, y: u32) -> Vec3 {
let width = grid.width();
let height = grid.height();
let sample = |sx: u32, sy: u32| terrain_height(scene, grid.sample(sx, sy).unwrap_or(0.0));
let x0 = x.saturating_sub(1);
let x1 = (x + 1).min(width - 1);
let y0 = y.saturating_sub(1);
let y1 = (y + 1).min(height - 1);
let left = sample(x0, y);
let right = sample(x1, y);
let up = sample(x, y0);
let down = sample(x, y1);
Vec3::new(left - right, 2.0, up - down)
}
fn perspective_surface_normal(grid: &HeightGrid, scene: &Scene, x: f32, z: f32) -> Vec3 {
let center = sample_height_bilinear(grid, x, z)
.map(|h| terrain_height(scene, h))
.unwrap_or(terrain_height(scene, 0.0));
let sample = |sx: f32, sz: f32| {
sample_height_bilinear(grid, sx, sz)
.map(|h| terrain_height(scene, h))
.unwrap_or(center)
};
let left = sample(x - 1.0, z);
let right = sample(x + 1.0, z);
let up = sample(x, z - 1.0);
let down = sample(x, z + 1.0);
Vec3::new(left - right, 2.0, up - down)
}
fn surface_color(scene: &Scene, elevation: f32, nx: f32, nz: f32, normal: Vec3) -> [u8; 3] {
let base = scene_color(scene, elevation); let base = scene_color(scene, elevation);
match hydrology_overlay_color(scene, nx, nz) { let band = match hydrology_overlay_color(scene, nx, nz) {
Some(overlay) => mix_color(base, overlay, 0.9), Some(overlay) => mix_color(base, overlay, 0.9),
None => base, None => base,
} };
scale_color(band, surface_lighting(scene, normal))
} }
fn v_add(a: Vec3, b: Vec3) -> Vec3 { fn v_add(a: Vec3, b: Vec3) -> Vec3 {
@@ -387,7 +440,8 @@ pub fn render_perspective_with_quality(
} else { } else {
0.5 0.5
}; };
let band = surface_color(scene, terrain_h, nx, nz); let normal = perspective_surface_normal(grid, scene, p.x, p.z);
let band = surface_color(scene, terrain_h, nx, nz, normal);
let distance_fade = (t / max_dist).clamp(0.0, 1.0); let distance_fade = (t / max_dist).clamp(0.0, 1.0);
let fade = distance_fade * haze_strength; let fade = distance_fade * haze_strength;
hit_color = Some(mix_color(band, sky, fade)); hit_color = Some(mix_color(band, sky, fade));
@@ -420,8 +474,7 @@ pub fn render_perspective_to_path(
mod tests { mod tests {
use super::*; use super::*;
use crate::colormap::{HIGHLAND_COLOR, LOWLAND_COLOR, SNOW_COLOR, WATER_COLOR}; use crate::colormap::{HIGHLAND_COLOR, LOWLAND_COLOR, SNOW_COLOR, WATER_COLOR};
use crate::scene::{Hydrology, Scene}; use crate::scene::{Hydrology, Light, Scene};
fn fixture_scene() -> Scene { fn fixture_scene() -> Scene {
Scene::default() Scene::default()
} }
@@ -517,6 +570,43 @@ mod tests {
assert_ne!(low_img.as_raw(), high_img.as_raw()); assert_ne!(low_img.as_raw(), high_img.as_raw());
} }
#[test]
fn render_top_down_responds_to_light_direction() {
let grid = HeightGrid::radial_hill(33, 33, 10.0).unwrap();
let east_light = Scene {
light: Light::from_azimuth_elevation(0.0, 55.0, 1.0),
..fixture_scene()
};
let west_light = Scene {
light: Light::from_azimuth_elevation(180.0, 55.0, 1.0),
..fixture_scene()
};
let east_img = render_top_down(&grid, &east_light);
let west_img = render_top_down(&grid, &west_light);
assert_ne!(east_img.as_raw(), west_img.as_raw());
}
#[test]
fn render_perspective_responds_to_light_intensity() {
let grid = HeightGrid::radial_hill(32, 32, 10.0).unwrap();
let base_scene = demo_scene(&grid);
let dim_scene = Scene {
light: Light::from_azimuth_elevation(-25.0, 60.0, 0.2),
..base_scene
};
let bright_scene = Scene {
light: Light::from_azimuth_elevation(-25.0, 60.0, 1.8),
..base_scene
};
let dim_img = render_perspective(&grid, &dim_scene, 32, 32);
let bright_img = render_perspective(&grid, &bright_scene, 32, 32);
assert_ne!(dim_img.as_raw(), bright_img.as_raw());
}
#[test] #[test]
fn render_is_deterministic() { fn render_is_deterministic() {
let grid = HeightGrid::radial_hill(16, 16, 10.0).unwrap(); let grid = HeightGrid::radial_hill(16, 16, 10.0).unwrap();
+88
View File
@@ -63,6 +63,70 @@ impl Default for Light {
} }
} }
impl Light {
/// Returns the light direction normalized to unit length.
pub fn normalized_direction(self) -> Vec3 {
normalize_vec3(self.direction)
}
/// Horizontal rotation of the light direction in degrees.
///
/// The value is derived from the stored direction vector so scene files can
/// continue to serialize the same clean-room `direction` field while the UI
/// exposes more user-friendly azimuth/elevation controls.
pub fn azimuth_degrees(self) -> f32 {
let direction = self.normalized_direction();
direction.z.atan2(direction.x).to_degrees()
}
/// Vertical angle of the light direction in degrees.
pub fn elevation_degrees(self) -> f32 {
let direction = self.normalized_direction();
let horizontal = (direction.x * direction.x + direction.z * direction.z).sqrt();
(-direction.y).atan2(horizontal).to_degrees()
}
/// Updates the stored direction from azimuth/elevation controls.
pub fn set_azimuth_elevation(&mut self, azimuth_degrees: f32, elevation_degrees: f32) {
self.direction = direction_from_azimuth_elevation(azimuth_degrees, elevation_degrees);
}
/// Creates a light from azimuth/elevation controls and an explicit intensity.
pub fn from_azimuth_elevation(
azimuth_degrees: f32,
elevation_degrees: f32,
intensity: f32,
) -> Self {
let mut light = Self {
direction: Vec3::new(0.0, -1.0, 0.0),
intensity: intensity.max(0.0),
};
light.set_azimuth_elevation(azimuth_degrees, elevation_degrees);
light
}
}
fn normalize_vec3(vec: Vec3) -> Vec3 {
let length_sq = vec.x * vec.x + vec.y * vec.y + vec.z * vec.z;
if length_sq <= f32::EPSILON {
Vec3::new(0.0, -1.0, 0.0)
} else {
let inv_len = length_sq.sqrt().recip();
Vec3::new(vec.x * inv_len, vec.y * inv_len, vec.z * inv_len)
}
}
fn direction_from_azimuth_elevation(azimuth_degrees: f32, elevation_degrees: f32) -> Vec3 {
let azimuth = azimuth_degrees.to_radians();
let elevation = elevation_degrees.to_radians();
let horizontal = elevation.cos();
normalize_vec3(Vec3::new(
horizontal * azimuth.cos(),
-elevation.sin(),
horizontal * azimuth.sin(),
))
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(default)] #[serde(default)]
pub struct Palette { pub struct Palette {
@@ -238,6 +302,30 @@ mod tests {
assert!(light.intensity > 0.0); assert!(light.intensity > 0.0);
} }
#[test]
fn light_angle_helpers_round_trip_direction() {
let light = Light::from_azimuth_elevation(25.0, 55.0, 1.25);
assert!((light.azimuth_degrees() - 25.0).abs() < 0.05);
assert!((light.elevation_degrees() - 55.0).abs() < 0.05);
assert!(
(light.normalized_direction().x * light.normalized_direction().x
+ light.normalized_direction().y * light.normalized_direction().y
+ light.normalized_direction().z * light.normalized_direction().z
- 1.0)
.abs()
< 0.0001
);
}
#[test]
fn light_direction_updates_from_angles() {
let mut light = Light::default();
light.set_azimuth_elevation(-90.0, 30.0);
assert!(light.direction.x.abs() < 0.001);
assert!(light.direction.y < 0.0);
assert!(light.direction.z < 0.0);
}
#[test] #[test]
fn scene_default_has_ordered_elevation_bands() { fn scene_default_has_ordered_elevation_bands() {
let s = Scene::default(); let s = Scene::default();
+22 -1
View File
@@ -26,11 +26,14 @@ use crate::render::render_top_down_to_path;
use crate::scene::Scene; 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};
use crate::terrain_gen::{DeterministicTerrainGenerator, TerrainGenerationSpec, TerrainGenerator};
/// Edge length of the terrain grid generated for `use preset` commands. /// 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. /// 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;
/// Seed for the project-owned fractal preset so scripts stay reproducible.
const FRACTAL_SEED: u64 = 1337;
/// Elevation that a fully white (255) heightmap pixel maps to. /// Elevation that a fully white (255) heightmap pixel maps to.
const HEIGHTMAP_PEAK_HEIGHT: f32 = 10.0; const HEIGHTMAP_PEAK_HEIGHT: f32 = 10.0;
@@ -127,6 +130,10 @@ pub fn run_script(script: &Script, base_dir: &Path) -> Result<ExecReport, Script
for command in &script.commands { for command in &script.commands {
match command { match command {
Command::UsePreset(PresetName::Fractal) => {
let spec = TerrainGenerationSpec::new(FRACTAL_SEED, PRESET_SIZE, PRESET_SIZE)?;
grid = Some(DeterministicTerrainGenerator::new().generate(&spec)?);
}
Command::UsePreset(PresetName::Hill) => { Command::UsePreset(PresetName::Hill) => {
grid = Some(HeightGrid::radial_hill( grid = Some(HeightGrid::radial_hill(
PRESET_SIZE, PRESET_SIZE,
@@ -219,7 +226,7 @@ mod tests {
fn run_script_renders_preset_to_png() { fn run_script_renders_preset_to_png() {
let dir = temp_dir("preset"); let dir = temp_dir("preset");
let script = parse_script( let script = parse_script(
"use preset hill\nset thresholds water=1.0 tree=4.0 snow=7.0\nrender output \"demo.png\"", "use preset fractal\nset thresholds water=1.0 tree=4.0 snow=7.0\nrender output \"demo.png\"",
) )
.unwrap(); .unwrap();
let report = run_script(&script, &dir).expect("script should execute"); let report = run_script(&script, &dir).expect("script should execute");
@@ -232,6 +239,20 @@ mod tests {
std::fs::remove_dir_all(&dir).ok(); std::fs::remove_dir_all(&dir).ok();
} }
#[test]
fn run_script_fractal_preset_is_deterministic() {
let dir = temp_dir("fractal");
let script = parse_script("use preset fractal\nrender output \"fractal.png\"").unwrap();
let first = run_script(&script, &dir).expect("first run should succeed");
let second = run_script(&script, &dir).expect("second run should succeed");
assert_eq!(first.outputs, second.outputs);
assert_eq!(first.outputs.len(), 1);
assert!(dir.join("fractal.png").exists());
std::fs::remove_dir_all(&dir).ok();
}
#[test] #[test]
fn run_script_creates_missing_output_directories() { fn run_script_creates_missing_output_directories() {
let dir = temp_dir("nested"); let dir = temp_dir("nested");