feat: wire vertical exaggeration through the shell #13
@@ -13,6 +13,7 @@ This repository currently contains:
|
|||||||
- 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, 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 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`.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ 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 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 --features app --bin openvistapro_app
|
cargo run --features app --bin openvistapro_app
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -56,6 +58,7 @@ cargo test --all-features
|
|||||||
```
|
```
|
||||||
|
|
||||||
The default `render` mode writes a deterministic top-down elevation preview.
|
The default `render` mode writes a deterministic top-down elevation preview.
|
||||||
|
Passing `--quality preview|balanced|final` selects the project-owned quality profile; if `--output` points to an existing directory, the renderer derives a quality-specific PNG name there.
|
||||||
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
|
||||||
size, sky gradient, and distance haze. It is intended as a readable reference
|
size, sky gradient, and distance haze. It is intended as a readable reference
|
||||||
@@ -64,9 +67,10 @@ 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, light, water, tree-line, snow-line, and haze
|
`Scene` payload containing camera position/target, camera heading-pitch-bank,
|
||||||
settings. The format is intentionally human-readable while the data model is
|
lens/FOV/clip ranges, light, water, tree-line, snow-line, haze, and hydrology
|
||||||
still evolving.
|
overlays/settings. The format is intentionally human-readable while the data
|
||||||
|
model is still evolving.
|
||||||
|
|
||||||
## Script language (MVP)
|
## Script language (MVP)
|
||||||
|
|
||||||
|
|||||||
@@ -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: 7
|
- Implemented: 12
|
||||||
- Partial: 7
|
- Partial: 5
|
||||||
- Planned: 6
|
- Planned: 3
|
||||||
|
|
||||||
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.fov_degrees`), `src/render.rs` perspective renderer. | No explicit bank/heading/pitch model or legacy lens/range 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. |
|
||||||
| 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`. | 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. | Planned | Not yet represented in `Scene` or renderer code. | Add hydrology controls/data model before claiming this family. |
|
| 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 VistaPro’s lighting workflow and lacks richer light controls. |
|
| Light direction and custom lighting | Manuals discuss sunlight placement and lighting experiments. | Partial | `src/scene.rs` (`Light`), `src/render.rs`, `src/app.rs` (light state exists in the scene model even if UI is minimal). | The current model is much simpler than VistaPro’s lighting workflow and lacks richer light controls. |
|
||||||
| Vertical exaggeration | VistaPro manuals describe vertical scaling / scene exaggeration controls. | Planned | No dedicated field or control in the current scene model. | Add an explicit vertical-scale parameter and render integration. |
|
| 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. | 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. |
|
| 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. |
|
||||||
| 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. | Implemented | `src/render.rs` (`RenderQualityPreset`, quality-aware top-down/perspective render helpers), `src/cli.rs` (`--quality`, output-path resolution), `src/app_state.rs`, `src/app.rs`, tests in `src/render.rs`, `src/app_state.rs`, and `src/cli.rs`. | The project-owned quality profile slice now toggles preview/balanced/final tradeoffs for both the CLI spike and the egui shell. |
|
||||||
| 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`, `README.md` script section. | Parser exists, but script execution is intentionally deferred. |
|
| 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. | Planned | No script runner or frame-sequencing engine exists yet. | Add execution semantics once the command model is stable. |
|
| 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). | Planned | No path generator or motion-model layer exists yet. | This is a separate planner/animation feature, not just a script parser. |
|
| 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`. | 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. |
|
| 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. |
|
||||||
| 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. | Separate compatibility/export work remains future scope 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 old dense UI/menu workflow.
|
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 legacy compatibility, richer scene controls, animation-frame export, and the old dense UI/menu workflow.
|
||||||
|
|||||||
@@ -6,14 +6,14 @@ This is a normalized modern shell map derived from the VistaPro manuals, screens
|
|||||||
|
|
||||||
| Modern panel | VistaPro surfaces it absorbs | Suggested placement | Current code support | Notes / gaps |
|
| Modern panel | VistaPro surfaces it absorbs | Suggested placement | Current code support | Notes / gaps |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| Viewport / preview | Main render window, map preview, perspective view, preview/final render output | Center dock | Partial | `src/app.rs` already renders the CPU preview into `CentralPanel`; perspective and top-down preview modes exist, but there is no GPU viewport or direct manipulation overlay yet. |
|
| Viewport / preview | Main render window, map preview, perspective view, preview/final render output | Center dock | Partial | `src/app.rs` renders the CPU preview into `CentralPanel`; perspective and top-down preview modes exist, but there is no GPU viewport or direct manipulation overlay yet. |
|
||||||
| Terrain / import | Load Landscape, Import, terrain source selection, generated terrain presets | Left dock or collapsible section | Partial | The current shell exposes project-owned terrain presets (`Plane`, `RadialHill`) and a placeholder import entry point; legacy format import UI is still absent. |
|
| Terrain / import | Load Landscape, Import, terrain source selection, generated terrain presets | Left dock or collapsible section | Partial | The current shell exposes project-owned terrain presets (`Plane`, `RadialHill`) and a working heightmap import action; legacy format import UI is still absent. |
|
||||||
| Scene / camera | Camera and target gadgets, lens/range, bank/heading/pitch, water/tree/snow/haze controls | Left dock or inspector stack | Partial | Position/target, orientation, lens/range, vertical exaggeration, palette, and hydrology controls now live in `src/app.rs` and `src/app_state.rs`; `src/scene.rs` and `src/render.rs` carry the model/render semantics. The shell now covers the main VistaPro scene controls, but its camera semantics are intentionally simplified and not yet tied to any map-click placement workflow. |
|
| Scene / camera | Camera and target gadgets, lens/range, bank/heading/pitch, water/tree/snow/haze controls | Left dock or inspector stack | Partial | Position/target, explicit heading/pitch/bank controls, lens/FOV/clip range controls, vertical exaggeration, color-map editing, and hydrology overlays now live in `src/app.rs` and `src/app_state.rs`; `src/scene.rs`, `src/render.rs`, and `src/script_exec.rs` carry the model/render semantics. The shell covers the main VistaPro scene controls, but its camera semantics are intentionally simplified and not yet tied to any map-click placement workflow. |
|
||||||
| Render | Preview vs final render, quality/smoothing, detail tradeoffs | Left dock, toolbar, or render tab | Partial | Current code toggles top-down vs perspective render mode, but there is no dedicated quality profile or render preset UI. |
|
| Render | Preview vs final render, quality/smoothing, detail tradeoffs | Left dock, toolbar, or render tab | Partial | Current code now exposes preview/balanced/final quality presets alongside top-down vs perspective render mode; the shell still lacks the full legacy menu chrome and fine-grained smoothing sliders. |
|
||||||
| Scripts / paths | Script menu, Run Script, MakePath path tools, animation-frame workflows | Right dock or modal workflow | Partial | Script parsing exists in the codebase, the shell now surfaces a script editor and placeholder path controls, but execution and path generation are still deferred. |
|
| Scripts / paths | Script menu, Run Script, MakePath path tools, animation-frame workflows | Right dock or modal workflow | Partial | Script parsing/execution and MakePath-style path generation now run end-to-end in the backend; the shell surfaces a script editor, Run Script, and Make Path controls, but animation-frame export and richer path editing are still future work. |
|
||||||
| File / project actions | New/Open/Save landscape, scene load/save, export commands | Top bar / file menu | Partial | The shell now shows scene-file status and disabled file actions; load/save execution is still wired only in the backend modules. |
|
| File / project actions | New/Open/Save landscape, scene load/save, export commands | Top bar / file menu | Partial | The shell now shows scene-file status and working New/Open/Save controls; legacy menu chrome and export dialogs are still missing. |
|
||||||
| Status / feedback | Coordinate readouts, render state, file path, progress, messages | Bottom status bar | Partial | The shell now has a bottom status bar driven by `AppData::ui_snapshot()`. |
|
| Status / feedback | Coordinate readouts, render state, file path, progress, messages | Bottom status bar | Present | The shell now has a bottom status bar driven by `AppData::ui_snapshot()`. |
|
||||||
| Deferred features / legacy compatibility | Dense menu hierarchy, advanced lighting, hydrology, vertical exaggeration, palette editing, legacy image/landscape exports | Right dock, tool drawer, or dialogs | Planned | These are best surfaced as future tabs or dialogs rather than cluttering the initial shell. |
|
| Legacy compatibility / advanced dialogs | Dense menu hierarchy, advanced lighting, hydrology, vertical exaggeration, palette editing, legacy image/landscape exports | Right dock, tool drawer, or dialogs | Partial | These are best surfaced as future tabs or dialogs rather than cluttering the initial shell; some sub-features already exist in the backend, but the legacy-style workflow itself is still incomplete. |
|
||||||
|
|
||||||
## Recommended shell structure
|
## Recommended shell structure
|
||||||
|
|
||||||
@@ -32,19 +32,19 @@ That layout preserves the VistaPro workflow while making room for modern discove
|
|||||||
| Panel | Code support today | Implementation evidence | Priority |
|
| Panel | Code support today | Implementation evidence | Priority |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Viewport / preview | Present | `src/app.rs` renders the preview into `CentralPanel`; `src/app_state.rs` builds the preview image. | High |
|
| Viewport / preview | Present | `src/app.rs` renders the preview into `CentralPanel`; `src/app_state.rs` builds the preview image. | High |
|
||||||
| Terrain / import | Partial | `TerrainPreset` and `AppAction::SetTerrainPreset` in `src/app_state.rs`; terrain radio buttons in `src/app.rs`. | High |
|
| Terrain / import | Partial | `TerrainPreset` and `AppAction::SetTerrainPreset` in `src/app_state.rs`; terrain radio buttons and import action in `src/app.rs`. | High |
|
||||||
| Scene / camera | Partial | `Scene`, camera position/target controls, and scene-band sliders in `src/app.rs` and `src/app_state.rs`. | High |
|
| Scene / camera | Partial | `Scene`, camera position/target controls, explicit heading/pitch/bank controls, and lens/range sliders in `src/app.rs` and `src/app_state.rs`. | High |
|
||||||
| Render | Partial | `RendererMode` plus preview switching in `src/app_state.rs` and `src/app.rs`. | High |
|
| Render | Partial | `RendererMode` plus preview switching in `src/app_state.rs` and `src/app.rs`. | High |
|
||||||
| Scripts / paths | Not yet | `docs/knowledgebase/feature-inventory.md` marks scripts and path generation as planned. | Medium |
|
| Scripts / paths | Partial | `src/script.rs`, `src/script_exec.rs`, `src/path.rs`, and the script/path controls in `src/app.rs`. | High |
|
||||||
| File / project actions | Not yet | `loaded_scene_path` exists in `AppData`, but there is no visible file/project UI yet. | Medium |
|
| File / project actions | Partial | `loaded_scene_path` plus the working New/Open/Save controls in `src/app.rs` and `src/scene_file.rs`. | Medium |
|
||||||
| Status / feedback | Not yet | No dedicated status widget or state binding is present. | Medium |
|
| Status / feedback | Present | Bottom status bar driven by `AppData::ui_snapshot()` in `src/app.rs`. | Medium |
|
||||||
| Deferred features / legacy compatibility | Not yet | These remain future work in the feature inventory. | Low |
|
| Legacy compatibility / advanced dialogs | Partial | Backend support exists for some sub-features like vertical exaggeration and hydrology, while palette/color-map editing is now wired into the shell; the legacy-style menu/dialog workflow is still incomplete. | Low |
|
||||||
|
|
||||||
## Remaining gaps
|
## Remaining gaps
|
||||||
|
|
||||||
- No legacy-style menu/dialog layer for file, export, or script workflows.
|
- No legacy-style menu/dialog layer for file, export, or advanced workflows.
|
||||||
- No docked status bar or live feedback line.
|
- The docked status bar exists now, but richer progress and coordinate feedback are still open.
|
||||||
- No dedicated scripts/paths editor surface.
|
- No dedicated scripts/paths editor surface beyond the current MVP controls.
|
||||||
- Palette import/export and legacy texture loading remain open.
|
- Palette import/export and legacy texture loading remain open.
|
||||||
- Hydrology still lacks routed-water simulation and drainage maps.
|
- Hydrology still lacks routed-water simulation and drainage maps.
|
||||||
- Legacy export and compatibility dialogs remain future work.
|
- Legacy export and compatibility dialogs remain future work.
|
||||||
|
|||||||
@@ -721,20 +721,10 @@ Expected: generated script parses successfully.
|
|||||||
|
|
||||||
## Milestone G: WGPU/egui application after CLI stability
|
## Milestone G: WGPU/egui application after CLI stability
|
||||||
|
|
||||||
**Status:** Tasks G1–G4 have landed. `src/app_state.rs` holds testable app state and
|
**Status:** Tasks G1–G4 have landed. `src/app_state.rs` holds testable app state and `AppAction` reducers; `src/app.rs` and `src/bin/openvistapro_app.rs` provide the `app`-feature `eframe`/`egui` shell. The shell now docks terrain, scene/camera, render, import, script, path, and project controls around the CPU top-down/perspective preview, with a bottom status bar and backend-backed import/script/path actions.
|
||||||
`AppAction` reducers; `src/app.rs` and `src/bin/openvistapro_app.rs` provide the
|
|
||||||
`app`-feature `eframe`/`egui` shell. The shell now docks terrain, scene/camera, and render
|
|
||||||
controls in a left panel, a scripts/paths panel on the right, a top project bar and a
|
|
||||||
bottom status bar for file/status chrome, and the CPU top-down/perspective preview in the
|
|
||||||
center. Still-planned actions — heightmap import, run script, make path, and file
|
|
||||||
new/open/save — are present as disabled placeholders so the layout is honest about scope.
|
|
||||||
The shell map is tracked in [`docs/knowledgebase/ui-panel-map.md`](../knowledgebase/ui-panel-map.md).
|
The shell map is tracked in [`docs/knowledgebase/ui-panel-map.md`](../knowledgebase/ui-panel-map.md).
|
||||||
|
|
||||||
Remaining UI roadmap: Task G5 (WGPU renderer backend) plus the still-open gaps — wiring
|
Remaining UI roadmap: Task G5 (WGPU renderer backend) plus the still-open gaps — legacy menus/dialogs, richer file/project chrome, animation-frame export, deeper script/path editors, and palette import/export / texture loading. All of it stays clean-room: no proprietary VistaPro assets, menus, or screenshots enter the repository.
|
||||||
file/import/script/path actions to their backend modules, legacy menus/dialogs,
|
|
||||||
orientation and lens/range controls, vertical exaggeration, a palette editor, and
|
|
||||||
hydrology controls. All of it stays clean-room: no proprietary VistaPro assets, menus, or
|
|
||||||
screenshots enter the repository.
|
|
||||||
|
|
||||||
### Task G1: Create app-state crate/module without a window
|
### Task G1: Create app-state crate/module without a window
|
||||||
|
|
||||||
|
|||||||
+105
-7
@@ -1,7 +1,7 @@
|
|||||||
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, RenderQuality, RendererMode, TerrainPreset};
|
||||||
use crate::scene::Vec3;
|
use crate::scene::Vec3;
|
||||||
use crate::ui_shell::{ShellSection, UiShellState};
|
use crate::ui_shell::{ShellSection, UiShellState};
|
||||||
|
|
||||||
@@ -127,6 +127,22 @@ impl OpenVistaProApp {
|
|||||||
if renderer_mode != self.data.renderer_mode {
|
if renderer_mode != self.data.renderer_mode {
|
||||||
self.data.apply(AppAction::SetRendererMode(renderer_mode));
|
self.data.apply(AppAction::SetRendererMode(renderer_mode));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ui.separator();
|
||||||
|
ui.label("Render quality");
|
||||||
|
let mut quality = self.data.render_quality;
|
||||||
|
changed |= ui
|
||||||
|
.radio_value(&mut quality, RenderQuality::Preview, "Preview")
|
||||||
|
.changed();
|
||||||
|
changed |= ui
|
||||||
|
.radio_value(&mut quality, RenderQuality::Balanced, "Balanced")
|
||||||
|
.changed();
|
||||||
|
changed |= ui
|
||||||
|
.radio_value(&mut quality, RenderQuality::Final, "Final")
|
||||||
|
.changed();
|
||||||
|
if quality != self.data.render_quality {
|
||||||
|
self.data.apply(AppAction::SetRenderQuality(quality));
|
||||||
|
}
|
||||||
changed
|
changed
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,24 +181,29 @@ impl OpenVistaProApp {
|
|||||||
self.data.apply(AppAction::SetCameraTarget(camera_target));
|
self.data.apply(AppAction::SetCameraTarget(camera_target));
|
||||||
|
|
||||||
ui.separator();
|
ui.separator();
|
||||||
ui.label("Camera orientation and lens");
|
ui.label("Camera orientation and lens/range");
|
||||||
let mut orientation = self.data.scene.camera.orientation;
|
let mut orientation = self.data.scene.camera.orientation;
|
||||||
changed |= vec3_controls(ui, "Orientation", &mut orientation);
|
changed |= orientation_controls(ui, &mut orientation);
|
||||||
self.data
|
self.data
|
||||||
.apply(AppAction::SetCameraOrientation(orientation));
|
.apply(AppAction::SetCameraOrientation(orientation));
|
||||||
|
|
||||||
|
ui.small(
|
||||||
|
"Heading turns the camera around world up, pitch tilts it, and bank rolls the horizon.",
|
||||||
|
);
|
||||||
|
|
||||||
let mut fov_degrees = self.data.scene.camera.fov_degrees;
|
let mut fov_degrees = self.data.scene.camera.fov_degrees;
|
||||||
let mut near_range = self.data.scene.camera.near_range;
|
let mut near_range = self.data.scene.camera.near_range;
|
||||||
let mut far_range = self.data.scene.camera.far_range;
|
let mut far_range = self.data.scene.camera.far_range;
|
||||||
changed |= ui
|
changed |= ui
|
||||||
.add(egui::Slider::new(&mut fov_degrees, 10.0..=170.0).text("FOV"))
|
.add(egui::Slider::new(&mut fov_degrees, 10.0..=170.0).text("Lens / FOV (°)"))
|
||||||
.changed();
|
.changed();
|
||||||
changed |= ui
|
changed |= ui
|
||||||
.add(egui::Slider::new(&mut near_range, 0.1..=50.0).text("Near"))
|
.add(egui::Slider::new(&mut near_range, 0.1..=50.0).text("Near clip"))
|
||||||
.changed();
|
.changed();
|
||||||
changed |= ui
|
changed |= ui
|
||||||
.add(egui::Slider::new(&mut far_range, 1.0..=1000.0).text("Far"))
|
.add(egui::Slider::new(&mut far_range, 1.0..=1000.0).text("Far clip"))
|
||||||
.changed();
|
.changed();
|
||||||
|
ui.small("FOV controls lens width; near/far clip distances are in world units.");
|
||||||
self.data.apply(AppAction::SetCameraLens {
|
self.data.apply(AppAction::SetCameraLens {
|
||||||
fov_degrees,
|
fov_degrees,
|
||||||
near_range,
|
near_range,
|
||||||
@@ -200,6 +221,13 @@ impl OpenVistaProApp {
|
|||||||
|
|
||||||
ui.separator();
|
ui.separator();
|
||||||
ui.label("Hydrology");
|
ui.label("Hydrology");
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if ui.button("Reset hydrology").clicked() {
|
||||||
|
self.data.apply(AppAction::ResetHydrology);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
ui.label("Lake and river overlays are clean-room defaults");
|
||||||
|
});
|
||||||
let mut river_level = self.data.scene.hydrology.river_level;
|
let mut river_level = self.data.scene.hydrology.river_level;
|
||||||
let mut lake_level = self.data.scene.hydrology.lake_level;
|
let mut lake_level = self.data.scene.hydrology.lake_level;
|
||||||
let mut drainage = self.data.scene.hydrology.drainage;
|
let mut drainage = self.data.scene.hydrology.drainage;
|
||||||
@@ -212,14 +240,58 @@ impl OpenVistaProApp {
|
|||||||
changed |= ui
|
changed |= ui
|
||||||
.add(egui::Slider::new(&mut drainage, 0.0..=5.0).text("Drainage"))
|
.add(egui::Slider::new(&mut drainage, 0.0..=5.0).text("Drainage"))
|
||||||
.changed();
|
.changed();
|
||||||
|
ui.label("Lake placement");
|
||||||
|
let mut lake_center_x = self.data.scene.hydrology.lake_center_x;
|
||||||
|
let mut lake_center_z = self.data.scene.hydrology.lake_center_z;
|
||||||
|
let mut lake_radius = self.data.scene.hydrology.lake_radius;
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Center X/Z");
|
||||||
|
changed |= ui
|
||||||
|
.add(
|
||||||
|
egui::DragValue::new(&mut lake_center_x)
|
||||||
|
.speed(0.01)
|
||||||
|
.range(0.0..=1.0),
|
||||||
|
)
|
||||||
|
.changed();
|
||||||
|
changed |= ui
|
||||||
|
.add(
|
||||||
|
egui::DragValue::new(&mut lake_center_z)
|
||||||
|
.speed(0.01)
|
||||||
|
.range(0.0..=1.0),
|
||||||
|
)
|
||||||
|
.changed();
|
||||||
|
});
|
||||||
|
changed |= ui
|
||||||
|
.add(egui::Slider::new(&mut lake_radius, 0.0..=0.5).text("Radius"))
|
||||||
|
.changed();
|
||||||
|
ui.label("River placement");
|
||||||
|
let mut river_center_x = self.data.scene.hydrology.river_center_x;
|
||||||
|
let mut river_width = self.data.scene.hydrology.river_width;
|
||||||
|
let mut river_bend = self.data.scene.hydrology.river_bend;
|
||||||
|
changed |= ui
|
||||||
|
.add(egui::Slider::new(&mut river_center_x, 0.0..=1.0).text("Center X"))
|
||||||
|
.changed();
|
||||||
|
changed |= ui
|
||||||
|
.add(egui::Slider::new(&mut river_width, 0.0..=0.25).text("Width"))
|
||||||
|
.changed();
|
||||||
|
changed |= ui
|
||||||
|
.add(egui::Slider::new(&mut river_bend, 0.0..=0.35).text("Bend"))
|
||||||
|
.changed();
|
||||||
self.data.apply(AppAction::SetHydrology {
|
self.data.apply(AppAction::SetHydrology {
|
||||||
river_level,
|
river_level,
|
||||||
lake_level,
|
lake_level,
|
||||||
drainage,
|
drainage,
|
||||||
|
lake_center_x,
|
||||||
|
lake_center_z,
|
||||||
|
lake_radius,
|
||||||
|
river_center_x,
|
||||||
|
river_width,
|
||||||
|
river_bend,
|
||||||
});
|
});
|
||||||
|
|
||||||
ui.separator();
|
ui.separator();
|
||||||
ui.label("Palette");
|
ui.label("Color map");
|
||||||
|
ui.label("Core elevation bands");
|
||||||
let mut palette = self.data.scene.palette;
|
let mut palette = self.data.scene.palette;
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.label("Water");
|
ui.label("Water");
|
||||||
@@ -389,9 +461,12 @@ impl OpenVistaProApp {
|
|||||||
ui.separator();
|
ui.separator();
|
||||||
ui.label(format!("Terrain: {:?}", self.data.terrain_preset));
|
ui.label(format!("Terrain: {:?}", self.data.terrain_preset));
|
||||||
ui.label(format!("Renderer: {:?}", self.data.renderer_mode));
|
ui.label(format!("Renderer: {:?}", self.data.renderer_mode));
|
||||||
|
ui.label(format!("Quality: {:?}", self.data.render_quality));
|
||||||
let (width, height) = self.data.preview_size;
|
let (width, height) = self.data.preview_size;
|
||||||
ui.label(format!("Preview: {width} x {height}"));
|
ui.label(format!("Preview: {width} x {height}"));
|
||||||
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!("Snow line: {:.2}", self.data.scene.snow_line));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -476,6 +551,29 @@ fn vec3_controls(ui: &mut egui::Ui, label: &str, value: &mut Vec3) -> bool {
|
|||||||
changed
|
changed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn orientation_controls(ui: &mut egui::Ui, value: &mut Vec3) -> bool {
|
||||||
|
let mut changed = false;
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Heading / yaw");
|
||||||
|
changed |= ui
|
||||||
|
.add(egui::DragValue::new(&mut value.x).speed(0.25).suffix("°"))
|
||||||
|
.changed();
|
||||||
|
});
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Pitch");
|
||||||
|
changed |= ui
|
||||||
|
.add(egui::DragValue::new(&mut value.y).speed(0.25).suffix("°"))
|
||||||
|
.changed();
|
||||||
|
});
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Bank / roll");
|
||||||
|
changed |= ui
|
||||||
|
.add(egui::DragValue::new(&mut value.z).speed(0.25).suffix("°"))
|
||||||
|
.changed();
|
||||||
|
});
|
||||||
|
changed
|
||||||
|
}
|
||||||
|
|
||||||
fn rgb_image_to_color_image(image: &RgbImage) -> egui::ColorImage {
|
fn rgb_image_to_color_image(image: &RgbImage) -> egui::ColorImage {
|
||||||
let size = [image.width() as usize, image.height() as usize];
|
let size = [image.width() as usize, image.height() as usize];
|
||||||
egui::ColorImage::from_rgb(size, image.as_raw())
|
egui::ColorImage::from_rgb(size, image.as_raw())
|
||||||
|
|||||||
+116
-6
@@ -3,7 +3,9 @@ use std::path::Path;
|
|||||||
use image::RgbImage;
|
use image::RgbImage;
|
||||||
|
|
||||||
use crate::path::{CameraPath, build_demo_path};
|
use crate::path::{CameraPath, build_demo_path};
|
||||||
use crate::render::{render_perspective, render_top_down};
|
use crate::render::{
|
||||||
|
RenderQualityPreset, render_perspective_with_quality, render_top_down_with_quality,
|
||||||
|
};
|
||||||
use crate::scene::{Scene, Vec3};
|
use crate::scene::{Scene, Vec3};
|
||||||
use crate::scene_file::{self, SceneFileError};
|
use crate::scene_file::{self, SceneFileError};
|
||||||
use crate::script::{Command, parse_script};
|
use crate::script::{Command, parse_script};
|
||||||
@@ -49,6 +51,32 @@ impl RendererMode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum RenderQuality {
|
||||||
|
Preview,
|
||||||
|
Balanced,
|
||||||
|
Final,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderQuality {
|
||||||
|
/// Human-readable label used by the UI shell and CLI.
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
RenderQuality::Preview => "Preview",
|
||||||
|
RenderQuality::Balanced => "Balanced",
|
||||||
|
RenderQuality::Final => "Final",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn preset(self) -> RenderQualityPreset {
|
||||||
|
match self {
|
||||||
|
RenderQuality::Preview => RenderQualityPreset::Preview,
|
||||||
|
RenderQuality::Balanced => RenderQualityPreset::Balanced,
|
||||||
|
RenderQuality::Final => RenderQualityPreset::Final,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A pure, derived summary of a parsed script, suitable for display in the UI.
|
/// A pure, derived summary of a parsed script, suitable for display in the UI.
|
||||||
///
|
///
|
||||||
/// All counts are zero and `error` carries the parser message when the script
|
/// All counts are zero and `error` carries the parser message when the script
|
||||||
@@ -124,6 +152,7 @@ pub struct AppData {
|
|||||||
pub scene: Scene,
|
pub scene: Scene,
|
||||||
pub terrain_preset: TerrainPreset,
|
pub terrain_preset: TerrainPreset,
|
||||||
pub renderer_mode: RendererMode,
|
pub renderer_mode: RendererMode,
|
||||||
|
pub render_quality: RenderQuality,
|
||||||
pub preview_size: (u32, u32),
|
pub preview_size: (u32, u32),
|
||||||
pub loaded_scene_path: Option<String>,
|
pub loaded_scene_path: Option<String>,
|
||||||
/// Heightmap path selected for import.
|
/// Heightmap path selected for import.
|
||||||
@@ -150,6 +179,7 @@ impl Default for AppData {
|
|||||||
scene: Scene::default(),
|
scene: Scene::default(),
|
||||||
terrain_preset: TerrainPreset::RadialHill,
|
terrain_preset: TerrainPreset::RadialHill,
|
||||||
renderer_mode: RendererMode::TopDown,
|
renderer_mode: RendererMode::TopDown,
|
||||||
|
render_quality: RenderQuality::Preview,
|
||||||
preview_size: (256, 256),
|
preview_size: (256, 256),
|
||||||
loaded_scene_path: Some(scene_path),
|
loaded_scene_path: Some(scene_path),
|
||||||
import_path: Some(import_path.clone()),
|
import_path: Some(import_path.clone()),
|
||||||
@@ -169,6 +199,7 @@ impl AppData {
|
|||||||
match action {
|
match action {
|
||||||
AppAction::SetTerrainPreset(preset) => self.terrain_preset = preset,
|
AppAction::SetTerrainPreset(preset) => self.terrain_preset = preset,
|
||||||
AppAction::SetRendererMode(mode) => self.renderer_mode = mode,
|
AppAction::SetRendererMode(mode) => self.renderer_mode = mode,
|
||||||
|
AppAction::SetRenderQuality(quality) => self.render_quality = quality,
|
||||||
AppAction::SetWaterLevel(value) => self.scene.water_level = value,
|
AppAction::SetWaterLevel(value) => self.scene.water_level = value,
|
||||||
AppAction::SetTreeLine(value) => self.scene.tree_line = value,
|
AppAction::SetTreeLine(value) => self.scene.tree_line = value,
|
||||||
AppAction::SetSnowLine(value) => self.scene.snow_line = value,
|
AppAction::SetSnowLine(value) => self.scene.snow_line = value,
|
||||||
@@ -194,10 +225,25 @@ impl AppData {
|
|||||||
river_level,
|
river_level,
|
||||||
lake_level,
|
lake_level,
|
||||||
drainage,
|
drainage,
|
||||||
|
lake_center_x,
|
||||||
|
lake_center_z,
|
||||||
|
lake_radius,
|
||||||
|
river_center_x,
|
||||||
|
river_width,
|
||||||
|
river_bend,
|
||||||
} => {
|
} => {
|
||||||
self.scene.hydrology.river_level = river_level;
|
self.scene.hydrology.river_level = river_level;
|
||||||
self.scene.hydrology.lake_level = lake_level;
|
self.scene.hydrology.lake_level = lake_level;
|
||||||
self.scene.hydrology.drainage = drainage.max(0.0);
|
self.scene.hydrology.drainage = drainage.max(0.0);
|
||||||
|
self.scene.hydrology.lake_center_x = lake_center_x.clamp(0.0, 1.0);
|
||||||
|
self.scene.hydrology.lake_center_z = lake_center_z.clamp(0.0, 1.0);
|
||||||
|
self.scene.hydrology.lake_radius = lake_radius.max(0.0);
|
||||||
|
self.scene.hydrology.river_center_x = river_center_x.clamp(0.0, 1.0);
|
||||||
|
self.scene.hydrology.river_width = river_width.max(0.0);
|
||||||
|
self.scene.hydrology.river_bend = river_bend.clamp(0.0, 0.5);
|
||||||
|
}
|
||||||
|
AppAction::ResetHydrology => {
|
||||||
|
self.scene.hydrology = crate::scene::Hydrology::default();
|
||||||
}
|
}
|
||||||
AppAction::SetPalette(palette) => self.scene.palette = palette,
|
AppAction::SetPalette(palette) => self.scene.palette = palette,
|
||||||
AppAction::SetPreviewSize { width, height } => {
|
AppAction::SetPreviewSize { width, height } => {
|
||||||
@@ -215,6 +261,7 @@ impl AppData {
|
|||||||
self.last_script_run = None;
|
self.last_script_run = None;
|
||||||
self.terrain_preset = TerrainPreset::RadialHill;
|
self.terrain_preset = TerrainPreset::RadialHill;
|
||||||
self.renderer_mode = RendererMode::TopDown;
|
self.renderer_mode = RendererMode::TopDown;
|
||||||
|
self.render_quality = RenderQuality::Preview;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn open_scene(&mut self, path: &Path) -> Result<(), SceneFileError> {
|
pub fn open_scene(&mut self, path: &Path) -> Result<(), SceneFileError> {
|
||||||
@@ -280,17 +327,18 @@ impl AppData {
|
|||||||
import_label: "Import terrain".to_string(),
|
import_label: "Import terrain".to_string(),
|
||||||
script_label: "Scripts / paths".to_string(),
|
script_label: "Scripts / paths".to_string(),
|
||||||
path_label: "Path tools".to_string(),
|
path_label: "Path tools".to_string(),
|
||||||
scene_controls_label: "Scene / camera / palette".to_string(),
|
scene_controls_label: "Scene / camera / color map".to_string(),
|
||||||
palette_label: "Palette / color map".to_string(),
|
palette_label: "Color map".to_string(),
|
||||||
hydrology_label: "Hydrology".to_string(),
|
hydrology_label: "Hydrology".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(),
|
self.terrain_preset.label(),
|
||||||
self.renderer_mode.label(),
|
self.renderer_mode.label(),
|
||||||
|
self.render_quality.label(),
|
||||||
self.scene.vertical_exaggeration,
|
self.scene.vertical_exaggeration,
|
||||||
),
|
),
|
||||||
script_preview: ScriptPreview::from_source(&self.script_source),
|
script_preview: ScriptPreview::from_source(&self.script_source),
|
||||||
@@ -305,8 +353,16 @@ impl AppData {
|
|||||||
let grid = self.build_preview_grid()?;
|
let grid = self.build_preview_grid()?;
|
||||||
let (width, height) = self.preview_size;
|
let (width, height) = self.preview_size;
|
||||||
let image = match self.renderer_mode {
|
let image = match self.renderer_mode {
|
||||||
RendererMode::TopDown => render_top_down(&grid, &self.scene),
|
RendererMode::TopDown => {
|
||||||
RendererMode::Perspective => render_perspective(&grid, &self.scene, width, height),
|
render_top_down_with_quality(&grid, &self.scene, self.render_quality.preset())
|
||||||
|
}
|
||||||
|
RendererMode::Perspective => render_perspective_with_quality(
|
||||||
|
&grid,
|
||||||
|
&self.scene,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
self.render_quality.preset(),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
Ok(image)
|
Ok(image)
|
||||||
}
|
}
|
||||||
@@ -316,6 +372,7 @@ impl AppData {
|
|||||||
pub enum AppAction {
|
pub enum AppAction {
|
||||||
SetTerrainPreset(TerrainPreset),
|
SetTerrainPreset(TerrainPreset),
|
||||||
SetRendererMode(RendererMode),
|
SetRendererMode(RendererMode),
|
||||||
|
SetRenderQuality(RenderQuality),
|
||||||
SetWaterLevel(f32),
|
SetWaterLevel(f32),
|
||||||
SetTreeLine(f32),
|
SetTreeLine(f32),
|
||||||
SetSnowLine(f32),
|
SetSnowLine(f32),
|
||||||
@@ -333,7 +390,14 @@ pub enum AppAction {
|
|||||||
river_level: f32,
|
river_level: f32,
|
||||||
lake_level: f32,
|
lake_level: f32,
|
||||||
drainage: f32,
|
drainage: f32,
|
||||||
|
lake_center_x: f32,
|
||||||
|
lake_center_z: f32,
|
||||||
|
lake_radius: f32,
|
||||||
|
river_center_x: f32,
|
||||||
|
river_width: f32,
|
||||||
|
river_bend: f32,
|
||||||
},
|
},
|
||||||
|
ResetHydrology,
|
||||||
SetPalette(crate::scene::Palette),
|
SetPalette(crate::scene::Palette),
|
||||||
SetPreviewSize {
|
SetPreviewSize {
|
||||||
width: u32,
|
width: u32,
|
||||||
@@ -377,6 +441,35 @@ mod tests {
|
|||||||
assert_eq!(app.scene.camera, original_camera);
|
assert_eq!(app.scene.camera, original_camera);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reducer_updates_hydrology_shapes_and_reset() {
|
||||||
|
let mut app = AppData::default();
|
||||||
|
app.apply(AppAction::SetHydrology {
|
||||||
|
river_level: 0.25,
|
||||||
|
lake_level: 0.5,
|
||||||
|
drainage: 0.1,
|
||||||
|
lake_center_x: 1.3,
|
||||||
|
lake_center_z: -0.2,
|
||||||
|
lake_radius: 0.3,
|
||||||
|
river_center_x: -0.4,
|
||||||
|
river_width: 0.12,
|
||||||
|
river_bend: 0.9,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(app.scene.hydrology.river_level, 0.25);
|
||||||
|
assert_eq!(app.scene.hydrology.lake_level, 0.5);
|
||||||
|
assert_eq!(app.scene.hydrology.drainage, 0.1);
|
||||||
|
assert_eq!(app.scene.hydrology.lake_center_x, 1.0);
|
||||||
|
assert_eq!(app.scene.hydrology.lake_center_z, 0.0);
|
||||||
|
assert_eq!(app.scene.hydrology.lake_radius, 0.3);
|
||||||
|
assert_eq!(app.scene.hydrology.river_center_x, 0.0);
|
||||||
|
assert_eq!(app.scene.hydrology.river_width, 0.12);
|
||||||
|
assert_eq!(app.scene.hydrology.river_bend, 0.5);
|
||||||
|
|
||||||
|
app.apply(AppAction::ResetHydrology);
|
||||||
|
assert_eq!(app.scene.hydrology, crate::scene::Hydrology::default());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reducer_updates_camera_vectors_and_mode() {
|
fn reducer_updates_camera_vectors_and_mode() {
|
||||||
let mut app = AppData::default();
|
let mut app = AppData::default();
|
||||||
@@ -415,6 +508,21 @@ mod tests {
|
|||||||
assert_eq!(preview.height(), 64);
|
assert_eq!(preview.height(), 64);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_quality_changes_preview_output() {
|
||||||
|
let mut app = AppData::default();
|
||||||
|
app.apply(AppAction::SetPreviewSize {
|
||||||
|
width: 33,
|
||||||
|
height: 33,
|
||||||
|
});
|
||||||
|
|
||||||
|
let preview = app.render_preview().unwrap();
|
||||||
|
app.apply(AppAction::SetRenderQuality(RenderQuality::Final));
|
||||||
|
let final_img = app.render_preview().unwrap();
|
||||||
|
|
||||||
|
assert_ne!(preview.as_raw(), final_img.as_raw());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ui_snapshot_exposes_existing_controls_and_new_entry_points() {
|
fn ui_snapshot_exposes_existing_controls_and_new_entry_points() {
|
||||||
let app = AppData::default();
|
let app = AppData::default();
|
||||||
@@ -426,10 +534,12 @@ mod tests {
|
|||||||
assert_eq!(shell.import_label, "Import terrain");
|
assert_eq!(shell.import_label, "Import terrain");
|
||||||
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!(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());
|
||||||
assert!(shell.status_line.contains("CPU preview"));
|
assert!(shell.status_line.contains("CPU preview"));
|
||||||
|
assert!(shell.status_line.contains("Preview"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+63
-4
@@ -3,8 +3,13 @@ use std::path::PathBuf;
|
|||||||
use clap::{Args, Parser, Subcommand, ValueEnum};
|
use clap::{Args, Parser, Subcommand, ValueEnum};
|
||||||
use image::ImageError;
|
use image::ImageError;
|
||||||
|
|
||||||
use crate::render::{demo_camera_for, render_perspective_to_path, render_top_down_to_path};
|
use crate::render::{
|
||||||
|
RenderQualityPreset, demo_camera_for, render_perspective_with_quality,
|
||||||
|
render_top_down_with_quality,
|
||||||
|
};
|
||||||
|
|
||||||
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::script_exec::{self, ScriptError};
|
||||||
use crate::terrain::{HeightGrid, TerrainError};
|
use crate::terrain::{HeightGrid, TerrainError};
|
||||||
@@ -55,6 +60,9 @@ pub struct RenderArgs {
|
|||||||
/// the top-down preview.
|
/// the top-down preview.
|
||||||
#[arg(long, default_value_t = false)]
|
#[arg(long, default_value_t = false)]
|
||||||
pub camera_demo: bool,
|
pub camera_demo: bool,
|
||||||
|
/// Render-quality preset for the CPU spike.
|
||||||
|
#[arg(long, value_enum, default_value_t = RenderQualityPreset::Preview)]
|
||||||
|
pub quality: RenderQualityPreset,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Args)]
|
#[derive(Debug, Clone, Args)]
|
||||||
@@ -146,10 +154,39 @@ impl From<ScriptError> for CliError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Preset {
|
||||||
|
pub fn slug(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Preset::Plane => "plane",
|
||||||
|
Preset::Hill => "hill",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn supported_presets() -> &'static [&'static str] {
|
pub fn supported_presets() -> &'static [&'static str] {
|
||||||
&["plane", "hill"]
|
&["plane", "hill"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn supported_quality_presets() -> &'static [&'static str] {
|
||||||
|
&["preview", "balanced", "final"]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_render_output_path(
|
||||||
|
output: &std::path::Path,
|
||||||
|
preset: Preset,
|
||||||
|
quality: RenderQualityPreset,
|
||||||
|
) -> PathBuf {
|
||||||
|
if output.is_dir() {
|
||||||
|
output.join(format!(
|
||||||
|
"openvistapro-{}-{}.png",
|
||||||
|
preset.slug(),
|
||||||
|
quality.output_tag()
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
output.to_path_buf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn supported_importers() -> &'static [&'static str] {
|
pub fn supported_importers() -> &'static [&'static str] {
|
||||||
#[cfg(all(
|
#[cfg(all(
|
||||||
feature = "hgt",
|
feature = "hgt",
|
||||||
@@ -223,6 +260,12 @@ pub fn info_text() -> String {
|
|||||||
let mut text = String::new();
|
let mut text = String::new();
|
||||||
writeln!(&mut text, "openvistapro {}", env!("CARGO_PKG_VERSION")).unwrap();
|
writeln!(&mut text, "openvistapro {}", env!("CARGO_PKG_VERSION")).unwrap();
|
||||||
writeln!(&mut text, "presets: {}", supported_presets().join(", ")).unwrap();
|
writeln!(&mut text, "presets: {}", supported_presets().join(", ")).unwrap();
|
||||||
|
writeln!(
|
||||||
|
&mut text,
|
||||||
|
"quality presets: {}",
|
||||||
|
supported_quality_presets().join(", ")
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
let importers = supported_importers();
|
let importers = supported_importers();
|
||||||
if importers.is_empty() {
|
if importers.is_empty() {
|
||||||
writeln!(&mut text, "importers: (none)").unwrap();
|
writeln!(&mut text, "importers: (none)").unwrap();
|
||||||
@@ -244,6 +287,7 @@ pub fn execute(cli: Cli) -> Result<(), CliError> {
|
|||||||
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)?,
|
||||||
};
|
};
|
||||||
|
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() {
|
||||||
scene_file::load_from_path(path)?
|
scene_file::load_from_path(path)?
|
||||||
} else {
|
} else {
|
||||||
@@ -251,15 +295,23 @@ pub fn execute(cli: Cli) -> Result<(), CliError> {
|
|||||||
};
|
};
|
||||||
if args.camera_demo {
|
if args.camera_demo {
|
||||||
scene.camera = demo_camera_for(&grid);
|
scene.camera = demo_camera_for(&grid);
|
||||||
render_perspective_to_path(&grid, &scene, args.width, args.height, &args.output)?;
|
let image = render_perspective_with_quality(
|
||||||
|
&grid,
|
||||||
|
&scene,
|
||||||
|
args.width,
|
||||||
|
args.height,
|
||||||
|
args.quality,
|
||||||
|
);
|
||||||
|
image.save(&output)?;
|
||||||
} else {
|
} else {
|
||||||
render_top_down_to_path(&grid, &scene, &args.output)?;
|
let image = render_top_down_with_quality(&grid, &scene, args.quality);
|
||||||
|
image.save(&output)?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Command::Scene(args) => match args.action {
|
Command::Scene(args) => match args.action {
|
||||||
SceneAction::Export(export) => {
|
SceneAction::Export(export) => {
|
||||||
scene_file::save_to_path(&Scene::default(), &export.output)?;
|
scene_file::save_to_path(&crate::scene::Scene::default(), &export.output)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -278,6 +330,7 @@ pub fn execute(cli: Cli) -> Result<(), CliError> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::scene::Scene;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_info_command() {
|
fn parses_info_command() {
|
||||||
@@ -707,6 +760,12 @@ mod tests {
|
|||||||
water_level: 1000.0,
|
water_level: 1000.0,
|
||||||
tree_line: 1001.0,
|
tree_line: 1001.0,
|
||||||
snow_line: 1002.0,
|
snow_line: 1002.0,
|
||||||
|
hydrology: crate::scene::Hydrology {
|
||||||
|
lake_radius: 0.0,
|
||||||
|
river_width: 0.0,
|
||||||
|
river_bend: 0.0,
|
||||||
|
..crate::scene::Hydrology::default()
|
||||||
|
},
|
||||||
..Scene::default()
|
..Scene::default()
|
||||||
};
|
};
|
||||||
crate::scene_file::save_to_path(&custom, &scene_path).expect("save scene");
|
crate::scene_file::save_to_path(&custom, &scene_path).expect("save scene");
|
||||||
|
|||||||
@@ -195,4 +195,13 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(scene_color(&scene, scene.snow_line + 1.0), SNOW_COLOR);
|
assert_eq!(scene_color(&scene, scene.snow_line + 1.0), SNOW_COLOR);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scene_color_uses_palette_band_colors_for_the_default_scene() {
|
||||||
|
let scene = Scene::default();
|
||||||
|
assert_eq!(scene.palette.water, WATER_COLOR);
|
||||||
|
assert_eq!(scene.palette.lowland, LOWLAND_COLOR);
|
||||||
|
assert_eq!(scene.palette.highland, HIGHLAND_COLOR);
|
||||||
|
assert_eq!(scene.palette.snow, SNOW_COLOR);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+259
-10
@@ -1,11 +1,60 @@
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
|
use clap::ValueEnum;
|
||||||
use image::{ImageError, Rgb, RgbImage};
|
use image::{ImageError, Rgb, RgbImage};
|
||||||
|
|
||||||
use crate::colormap::scene_color;
|
use crate::colormap::scene_color;
|
||||||
use crate::scene::{Camera, Scene, Vec3};
|
use crate::scene::{Camera, Scene, Vec3};
|
||||||
use crate::terrain::HeightGrid;
|
use crate::terrain::HeightGrid;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
|
||||||
|
pub enum RenderQualityPreset {
|
||||||
|
Preview,
|
||||||
|
Balanced,
|
||||||
|
Final,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub struct RenderQualityProfile {
|
||||||
|
pub label: &'static str,
|
||||||
|
pub output_tag: &'static str,
|
||||||
|
pub perspective_step: f32,
|
||||||
|
pub top_down_blur_passes: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderQualityPreset {
|
||||||
|
pub fn profile(self) -> RenderQualityProfile {
|
||||||
|
match self {
|
||||||
|
RenderQualityPreset::Preview => RenderQualityProfile {
|
||||||
|
label: "Preview",
|
||||||
|
output_tag: "preview",
|
||||||
|
perspective_step: 0.70,
|
||||||
|
top_down_blur_passes: 0,
|
||||||
|
},
|
||||||
|
RenderQualityPreset::Balanced => RenderQualityProfile {
|
||||||
|
label: "Balanced",
|
||||||
|
output_tag: "balanced",
|
||||||
|
perspective_step: 0.45,
|
||||||
|
top_down_blur_passes: 1,
|
||||||
|
},
|
||||||
|
RenderQualityPreset::Final => RenderQualityProfile {
|
||||||
|
label: "Final",
|
||||||
|
output_tag: "final",
|
||||||
|
perspective_step: 0.25,
|
||||||
|
top_down_blur_passes: 2,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
self.profile().label
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn output_tag(self) -> &'static str {
|
||||||
|
self.profile().output_tag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn render_top_down(grid: &HeightGrid, scene: &Scene) -> RgbImage {
|
pub fn render_top_down(grid: &HeightGrid, scene: &Scene) -> RgbImage {
|
||||||
let w = grid.width();
|
let w = grid.width();
|
||||||
let h = grid.height();
|
let h = grid.height();
|
||||||
@@ -14,13 +63,64 @@ pub fn render_top_down(grid: &HeightGrid, scene: &Scene) -> RgbImage {
|
|||||||
for y in 0..h {
|
for y in 0..h {
|
||||||
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 color = scene_color(scene, elevation);
|
let (nx, nz) = normalized_coords(x, y, w, h);
|
||||||
|
let color = surface_color(scene, elevation, nx, nz);
|
||||||
img.put_pixel(x, y, Rgb(color));
|
img.put_pixel(x, y, Rgb(color));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
img
|
img
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn blur_pass(image: &RgbImage) -> RgbImage {
|
||||||
|
let mut blurred = RgbImage::new(image.width(), image.height());
|
||||||
|
for y in 0..image.height() {
|
||||||
|
for x in 0..image.width() {
|
||||||
|
let mut accum = [0u32; 3];
|
||||||
|
let mut count = 0u32;
|
||||||
|
for dy in -1i32..=1 {
|
||||||
|
for dx in -1i32..=1 {
|
||||||
|
let nx = x as i32 + dx;
|
||||||
|
let ny = y as i32 + dy;
|
||||||
|
if nx < 0 || ny < 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (nx, ny) = (nx as u32, ny as u32);
|
||||||
|
if nx >= image.width() || ny >= image.height() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let px = image.get_pixel(nx, ny).0;
|
||||||
|
accum[0] += u32::from(px[0]);
|
||||||
|
accum[1] += u32::from(px[1]);
|
||||||
|
accum[2] += u32::from(px[2]);
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let avg = [
|
||||||
|
(accum[0] / count.max(1)) as u8,
|
||||||
|
(accum[1] / count.max(1)) as u8,
|
||||||
|
(accum[2] / count.max(1)) as u8,
|
||||||
|
];
|
||||||
|
blurred.put_pixel(x, y, Rgb(avg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
blurred
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_top_down_quality(mut image: RgbImage, quality: RenderQualityPreset) -> RgbImage {
|
||||||
|
for _ in 0..quality.profile().top_down_blur_passes {
|
||||||
|
image = blur_pass(&image);
|
||||||
|
}
|
||||||
|
image
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_top_down_with_quality(
|
||||||
|
grid: &HeightGrid,
|
||||||
|
scene: &Scene,
|
||||||
|
quality: RenderQualityPreset,
|
||||||
|
) -> RgbImage {
|
||||||
|
apply_top_down_quality(render_top_down(grid, scene), quality)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn render_top_down_to_path(
|
pub fn render_top_down_to_path(
|
||||||
grid: &HeightGrid,
|
grid: &HeightGrid,
|
||||||
scene: &Scene,
|
scene: &Scene,
|
||||||
@@ -55,6 +155,53 @@ fn mix_color(color: [u8; 3], target: [u8; 3], factor: f32) -> [u8; 3] {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalized_coords(x: u32, y: u32, width: u32, height: u32) -> (f32, f32) {
|
||||||
|
let nx = if width > 1 {
|
||||||
|
x as f32 / (width as f32 - 1.0)
|
||||||
|
} else {
|
||||||
|
0.5
|
||||||
|
};
|
||||||
|
let nz = if height > 1 {
|
||||||
|
y as f32 / (height as f32 - 1.0)
|
||||||
|
} else {
|
||||||
|
0.5
|
||||||
|
};
|
||||||
|
(nx.clamp(0.0, 1.0), nz.clamp(0.0, 1.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hydrology_overlay_color(scene: &Scene, nx: f32, nz: f32) -> Option<[u8; 3]> {
|
||||||
|
let lake = scene.hydrology.lake_coverage(nx, nz);
|
||||||
|
let river = scene.hydrology.river_coverage(nx, nz);
|
||||||
|
let coverage = lake.max(river);
|
||||||
|
if coverage <= 0.0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tint = if lake >= river {
|
||||||
|
mix_color(scene.palette.water, [40, 125, 225], 0.68)
|
||||||
|
} else {
|
||||||
|
mix_color(scene.palette.water, [100, 210, 255], 0.75)
|
||||||
|
};
|
||||||
|
let colored = mix_color(scene.palette.water, tint, coverage);
|
||||||
|
Some(if coverage > 0.72 {
|
||||||
|
mix_color(
|
||||||
|
colored,
|
||||||
|
[245, 250, 255],
|
||||||
|
(coverage - 0.72).clamp(0.0, 0.28) * 3.0,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
colored
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn surface_color(scene: &Scene, elevation: f32, nx: f32, nz: f32) -> [u8; 3] {
|
||||||
|
let base = scene_color(scene, elevation);
|
||||||
|
match hydrology_overlay_color(scene, nx, nz) {
|
||||||
|
Some(overlay) => mix_color(base, overlay, 0.9),
|
||||||
|
None => base,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn v_add(a: Vec3, b: Vec3) -> Vec3 {
|
fn v_add(a: Vec3, b: Vec3) -> Vec3 {
|
||||||
Vec3::new(a.x + b.x, a.y + b.y, a.z + b.z)
|
Vec3::new(a.x + b.x, a.y + b.y, a.z + b.z)
|
||||||
}
|
}
|
||||||
@@ -178,6 +325,16 @@ pub fn demo_camera_for(grid: &HeightGrid) -> Camera {
|
|||||||
/// Coordinate convention: world X and Z map onto the grid's `(x, y)` indices,
|
/// Coordinate convention: world X and Z map onto the grid's `(x, y)` indices,
|
||||||
/// world Y is elevation, up = +Y.
|
/// world Y is elevation, up = +Y.
|
||||||
pub fn render_perspective(grid: &HeightGrid, scene: &Scene, width: u32, height: u32) -> RgbImage {
|
pub fn render_perspective(grid: &HeightGrid, scene: &Scene, width: u32, height: u32) -> RgbImage {
|
||||||
|
render_perspective_with_quality(grid, scene, width, height, RenderQualityPreset::Preview)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_perspective_with_quality(
|
||||||
|
grid: &HeightGrid,
|
||||||
|
scene: &Scene,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
quality: RenderQualityPreset,
|
||||||
|
) -> RgbImage {
|
||||||
let mut img = RgbImage::new(width.max(1), height.max(1));
|
let mut img = RgbImage::new(width.max(1), height.max(1));
|
||||||
|
|
||||||
let cam = &scene.camera;
|
let cam = &scene.camera;
|
||||||
@@ -196,7 +353,7 @@ pub fn render_perspective(grid: &HeightGrid, scene: &Scene, width: u32, height:
|
|||||||
let grid_w = grid.width() as f32;
|
let grid_w = grid.width() as f32;
|
||||||
let grid_h = grid.height() as f32;
|
let grid_h = grid.height() as f32;
|
||||||
let max_dist = cam.far_range.max(cam.near_range + 0.1);
|
let max_dist = cam.far_range.max(cam.near_range + 0.1);
|
||||||
let step = 0.5_f32;
|
let step = quality.profile().perspective_step;
|
||||||
|
|
||||||
let haze_strength = scene.haze.clamp(0.0, 1.0);
|
let haze_strength = scene.haze.clamp(0.0, 1.0);
|
||||||
|
|
||||||
@@ -220,7 +377,17 @@ pub fn render_perspective(grid: &HeightGrid, scene: &Scene, width: u32, height:
|
|||||||
if let Some(terrain_h) = sample_height_bilinear(grid, p.x, p.z) {
|
if let Some(terrain_h) = sample_height_bilinear(grid, p.x, p.z) {
|
||||||
let terrain_h = terrain_height(scene, terrain_h);
|
let terrain_h = terrain_height(scene, terrain_h);
|
||||||
if p.y <= terrain_h {
|
if p.y <= terrain_h {
|
||||||
let band = scene_color(scene, terrain_h);
|
let nx = if grid_w > 1.0 {
|
||||||
|
(p.x / (grid_w - 1.0)).clamp(0.0, 1.0)
|
||||||
|
} else {
|
||||||
|
0.5
|
||||||
|
};
|
||||||
|
let nz = if grid_h > 1.0 {
|
||||||
|
(p.z / (grid_h - 1.0)).clamp(0.0, 1.0)
|
||||||
|
} else {
|
||||||
|
0.5
|
||||||
|
};
|
||||||
|
let band = surface_color(scene, terrain_h, nx, nz);
|
||||||
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));
|
||||||
@@ -253,11 +420,23 @@ 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};
|
||||||
|
|
||||||
fn fixture_scene() -> Scene {
|
fn fixture_scene() -> Scene {
|
||||||
Scene::default()
|
Scene::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn dry_scene() -> Scene {
|
||||||
|
Scene {
|
||||||
|
hydrology: Hydrology {
|
||||||
|
lake_radius: 0.0,
|
||||||
|
river_width: 0.0,
|
||||||
|
..Hydrology::default()
|
||||||
|
},
|
||||||
|
..Scene::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn render_matches_grid_dimensions() {
|
fn render_matches_grid_dimensions() {
|
||||||
let grid = HeightGrid::plane(8, 5).unwrap();
|
let grid = HeightGrid::plane(8, 5).unwrap();
|
||||||
@@ -267,19 +446,27 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn render_plane_is_uniform_water() {
|
fn render_plane_shows_distinct_hydrology_overlays() {
|
||||||
let grid = HeightGrid::plane(4, 4).unwrap();
|
let grid = HeightGrid::plane(32, 32).unwrap();
|
||||||
let img = render_top_down(&grid, &fixture_scene());
|
let img = render_top_down(&grid, &fixture_scene());
|
||||||
|
|
||||||
|
let mut saw_base_water = false;
|
||||||
|
let mut saw_feature = false;
|
||||||
for y in 0..img.height() {
|
for y in 0..img.height() {
|
||||||
for x in 0..img.width() {
|
for x in 0..img.width() {
|
||||||
let p = img.get_pixel(x, y);
|
match img.get_pixel(x, y).0 {
|
||||||
assert_eq!(p.0, WATER_COLOR, "pixel at ({x},{y}) should be water");
|
c if c == WATER_COLOR => saw_base_water = true,
|
||||||
|
c if c != WATER_COLOR => saw_feature = true,
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
assert!(saw_base_water, "expected background water pixels");
|
||||||
|
assert!(saw_feature, "expected lake/river overlay pixels");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn render_hill_has_all_four_bands() {
|
fn render_hill_has_all_four_bands_and_hydrology_overlays() {
|
||||||
let grid = HeightGrid::radial_hill(33, 33, 10.0).unwrap();
|
let grid = HeightGrid::radial_hill(33, 33, 10.0).unwrap();
|
||||||
let img = render_top_down(&grid, &fixture_scene());
|
let img = render_top_down(&grid, &fixture_scene());
|
||||||
|
|
||||||
@@ -287,6 +474,7 @@ mod tests {
|
|||||||
let mut saw_lowland = false;
|
let mut saw_lowland = false;
|
||||||
let mut saw_highland = false;
|
let mut saw_highland = false;
|
||||||
let mut saw_snow = false;
|
let mut saw_snow = false;
|
||||||
|
let mut saw_overlay = false;
|
||||||
for y in 0..img.height() {
|
for y in 0..img.height() {
|
||||||
for x in 0..img.width() {
|
for x in 0..img.width() {
|
||||||
match img.get_pixel(x, y).0 {
|
match img.get_pixel(x, y).0 {
|
||||||
@@ -294,7 +482,7 @@ mod tests {
|
|||||||
c if c == LOWLAND_COLOR => saw_lowland = true,
|
c if c == LOWLAND_COLOR => saw_lowland = true,
|
||||||
c if c == HIGHLAND_COLOR => saw_highland = true,
|
c if c == HIGHLAND_COLOR => saw_highland = true,
|
||||||
c if c == SNOW_COLOR => saw_snow = true,
|
c if c == SNOW_COLOR => saw_snow = true,
|
||||||
other => panic!("unexpected color {other:?} at ({x},{y})"),
|
_ => saw_overlay = true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -302,15 +490,33 @@ mod tests {
|
|||||||
assert!(saw_lowland, "expected at least one lowland pixel");
|
assert!(saw_lowland, "expected at least one lowland pixel");
|
||||||
assert!(saw_highland, "expected at least one highland pixel");
|
assert!(saw_highland, "expected at least one highland pixel");
|
||||||
assert!(saw_snow, "expected at least one snow pixel");
|
assert!(saw_snow, "expected at least one snow pixel");
|
||||||
|
assert!(saw_overlay, "expected lake/river overlay pixels");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn render_center_of_hill_is_snow() {
|
fn render_center_of_hill_is_snow() {
|
||||||
let grid = HeightGrid::radial_hill(5, 5, 10.0).unwrap();
|
let grid = HeightGrid::radial_hill(5, 5, 10.0).unwrap();
|
||||||
let img = render_top_down(&grid, &fixture_scene());
|
let img = render_top_down(&grid, &dry_scene());
|
||||||
assert_eq!(img.get_pixel(2, 2).0, SNOW_COLOR);
|
assert_eq!(img.get_pixel(2, 2).0, SNOW_COLOR);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_top_down_applies_vertical_exaggeration() {
|
||||||
|
let grid = HeightGrid::new(1, 1, vec![3.0]).unwrap();
|
||||||
|
let low_scene = dry_scene();
|
||||||
|
let high_scene = Scene {
|
||||||
|
vertical_exaggeration: 2.0,
|
||||||
|
..dry_scene()
|
||||||
|
};
|
||||||
|
|
||||||
|
let low_img = render_top_down(&grid, &low_scene);
|
||||||
|
let high_img = render_top_down(&grid, &high_scene);
|
||||||
|
|
||||||
|
assert_eq!(low_img.get_pixel(0, 0).0, LOWLAND_COLOR);
|
||||||
|
assert_eq!(high_img.get_pixel(0, 0).0, HIGHLAND_COLOR);
|
||||||
|
assert_ne!(low_img.as_raw(), high_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();
|
||||||
@@ -320,6 +526,30 @@ mod tests {
|
|||||||
assert_eq!(a.as_raw(), b.as_raw());
|
assert_eq!(a.as_raw(), b.as_raw());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_quality_presets_change_sampling_and_blur() {
|
||||||
|
let preview = RenderQualityPreset::Preview.profile();
|
||||||
|
let balanced = RenderQualityPreset::Balanced.profile();
|
||||||
|
let final_profile = RenderQualityPreset::Final.profile();
|
||||||
|
|
||||||
|
assert_eq!(preview.label, "Preview");
|
||||||
|
assert_eq!(preview.top_down_blur_passes, 0);
|
||||||
|
assert!(preview.perspective_step > balanced.perspective_step);
|
||||||
|
assert!(balanced.perspective_step > final_profile.perspective_step);
|
||||||
|
assert!(balanced.top_down_blur_passes > preview.top_down_blur_passes);
|
||||||
|
assert!(final_profile.top_down_blur_passes > balanced.top_down_blur_passes);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_top_down_quality_changes_output_for_detail_presets() {
|
||||||
|
let grid = HeightGrid::radial_hill(33, 33, 10.0).unwrap();
|
||||||
|
let scene = fixture_scene();
|
||||||
|
let preview = render_top_down_with_quality(&grid, &scene, RenderQualityPreset::Preview);
|
||||||
|
let final_img = render_top_down_with_quality(&grid, &scene, RenderQualityPreset::Final);
|
||||||
|
|
||||||
|
assert_ne!(preview.as_raw(), final_img.as_raw());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn render_to_path_writes_png_file() {
|
fn render_to_path_writes_png_file() {
|
||||||
let grid = HeightGrid::radial_hill(8, 8, 10.0).unwrap();
|
let grid = HeightGrid::radial_hill(8, 8, 10.0).unwrap();
|
||||||
@@ -428,6 +658,25 @@ mod tests {
|
|||||||
let _img = render_perspective(&plane, &demo_scene(&plane), 16, 16);
|
let _img = render_perspective(&plane, &demo_scene(&plane), 16, 16);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_perspective_applies_vertical_exaggeration() {
|
||||||
|
let grid = HeightGrid::radial_hill(32, 32, 10.0).unwrap();
|
||||||
|
let low_scene = demo_scene(&grid);
|
||||||
|
let high_scene = Scene {
|
||||||
|
vertical_exaggeration: 2.0,
|
||||||
|
..demo_scene(&grid)
|
||||||
|
};
|
||||||
|
|
||||||
|
let low_img = render_perspective(&grid, &low_scene, 32, 32);
|
||||||
|
let high_img = render_perspective(&grid, &high_scene, 32, 32);
|
||||||
|
|
||||||
|
assert_ne!(
|
||||||
|
low_img.as_raw(),
|
||||||
|
high_img.as_raw(),
|
||||||
|
"vertical exaggeration should alter perspective output"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn render_perspective_to_path_writes_png_file() {
|
fn render_perspective_to_path_writes_png_file() {
|
||||||
let grid = HeightGrid::radial_hill(16, 16, 10.0).unwrap();
|
let grid = HeightGrid::radial_hill(16, 16, 10.0).unwrap();
|
||||||
|
|||||||
+92
-1
@@ -24,10 +24,13 @@ impl Vec3 {
|
|||||||
pub struct Camera {
|
pub struct Camera {
|
||||||
pub position: Vec3,
|
pub position: Vec3,
|
||||||
pub target: Vec3,
|
pub target: Vec3,
|
||||||
/// Heading, pitch, and bank in degrees.
|
/// Camera heading (yaw), pitch, and bank/roll in degrees.
|
||||||
pub orientation: Vec3,
|
pub orientation: Vec3,
|
||||||
|
/// Vertical field of view in degrees used by the pinhole camera.
|
||||||
pub fov_degrees: f32,
|
pub fov_degrees: f32,
|
||||||
|
/// Near clipping distance in world units from the camera.
|
||||||
pub near_range: f32,
|
pub near_range: f32,
|
||||||
|
/// Far clipping distance in world units from the camera.
|
||||||
pub far_range: f32,
|
pub far_range: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +66,9 @@ impl Default for Light {
|
|||||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct Palette {
|
pub struct Palette {
|
||||||
|
pub water_level: f32,
|
||||||
|
pub tree_line: f32,
|
||||||
|
pub snow_line: f32,
|
||||||
pub water: [u8; 3],
|
pub water: [u8; 3],
|
||||||
pub lowland: [u8; 3],
|
pub lowland: [u8; 3],
|
||||||
pub highland: [u8; 3],
|
pub highland: [u8; 3],
|
||||||
@@ -72,6 +78,9 @@ pub struct Palette {
|
|||||||
impl Default for Palette {
|
impl Default for Palette {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
water_level: 1.0,
|
||||||
|
tree_line: 4.0,
|
||||||
|
snow_line: 7.0,
|
||||||
water: [30, 70, 130],
|
water: [30, 70, 130],
|
||||||
lowland: [70, 130, 50],
|
lowland: [70, 130, 50],
|
||||||
highland: [120, 100, 80],
|
highland: [120, 100, 80],
|
||||||
@@ -80,18 +89,72 @@ impl Default for Palette {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Palette {
|
||||||
|
pub fn sync_thresholds_from_scene(&mut self, water_level: f32, tree_line: f32, snow_line: f32) {
|
||||||
|
self.water_level = water_level;
|
||||||
|
self.tree_line = tree_line;
|
||||||
|
self.snow_line = snow_line;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct Hydrology {
|
pub struct Hydrology {
|
||||||
pub river_level: f32,
|
pub river_level: f32,
|
||||||
pub lake_level: f32,
|
pub lake_level: f32,
|
||||||
pub drainage: f32,
|
pub drainage: f32,
|
||||||
|
/// Normalized center point for the generated lake overlay.
|
||||||
|
pub lake_center_x: f32,
|
||||||
|
/// Normalized center point for the generated lake overlay.
|
||||||
|
pub lake_center_z: f32,
|
||||||
|
/// Normalized radius for the generated lake overlay.
|
||||||
|
pub lake_radius: f32,
|
||||||
|
/// Normalized centerline for the generated river overlay.
|
||||||
|
pub river_center_x: f32,
|
||||||
|
/// Normalized half-width for the generated river overlay.
|
||||||
|
pub river_width: f32,
|
||||||
|
/// Horizontal meander amplitude for the generated river overlay.
|
||||||
|
pub river_bend: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn smoothstep(edge0: f32, edge1: f32, value: f32) -> f32 {
|
||||||
|
if edge0 >= edge1 {
|
||||||
|
return if value < edge0 { 0.0 } else { 1.0 };
|
||||||
|
}
|
||||||
|
let t = ((value - edge0) / (edge1 - edge0)).clamp(0.0, 1.0);
|
||||||
|
t * t * (3.0 - 2.0 * t)
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Hydrology {
|
impl Hydrology {
|
||||||
pub fn effective_water_level(self, base_water_level: f32) -> f32 {
|
pub fn effective_water_level(self, base_water_level: f32) -> f32 {
|
||||||
(base_water_level.max(self.river_level).max(self.lake_level) - self.drainage).max(0.0)
|
(base_water_level.max(self.river_level).max(self.lake_level) - self.drainage).max(0.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Coverage of the generated lake primitive at normalized coordinates.
|
||||||
|
pub fn lake_coverage(self, x: f32, z: f32) -> f32 {
|
||||||
|
let radius = self.lake_radius.max(0.0);
|
||||||
|
if radius <= 0.0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let dx = x - self.lake_center_x;
|
||||||
|
let dz = z - self.lake_center_z;
|
||||||
|
let dist = (dx * dx + dz * dz).sqrt();
|
||||||
|
let falloff = (radius * 0.2).max(0.01);
|
||||||
|
1.0 - smoothstep(radius - falloff, radius + falloff, dist)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Coverage of the generated river primitive at normalized coordinates.
|
||||||
|
pub fn river_coverage(self, x: f32, z: f32) -> f32 {
|
||||||
|
let half_width = self.river_width.max(0.0);
|
||||||
|
if half_width <= 0.0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let meander = self.river_bend * (z * std::f32::consts::TAU * 1.25).sin();
|
||||||
|
let center_x = (self.river_center_x + meander).clamp(0.0, 1.0);
|
||||||
|
let dist = (x - center_x).abs();
|
||||||
|
let falloff = (half_width * 0.35).max(0.01);
|
||||||
|
1.0 - smoothstep(half_width - falloff, half_width + falloff, dist)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Hydrology {
|
impl Default for Hydrology {
|
||||||
@@ -100,6 +163,12 @@ impl Default for Hydrology {
|
|||||||
river_level: 0.5,
|
river_level: 0.5,
|
||||||
lake_level: 0.75,
|
lake_level: 0.75,
|
||||||
drainage: 0.0,
|
drainage: 0.0,
|
||||||
|
lake_center_x: 0.38,
|
||||||
|
lake_center_z: 0.62,
|
||||||
|
lake_radius: 0.16,
|
||||||
|
river_center_x: 0.58,
|
||||||
|
river_width: 0.08,
|
||||||
|
river_bend: 0.08,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -188,4 +257,26 @@ mod tests {
|
|||||||
assert_eq!(s.camera, Camera::default());
|
assert_eq!(s.camera, Camera::default());
|
||||||
assert_eq!(s.light, Light::default());
|
assert_eq!(s.light, Light::default());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hydrology_default_generates_distinct_lake_and_river_shapes() {
|
||||||
|
let h = Hydrology::default();
|
||||||
|
assert!(h.lake_radius > 0.0);
|
||||||
|
assert!(h.river_width > 0.0);
|
||||||
|
assert!(h.lake_coverage(h.lake_center_x, h.lake_center_z) > 0.9);
|
||||||
|
assert!(h.river_coverage(h.river_center_x, 0.5) > 0.5);
|
||||||
|
assert!(h.lake_coverage(0.0, 0.0) < 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hydrology_clamps_and_falls_off_with_distance() {
|
||||||
|
let h = Hydrology {
|
||||||
|
river_bend: 0.0,
|
||||||
|
..Hydrology::default()
|
||||||
|
};
|
||||||
|
let center = h.lake_coverage(h.lake_center_x, h.lake_center_z);
|
||||||
|
let far = h.lake_coverage(0.95, 0.05);
|
||||||
|
assert!(center > far);
|
||||||
|
assert!(h.river_coverage(h.river_center_x, 0.5) > h.river_coverage(0.0, 0.5));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-2
@@ -88,7 +88,11 @@ impl From<toml::de::Error> for SceneFileError {
|
|||||||
|
|
||||||
/// Serialize `scene` into a TOML string with schema and version headers.
|
/// Serialize `scene` into a TOML string with schema and version headers.
|
||||||
pub fn to_toml_string(scene: &Scene) -> Result<String, SceneFileError> {
|
pub fn to_toml_string(scene: &Scene) -> Result<String, SceneFileError> {
|
||||||
let file = SceneFile::new(*scene);
|
let mut scene = *scene;
|
||||||
|
scene
|
||||||
|
.palette
|
||||||
|
.sync_thresholds_from_scene(scene.water_level, scene.tree_line, scene.snow_line);
|
||||||
|
let file = SceneFile::new(scene);
|
||||||
Ok(toml::to_string_pretty(&file)?)
|
Ok(toml::to_string_pretty(&file)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +105,11 @@ pub fn from_toml_str(text: &str) -> Result<Scene, SceneFileError> {
|
|||||||
if file.version != SCENE_VERSION {
|
if file.version != SCENE_VERSION {
|
||||||
return Err(SceneFileError::UnsupportedVersion(file.version));
|
return Err(SceneFileError::UnsupportedVersion(file.version));
|
||||||
}
|
}
|
||||||
Ok(file.scene)
|
let mut scene = file.scene;
|
||||||
|
scene
|
||||||
|
.palette
|
||||||
|
.sync_thresholds_from_scene(scene.water_level, scene.tree_line, scene.snow_line);
|
||||||
|
Ok(scene)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write `scene` to `path` as an OpenVistaPro `.ovp.toml` scene file.
|
/// Write `scene` to `path` as an OpenVistaPro `.ovp.toml` scene file.
|
||||||
@@ -150,8 +158,17 @@ mod tests {
|
|||||||
river_level: 0.8,
|
river_level: 0.8,
|
||||||
lake_level: 1.1,
|
lake_level: 1.1,
|
||||||
drainage: 0.2,
|
drainage: 0.2,
|
||||||
|
lake_center_x: 0.42,
|
||||||
|
lake_center_z: 0.58,
|
||||||
|
lake_radius: 0.11,
|
||||||
|
river_center_x: 0.6,
|
||||||
|
river_width: 0.09,
|
||||||
|
river_bend: 0.04,
|
||||||
},
|
},
|
||||||
palette: Palette {
|
palette: Palette {
|
||||||
|
water_level: 0.5,
|
||||||
|
tree_line: 3.0,
|
||||||
|
snow_line: 8.0,
|
||||||
water: [12, 34, 56],
|
water: [12, 34, 56],
|
||||||
lowland: [78, 90, 12],
|
lowland: [78, 90, 12],
|
||||||
highland: [123, 111, 99],
|
highland: [123, 111, 99],
|
||||||
|
|||||||
@@ -141,6 +141,11 @@ pub fn run_script(script: &Script, base_dir: &Path) -> Result<ExecReport, Script
|
|||||||
scene.water_level = thresholds.water;
|
scene.water_level = thresholds.water;
|
||||||
scene.tree_line = thresholds.tree;
|
scene.tree_line = thresholds.tree;
|
||||||
scene.snow_line = thresholds.snow;
|
scene.snow_line = thresholds.snow;
|
||||||
|
scene.palette.sync_thresholds_from_scene(
|
||||||
|
thresholds.water,
|
||||||
|
thresholds.tree,
|
||||||
|
thresholds.snow,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Command::ImportHeightmap { path } => {
|
Command::ImportHeightmap { path } => {
|
||||||
grid = Some(load_heightmap(&resolve(base_dir, path))?);
|
grid = Some(load_heightmap(&resolve(base_dir, path))?);
|
||||||
|
|||||||
Reference in New Issue
Block a user