fix: use Glow renderer for app smoke startup #17
Generated
+1
-8
@@ -584,6 +584,7 @@ dependencies = [
|
|||||||
"egui-wgpu",
|
"egui-wgpu",
|
||||||
"egui-winit",
|
"egui-winit",
|
||||||
"egui_glow",
|
"egui_glow",
|
||||||
|
"glow",
|
||||||
"glutin",
|
"glutin",
|
||||||
"glutin-winit",
|
"glutin-winit",
|
||||||
"image",
|
"image",
|
||||||
@@ -594,7 +595,6 @@ dependencies = [
|
|||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pollster",
|
|
||||||
"profiling",
|
"profiling",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
"static_assertions",
|
"static_assertions",
|
||||||
@@ -602,7 +602,6 @@ dependencies = [
|
|||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
"web-time",
|
"web-time",
|
||||||
"wgpu",
|
|
||||||
"winapi",
|
"winapi",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.59.0",
|
||||||
"winit",
|
"winit",
|
||||||
@@ -1990,12 +1989,6 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pollster"
|
|
||||||
version = "0.4.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "portable-atomic"
|
name = "portable-atomic"
|
||||||
version = "1.13.1"
|
version = "1.13.1"
|
||||||
|
|||||||
+1
-2
@@ -12,8 +12,7 @@ import-geotiff = ["dep:geotiff-reader"]
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clap = { version = "4.6.1", features = ["derive"] }
|
clap = { version = "4.6.1", features = ["derive"] }
|
||||||
eframe = { version = "0.32.3", optional = true, default-features = false, features = ["default_fonts", "wayland", "wgpu", "x11"] }
|
eframe = { version = "0.32.3", optional = true, default-features = false, features = ["default_fonts", "glow", "wayland", "x11"] }
|
||||||
#wgpu = { version = "25.0.2", features = ["metal"] }
|
|
||||||
image = { version = "0.25.9", default-features = false, features = ["png"] }
|
image = { version = "0.25.9", default-features = false, features = ["png"] }
|
||||||
geotiff-reader = { version = "0.4.0", optional = true, default-features = false, features = ["local"] }
|
geotiff-reader = { version = "0.4.0", optional = true, default-features = false, features = ["local"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ 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, top-level menus/dialogs, 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.
|
||||||
|
The native shell uses eframe's Glow renderer under X11/Xvfb, which keeps the app smoke launch independent of WGPU backend feature selection.
|
||||||
|
|
||||||
Importer status:
|
Importer status:
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
Start simple, then split into crates when module boundaries stabilize.
|
Start simple, then split into crates when module boundaries stabilize.
|
||||||
|
|
||||||
- `src/terrain.rs`: height grid, bounds, sampling, and deterministic terrain fixtures.
|
- `src/terrain.rs`: height grid, bounds, sampling, and deterministic terrain fixtures.
|
||||||
- `src/terrain_gen.rs`: `TerrainGenerationSpec` and `DeterministicTerrainGenerator` for the seeded terrain-generation pipeline; `cargo test terrain_gen` exercises the determinism/seed note.
|
- `src/terrain_gen.rs`: `TerrainGenerationSpec`, `TerrainGenerationSettings`, and `DeterministicTerrainGenerator` for the seeded terrain-generation pipeline; `cargo test terrain_gen` exercises the determinism/seed note and the first shipped `fractal` preset.
|
||||||
- `src/import.rs`: importers for open/safe formats (`ovp-text`, feature-gated HGT, feature-gated ESRI ASCII Grid, and feature-gated GeoTIFF via `src/import/geotiff.rs`); historical compatibility later. Each importer yields the same internal `HeightGrid` plus `TerrainSourceMetadata`, keeping source formats out of renderer code.
|
- `src/import.rs`: importers for open/safe formats (`ovp-text`, feature-gated HGT, feature-gated ESRI ASCII Grid, and feature-gated GeoTIFF via `src/import/geotiff.rs`); historical compatibility later. Each importer yields the same internal `HeightGrid` plus `TerrainSourceMetadata`, keeping source formats out of renderer code.
|
||||||
- `src/scene.rs` and `src/scene_file.rs`: camera, light, atmosphere, water/vegetation thresholds, and `.ovp.toml` persistence.
|
- `src/scene.rs` and `src/scene_file.rs`: camera, light, atmosphere, water/vegetation thresholds, and `.ovp.toml` persistence.
|
||||||
- `src/render.rs`: deterministic CPU top-down renderer plus CPU perspective demo renderer; WGPU renderer later.
|
- `src/render.rs`: deterministic CPU top-down renderer plus CPU perspective demo renderer; WGPU renderer later.
|
||||||
|
|||||||
@@ -8,12 +8,12 @@ This is a normalized modern shell map derived from the VistaPro manuals, screens
|
|||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| 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. |
|
| 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 working heightmap import action; 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, 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. |
|
| 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, lighting direction/intensity gadgets, 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 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. |
|
| 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/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. |
|
| 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 working New/Open/Save controls; legacy menu chrome and export dialogs are still missing. |
|
| File / project actions | New/Open/Save landscape, scene load/save, export commands | Top bar / file menu | Partial | The shell now shows scene-file status plus working New/Open/Save controls, and the top menu mirrors those actions alongside import and help/about entry points; legacy menu chrome and export dialogs are still missing. |
|
||||||
| 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()`. |
|
| 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()`. |
|
||||||
| 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 direct legacy export compatibility is intentionally out of scope for now. |
|
| 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, and the modern shell now exposes a top menu, about/help dialog, and lighting numeric controls, but the legacy-style menu/dialog workflow is still incomplete. |
|
||||||
|
|
||||||
## Recommended shell structure
|
## Recommended shell structure
|
||||||
|
|
||||||
|
|||||||
@@ -30,9 +30,15 @@ screenshots, archives, disk images, or local-only reference payloads under
|
|||||||
`tests/`. Those belong in the git-ignored `reference/` and `.work/` directories
|
`tests/`. Those belong in the git-ignored `reference/` and `.work/` directories
|
||||||
and must stay local-only.
|
and must stay local-only.
|
||||||
|
|
||||||
Importers parse only open or synthetic formats. The `ovp-text` plain-text
|
Importers parse only open or synthetic formats. The supported terrain source
|
||||||
heightfield format read by `src/import.rs` is an original OpenVistaPro format;
|
boundary currently consists of the original OpenVistaPro `ovp-text` plain-text
|
||||||
it is not a reimplementation of any proprietary VistaPro file layout.
|
heightfield format, script-level PNG heightmaps, and open SRTM/HGT, ESRI ASCII
|
||||||
|
Grid, and GeoTIFF inputs. These all normalize to the same internal terrain
|
||||||
|
model and are not reimplementations of any proprietary VistaPro file layout.
|
||||||
|
|
||||||
|
Legacy VistaPro terrain/image compatibility remains out of scope for the clean-
|
||||||
|
room project unless a future, separately reviewed compatibility investigation
|
||||||
|
explicitly approves it.
|
||||||
|
|
||||||
## Reverse engineering boundary
|
## Reverse engineering boundary
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Define the next terrain-generation workstream so future slices stay small, deter
|
|||||||
|
|
||||||
OpenVistaPro already has:
|
OpenVistaPro already has:
|
||||||
|
|
||||||
- `src/terrain_gen.rs` with `TerrainGenerationSpec` and `DeterministicTerrainGenerator`, plus `cargo test terrain_gen` coverage for the determinism/seed note.
|
- `src/terrain_gen.rs` with `TerrainGenerationSpec`, `TerrainGenerationSettings`, and `DeterministicTerrainGenerator`, plus `cargo test terrain_gen` coverage for the determinism/seed note.
|
||||||
|
|
||||||
- `src/terrain.rs` with immutable `HeightGrid` storage, safe indexing, min/max, and deterministic `plane` / `radial_hill` fixtures.
|
- `src/terrain.rs` with immutable `HeightGrid` storage, safe indexing, min/max, and deterministic `plane` / `radial_hill` fixtures.
|
||||||
- `src/render.rs` with a deterministic top-down preview and a CPU perspective spike that only depends on `HeightGrid` + `Scene`.
|
- `src/render.rs` with a deterministic top-down preview and a CPU perspective spike that only depends on `HeightGrid` + `Scene`.
|
||||||
@@ -16,7 +16,7 @@ OpenVistaPro already has:
|
|||||||
- `src/import.rs` for the open-format import boundary.
|
- `src/import.rs` for the open-format import boundary.
|
||||||
- `docs/plans/initial-roadmap.md` and `docs/plans/phase-4-formats-scripts-ui.md` for the broader project sequence.
|
- `docs/plans/initial-roadmap.md` and `docs/plans/phase-4-formats-scripts-ui.md` for the broader project sequence.
|
||||||
|
|
||||||
The missing piece is a dedicated procedural terrain generator pipeline that can produce richer synthetic landscapes without mixing algorithm state into `HeightGrid`.
|
The core seeded procedural terrain pipeline now exists; the remaining work in this roadmap is about future generator variants and richer presets, not the first shipped fractal slice.
|
||||||
|
|
||||||
## Decision summary
|
## Decision summary
|
||||||
|
|
||||||
@@ -108,6 +108,7 @@ Suggested first presets:
|
|||||||
- `plain`-like flat terrain via zeroed noise amplitude
|
- `plain`-like flat terrain via zeroed noise amplitude
|
||||||
- `island` / `continental` profile with a radial falloff mask
|
- `island` / `continental` profile with a radial falloff mask
|
||||||
- `mountain` profile with stronger octave contrast
|
- `mountain` profile with stronger octave contrast
|
||||||
|
- `fractal` / `noise` preset exposed through the app shell and CLI as the first shipped procedural family
|
||||||
|
|
||||||
Acceptance:
|
Acceptance:
|
||||||
|
|
||||||
@@ -154,6 +155,7 @@ cargo fmt --check
|
|||||||
cargo test terrain
|
cargo test terrain
|
||||||
cargo test
|
cargo test
|
||||||
cargo clippy --all-targets -- -D warnings
|
cargo clippy --all-targets -- -D warnings
|
||||||
|
cargo run -- render --preset fractal --seed 1337 --width 64 --height 64 --output /tmp/openvistapro-fractal-plan.png
|
||||||
```
|
```
|
||||||
|
|
||||||
Add one feature-specific smoke command for the slice once the generator has a public entry point, for example a tiny render or sample-generation command that writes to `/tmp` and proves the generated grid is usable end-to-end.
|
Add one feature-specific smoke command for the slice once the generator has a public entry point, for example a tiny render or sample-generation command that writes to `/tmp` and proves the generated grid is usable end-to-end.
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# WGPU/egui Interactive App Architecture Spike
|
||||||
|
|
||||||
|
> Goal: introduce an interactive OpenVistaPro app without destabilizing the existing CLI, importer pipeline, or deterministic CPU reference renderer.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Keep the repository as a single Rust crate for now.
|
||||||
|
|
||||||
|
The current boundary set is still moving, but it is already useful:
|
||||||
|
|
||||||
|
- `src/terrain.rs`, `src/terrain_gen.rs`, `src/import.rs`, `src/scene.rs`, `src/scene_file.rs`, `src/script.rs`, `src/script_exec.rs`, `src/path.rs`, `src/render.rs`, and `src/colormap.rs` are the clean-room core.
|
||||||
|
- `src/cli.rs` and `src/main.rs` are the command-line adapter.
|
||||||
|
- `src/app_state.rs`, `src/ui_shell.rs`, `src/app.rs`, and `src/bin/openvistapro_app.rs` are the optional interactive shell.
|
||||||
|
|
||||||
|
A workspace split into `openvistapro-core`, `openvistapro-cli`, and `openvistapro-app` would add maintenance overhead before there is a second renderer/backend boundary that truly justifies it. The better near-term move is to keep a monolith and enforce boundaries with modules, feature flags, and tests.
|
||||||
|
|
||||||
|
Revisit a workspace split only when at least one of these becomes true:
|
||||||
|
|
||||||
|
1. the GPU renderer needs materially different dependencies from the CLI/reference renderer,
|
||||||
|
2. the core API stabilizes enough that the app shell can depend on it as a published internal crate boundary, or
|
||||||
|
3. build times / dependency fanout make the single-crate layout painful.
|
||||||
|
|
||||||
|
## Integration recommendation
|
||||||
|
|
||||||
|
Use egui as the interactive shell now, and treat WGPU as the rendering backend that arrives later behind a narrow viewport abstraction.
|
||||||
|
|
||||||
|
Recommended path:
|
||||||
|
|
||||||
|
1. Keep `eframe` for the windowing / egui host while the shell is still mostly controls and state.
|
||||||
|
2. Keep the current CPU renderer as the canonical reference output for tests and CLI smoke runs.
|
||||||
|
3. Introduce a tiny viewport trait or adapter boundary before any full GPU renderer lands.
|
||||||
|
4. Add a WGPU-backed viewport only once the render pipeline actually needs GPU surfaces, textures, and custom passes.
|
||||||
|
|
||||||
|
Why this order:
|
||||||
|
|
||||||
|
- `eframe` already gives a working egui host with the least glue.
|
||||||
|
- CLI builds stay GPU-free because the app stays behind the `app` feature.
|
||||||
|
- The CPU renderer remains the reference implementation for determinism and testability.
|
||||||
|
- WGPU can then become an implementation detail of the viewport, not a rewrite of the app state model.
|
||||||
|
|
||||||
|
## Module boundaries
|
||||||
|
|
||||||
|
Keep the following responsibilities separate:
|
||||||
|
|
||||||
|
### Core domain
|
||||||
|
|
||||||
|
- `terrain.rs`: immutable height grid and sampling rules.
|
||||||
|
- `terrain_gen.rs`: deterministic procedural generation inputs and output grid creation.
|
||||||
|
- `import.rs`: import boundary and source metadata.
|
||||||
|
- `scene.rs`: camera, lighting, hydrology, and palette-linked scene state.
|
||||||
|
- `scene_file.rs`: `.ovp.toml` persistence.
|
||||||
|
- `script.rs` / `script_exec.rs`: project-owned script language and executor.
|
||||||
|
- `path.rs`: MakePath-style camera path generation.
|
||||||
|
- `render.rs`: deterministic CPU render outputs.
|
||||||
|
- `colormap.rs`: palette / band mapping.
|
||||||
|
|
||||||
|
### CLI adapter
|
||||||
|
|
||||||
|
- `cli.rs` should stay focused on argument parsing and orchestration.
|
||||||
|
- Keep it thin enough that the same core operations can be called from future automation or from the app shell.
|
||||||
|
|
||||||
|
### Interactive shell state
|
||||||
|
|
||||||
|
- `app_state.rs` should remain pure state plus reducers / actions.
|
||||||
|
- `ui_shell.rs` should own section ordering, labels, and placeholder metadata.
|
||||||
|
- Avoid leaking egui types into either file.
|
||||||
|
|
||||||
|
### egui view/controller
|
||||||
|
|
||||||
|
- `app.rs` should remain the presentation layer that turns `AppData` into panels, menus, and a preview texture.
|
||||||
|
- The shell should consume state snapshots and call backend actions, not embed render or import logic inline.
|
||||||
|
|
||||||
|
### Future GPU viewport
|
||||||
|
|
||||||
|
- Introduce a narrow backend boundary for viewport rendering before the first custom WGPU renderer lands.
|
||||||
|
- Keep surface creation, texture upload, and draw passes out of `AppData`.
|
||||||
|
- Prefer a dedicated GPU adapter module over scattering WGPU setup across the UI code.
|
||||||
|
|
||||||
|
## Minimal first app shell
|
||||||
|
|
||||||
|
The smallest durable shell is:
|
||||||
|
|
||||||
|
- a stable top command bar,
|
||||||
|
- a left or right dock for section-specific controls,
|
||||||
|
- a central viewport that can show the CPU preview now and a GPU surface later,
|
||||||
|
- a status bar for file paths, script status, and render feedback,
|
||||||
|
- a tiny about/help dialog for orientation.
|
||||||
|
|
||||||
|
That is already enough to validate layout, interaction, and state management without pretending the GPU renderer exists yet.
|
||||||
|
|
||||||
|
## Testing strategy
|
||||||
|
|
||||||
|
Keep the test surface split the same way as the code.
|
||||||
|
|
||||||
|
### Unit tests
|
||||||
|
|
||||||
|
- `AppData` reducers and state transitions.
|
||||||
|
- `UiShellState` navigation order and section metadata.
|
||||||
|
- render-mode selection and preview-state plumbing.
|
||||||
|
- scene / script / path / importer behavior.
|
||||||
|
|
||||||
|
### Smoke commands
|
||||||
|
|
||||||
|
Use existing CLI and app entry points as smoke tests for the architecture:
|
||||||
|
|
||||||
|
- `cargo run -- info`
|
||||||
|
- `cargo run -- render --preset fractal --seed 1337 --width 64 --height 64 --output /tmp/openvistapro-fractal.png`
|
||||||
|
- `cargo run --features app --bin openvistapro_app` when a display is available
|
||||||
|
|
||||||
|
### Later integration tests
|
||||||
|
|
||||||
|
Once the GPU viewport exists, add lightweight screenshot or frame-smoke coverage around the viewport adapter rather than baking WGPU assumptions into the core state model.
|
||||||
|
|
||||||
|
## Next tasks
|
||||||
|
|
||||||
|
1. Add a small viewport abstraction so the egui shell can switch between CPU preview and a future GPU backend without changing `AppData`.
|
||||||
|
2. Keep the CPU renderer as the reference implementation for deterministic validation.
|
||||||
|
3. Add a dedicated GPU adapter module only when the WGPU surface truly exists.
|
||||||
|
4. Revisit a workspace split after the GPU path forces a second hard boundary.
|
||||||
|
5. Keep `docs/knowledgebase/architecture-notes.md` and `docs/knowledgebase/ui-panel-map.md` aligned with any later shell or viewport changes.
|
||||||
|
|
||||||
|
## Recommendation summary
|
||||||
|
|
||||||
|
Stay monolithic now, keep egui as the host shell, and treat WGPU as a future viewport backend rather than the reason to split the crate today. That gives OpenVistaPro the fastest path to a usable interactive app while protecting the CLI and deterministic renderer from churn.
|
||||||
+18
-5
@@ -765,14 +765,27 @@ fn rgb_image_to_color_image(image: &RgbImage) -> egui::ColorImage {
|
|||||||
egui::ColorImage::from_rgb(size, image.as_raw())
|
egui::ColorImage::from_rgb(size, image.as_raw())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run() -> eframe::Result<()> {
|
pub(crate) fn native_options() -> eframe::NativeOptions {
|
||||||
let options = eframe::NativeOptions {
|
eframe::NativeOptions {
|
||||||
renderer: eframe::Renderer::Wgpu,
|
renderer: eframe::Renderer::Glow,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run() -> eframe::Result<()> {
|
||||||
eframe::run_native(
|
eframe::run_native(
|
||||||
WINDOW_TITLE,
|
WINDOW_TITLE,
|
||||||
options,
|
native_options(),
|
||||||
Box::new(|_cc| Ok(Box::new(OpenVistaProApp::new()))),
|
Box::new(|_cc| Ok(Box::new(OpenVistaProApp::new()))),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn native_options_prefer_glow_renderer_for_the_interactive_shell() {
|
||||||
|
assert_eq!(native_options().renderer, eframe::Renderer::Glow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+8
-3
@@ -18,7 +18,7 @@
|
|||||||
//! Supported commands:
|
//! Supported commands:
|
||||||
//!
|
//!
|
||||||
//! ```text
|
//! ```text
|
||||||
//! use preset <name> # <name> is `hill` or `plane`
|
//! use preset <name> # <name> is `fractal`, `hill`, or `plane`
|
||||||
//! set thresholds water=<f> tree=<f> snow=<f>
|
//! set thresholds water=<f> tree=<f> snow=<f>
|
||||||
//! import heightmap "<path>"
|
//! import heightmap "<path>"
|
||||||
//! render output "<path>"
|
//! render output "<path>"
|
||||||
@@ -46,6 +46,8 @@ pub struct Script {
|
|||||||
/// A built-in terrain preset selectable via `use preset <name>`.
|
/// A built-in terrain preset selectable via `use preset <name>`.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum PresetName {
|
pub enum PresetName {
|
||||||
|
/// Seeded procedural fractal terrain.
|
||||||
|
Fractal,
|
||||||
/// A single rolling hill.
|
/// A single rolling hill.
|
||||||
Hill,
|
Hill,
|
||||||
/// A flat plane.
|
/// A flat plane.
|
||||||
@@ -182,6 +184,7 @@ fn parse_use(args: &[String], line: usize) -> Result<Command, ParseError> {
|
|||||||
expect_keyword(args.first(), "preset", line, "use")?;
|
expect_keyword(args.first(), "preset", line, "use")?;
|
||||||
let name = expect_arg(args.get(1), line, "use preset", "a preset name")?;
|
let name = expect_arg(args.get(1), line, "use preset", "a preset name")?;
|
||||||
let preset = match name.as_str() {
|
let preset = match name.as_str() {
|
||||||
|
"fractal" => PresetName::Fractal,
|
||||||
"hill" => PresetName::Hill,
|
"hill" => PresetName::Hill,
|
||||||
"plane" => PresetName::Plane,
|
"plane" => PresetName::Plane,
|
||||||
other => return Err(ParseError::new(line, format!("unknown preset {other:?}"))),
|
other => return Err(ParseError::new(line, format!("unknown preset {other:?}"))),
|
||||||
@@ -278,6 +281,7 @@ mod tests {
|
|||||||
fn parses_happy_path_script_into_ast() {
|
fn parses_happy_path_script_into_ast() {
|
||||||
let script = r#"
|
let script = r#"
|
||||||
# OpenVistaPro project-owned script, not legacy VistaPro syntax
|
# OpenVistaPro project-owned script, not legacy VistaPro syntax
|
||||||
|
use preset fractal
|
||||||
use preset hill
|
use preset hill
|
||||||
set thresholds water=0.18 tree=0.42 snow=0.77
|
set thresholds water=0.18 tree=0.42 snow=0.77
|
||||||
import heightmap "data/demo-height.png"
|
import heightmap "data/demo-height.png"
|
||||||
@@ -289,6 +293,7 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
ast.commands,
|
ast.commands,
|
||||||
vec![
|
vec![
|
||||||
|
Command::UsePreset(PresetName::Fractal),
|
||||||
Command::UsePreset(PresetName::Hill),
|
Command::UsePreset(PresetName::Hill),
|
||||||
Command::SetThresholds(SceneThresholds {
|
Command::SetThresholds(SceneThresholds {
|
||||||
water: 0.18,
|
water: 0.18,
|
||||||
@@ -307,14 +312,14 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ignores_comments_blank_lines_and_extra_whitespace() {
|
fn ignores_comments_blank_lines_and_extra_whitespace() {
|
||||||
let script = "\n # comment\n\n\tuse preset plane \n render output \"out.png\" # trailing comment\n";
|
let script = "\n # comment\n\n\tuse preset fractal \n render output \"out.png\" # trailing comment\n";
|
||||||
|
|
||||||
let ast = parse_script(script).unwrap();
|
let ast = parse_script(script).unwrap();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ast.commands,
|
ast.commands,
|
||||||
vec![
|
vec![
|
||||||
Command::UsePreset(PresetName::Plane),
|
Command::UsePreset(PresetName::Fractal),
|
||||||
Command::RenderOutput {
|
Command::RenderOutput {
|
||||||
path: "out.png".into()
|
path: "out.png".into()
|
||||||
},
|
},
|
||||||
|
|||||||
+162
-11
@@ -1,29 +1,104 @@
|
|||||||
use crate::terrain::{HeightGrid, TerrainError};
|
use crate::terrain::{HeightGrid, TerrainError};
|
||||||
|
|
||||||
const OCTAVE_COUNT: usize = 4;
|
const DEFAULT_OCTAVE_COUNT: usize = 4;
|
||||||
const BASE_FREQUENCY: f32 = 2.0;
|
const DEFAULT_BASE_FREQUENCY: f32 = 2.0;
|
||||||
const LACUNARITY: f32 = 2.0;
|
const DEFAULT_LACUNARITY: f32 = 2.0;
|
||||||
const GAIN: f32 = 0.5;
|
const DEFAULT_GAIN: f32 = 0.5;
|
||||||
const LATTICE_SEED_STEP: u64 = 0x9E37_79B9_7F4A_7C15;
|
const LATTICE_SEED_STEP: u64 = 0x9E37_79B9_7F4A_7C15;
|
||||||
const LATTICE_X_MUL: u64 = 0x9E37_79B9_7F4A_7C15;
|
const LATTICE_X_MUL: u64 = 0x9E37_79B9_7F4A_7C15;
|
||||||
const LATTICE_Y_MUL: u64 = 0xC2B2_AE3D_27D4_EB4F;
|
const LATTICE_Y_MUL: u64 = 0xC2B2_AE3D_27D4_EB4F;
|
||||||
const FINALIZER_MUL1: u64 = 0xBF58_476D_1CE4_E5B9;
|
const FINALIZER_MUL1: u64 = 0xBF58_476D_1CE4_E5B9;
|
||||||
const FINALIZER_MUL2: u64 = 0x94D0_49BB_1331_11EB;
|
const FINALIZER_MUL2: u64 = 0x94D0_49BB_1331_11EB;
|
||||||
|
|
||||||
|
fn sanitize_positive(value: f32, fallback: f32) -> f32 {
|
||||||
|
if value.is_finite() && value > 0.0 {
|
||||||
|
value
|
||||||
|
} else {
|
||||||
|
fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize_gain(value: f32) -> f32 {
|
||||||
|
if value.is_finite() {
|
||||||
|
value.clamp(0.0, 1.0)
|
||||||
|
} else {
|
||||||
|
DEFAULT_GAIN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tunable fractal-noise controls for procedural terrain generation.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub struct TerrainGenerationSettings {
|
||||||
|
octave_count: usize,
|
||||||
|
base_frequency: f32,
|
||||||
|
lacunarity: f32,
|
||||||
|
gain: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TerrainGenerationSettings {
|
||||||
|
/// Build a settings bundle, normalizing obviously invalid inputs.
|
||||||
|
pub fn new(octave_count: usize, base_frequency: f32, lacunarity: f32, gain: f32) -> Self {
|
||||||
|
Self {
|
||||||
|
octave_count: octave_count.max(1),
|
||||||
|
base_frequency: sanitize_positive(base_frequency, DEFAULT_BASE_FREQUENCY),
|
||||||
|
lacunarity: sanitize_positive(lacunarity, DEFAULT_LACUNARITY),
|
||||||
|
gain: sanitize_gain(gain),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn octave_count(&self) -> usize {
|
||||||
|
self.octave_count
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn base_frequency(&self) -> f32 {
|
||||||
|
self.base_frequency
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn lacunarity(&self) -> f32 {
|
||||||
|
self.lacunarity
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn gain(&self) -> f32 {
|
||||||
|
self.gain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TerrainGenerationSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new(
|
||||||
|
DEFAULT_OCTAVE_COUNT,
|
||||||
|
DEFAULT_BASE_FREQUENCY,
|
||||||
|
DEFAULT_LACUNARITY,
|
||||||
|
DEFAULT_GAIN,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Describes a terrain generation request.
|
/// Describes a terrain generation request.
|
||||||
///
|
///
|
||||||
/// Specs are immutable once constructed; identical specs always yield
|
/// Specs are immutable once constructed; identical specs always yield
|
||||||
/// identical [`HeightGrid`]s from a given [`TerrainGenerator`].
|
/// identical [`HeightGrid`]s from a given [`TerrainGenerator`].
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
pub struct TerrainGenerationSpec {
|
pub struct TerrainGenerationSpec {
|
||||||
seed: u64,
|
seed: u64,
|
||||||
width: u32,
|
width: u32,
|
||||||
height: u32,
|
height: u32,
|
||||||
|
settings: TerrainGenerationSettings,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TerrainGenerationSpec {
|
impl TerrainGenerationSpec {
|
||||||
/// Creates a spec, rejecting zero-sized grids with [`TerrainError::ZeroDimension`].
|
/// Creates a spec, rejecting zero-sized grids with [`TerrainError::ZeroDimension`].
|
||||||
pub fn new(seed: u64, width: u32, height: u32) -> Result<Self, TerrainError> {
|
pub fn new(seed: u64, width: u32, height: u32) -> Result<Self, TerrainError> {
|
||||||
|
Self::with_settings(seed, width, height, TerrainGenerationSettings::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a spec with explicit fractal settings.
|
||||||
|
pub fn with_settings(
|
||||||
|
seed: u64,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
settings: TerrainGenerationSettings,
|
||||||
|
) -> Result<Self, TerrainError> {
|
||||||
if width == 0 || height == 0 {
|
if width == 0 || height == 0 {
|
||||||
return Err(TerrainError::ZeroDimension);
|
return Err(TerrainError::ZeroDimension);
|
||||||
}
|
}
|
||||||
@@ -31,9 +106,28 @@ impl TerrainGenerationSpec {
|
|||||||
seed,
|
seed,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
|
settings,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Convenience constructor for callers that want to override the default settings inline.
|
||||||
|
pub fn with_parameters(
|
||||||
|
seed: u64,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
octave_count: usize,
|
||||||
|
base_frequency: f32,
|
||||||
|
lacunarity: f32,
|
||||||
|
gain: f32,
|
||||||
|
) -> Result<Self, TerrainError> {
|
||||||
|
Self::with_settings(
|
||||||
|
seed,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
TerrainGenerationSettings::new(octave_count, base_frequency, lacunarity, gain),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn seed(&self) -> u64 {
|
pub fn seed(&self) -> u64 {
|
||||||
self.seed
|
self.seed
|
||||||
}
|
}
|
||||||
@@ -45,6 +139,10 @@ impl TerrainGenerationSpec {
|
|||||||
pub fn height(&self) -> u32 {
|
pub fn height(&self) -> u32 {
|
||||||
self.height
|
self.height
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn settings(&self) -> TerrainGenerationSettings {
|
||||||
|
self.settings
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Produces a [`HeightGrid`] from a [`TerrainGenerationSpec`].
|
/// Produces a [`HeightGrid`] from a [`TerrainGenerationSpec`].
|
||||||
@@ -116,18 +214,18 @@ fn value_noise(seed: u64, x: f32, y: f32) -> f32 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
fn fbm_height(seed: u64, x: f32, y: f32) -> f32 {
|
fn fbm_height(seed: u64, x: f32, y: f32, settings: TerrainGenerationSettings) -> f32 {
|
||||||
let mut total = 0.0;
|
let mut total = 0.0;
|
||||||
let mut amplitude = 1.0;
|
let mut amplitude = 1.0;
|
||||||
let mut frequency = BASE_FREQUENCY;
|
let mut frequency = settings.base_frequency();
|
||||||
let mut amplitude_sum = 0.0;
|
let mut amplitude_sum = 0.0;
|
||||||
|
|
||||||
for octave in 0..OCTAVE_COUNT {
|
for octave in 0..settings.octave_count() {
|
||||||
let octave_seed = seed.wrapping_add((octave as u64).wrapping_mul(LATTICE_SEED_STEP));
|
let octave_seed = seed.wrapping_add((octave as u64).wrapping_mul(LATTICE_SEED_STEP));
|
||||||
total += amplitude * value_noise(octave_seed, x * frequency, y * frequency);
|
total += amplitude * value_noise(octave_seed, x * frequency, y * frequency);
|
||||||
amplitude_sum += amplitude;
|
amplitude_sum += amplitude;
|
||||||
amplitude *= GAIN;
|
amplitude *= settings.gain();
|
||||||
frequency *= LACUNARITY;
|
frequency *= settings.lacunarity();
|
||||||
}
|
}
|
||||||
|
|
||||||
total / amplitude_sum
|
total / amplitude_sum
|
||||||
@@ -142,7 +240,7 @@ impl TerrainGenerator for DeterministicTerrainGenerator {
|
|||||||
let ny = normalized_coord(y, height);
|
let ny = normalized_coord(y, height);
|
||||||
for x in 0..width {
|
for x in 0..width {
|
||||||
let nx = normalized_coord(x, width);
|
let nx = normalized_coord(x, width);
|
||||||
samples.push(fbm_height(spec.seed(), nx, ny));
|
samples.push(fbm_height(spec.seed(), nx, ny, spec.settings()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
HeightGrid::new(width, height, samples)
|
HeightGrid::new(width, height, samples)
|
||||||
@@ -165,6 +263,28 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_normalize_invalid_inputs() {
|
||||||
|
let settings = TerrainGenerationSettings::new(0, -1.0, 0.0, 2.0);
|
||||||
|
assert_eq!(settings.octave_count(), 1);
|
||||||
|
assert_eq!(settings.base_frequency(), DEFAULT_BASE_FREQUENCY);
|
||||||
|
assert_eq!(settings.lacunarity(), DEFAULT_LACUNARITY);
|
||||||
|
assert_eq!(settings.gain(), 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn with_parameters_exposes_custom_settings() {
|
||||||
|
let spec = TerrainGenerationSpec::with_parameters(7, 8, 6, 2, 1.5, 3.0, 0.25).unwrap();
|
||||||
|
let settings = spec.settings();
|
||||||
|
assert_eq!(spec.seed(), 7);
|
||||||
|
assert_eq!(spec.width(), 8);
|
||||||
|
assert_eq!(spec.height(), 6);
|
||||||
|
assert_eq!(settings.octave_count(), 2);
|
||||||
|
assert_eq!(settings.base_frequency(), 1.5);
|
||||||
|
assert_eq!(settings.lacunarity(), 3.0);
|
||||||
|
assert_eq!(settings.gain(), 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn generate_matches_spec_dimensions() {
|
fn generate_matches_spec_dimensions() {
|
||||||
let spec = TerrainGenerationSpec::new(99, 8, 5).unwrap();
|
let spec = TerrainGenerationSpec::new(99, 8, 5).unwrap();
|
||||||
@@ -185,4 +305,35 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_ne!(a.sample(0, 0), b.sample(0, 0));
|
assert_ne!(a.sample(0, 0), b.sample(0, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generate_is_bounded_and_deterministic_for_exact_samples() {
|
||||||
|
let spec = TerrainGenerationSpec::new(1337, 4, 4).unwrap();
|
||||||
|
let generator = DeterministicTerrainGenerator::new();
|
||||||
|
let first = generator.generate(&spec).unwrap();
|
||||||
|
let second = generator.generate(&spec).unwrap();
|
||||||
|
|
||||||
|
let expected = [
|
||||||
|
[0.706_783_f32, 0.390_681, 0.571_295, 0.454_375],
|
||||||
|
[0.667_478, 0.549_327, 0.476_506, 0.335_594],
|
||||||
|
[0.603_516, 0.451_503, 0.516_397, 0.357_081],
|
||||||
|
[0.308_838, 0.295_558, 0.570_510, 0.666_683],
|
||||||
|
];
|
||||||
|
|
||||||
|
for y in 0..4 {
|
||||||
|
for x in 0..4 {
|
||||||
|
let sample = first.sample(x, y).unwrap();
|
||||||
|
assert!((0.0..=1.0).contains(&sample));
|
||||||
|
assert_eq!(sample, second.sample(x, y).unwrap());
|
||||||
|
assert!(
|
||||||
|
(sample - expected[y as usize][x as usize]).abs() < 1e-6,
|
||||||
|
"mismatch at ({x}, {y})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (min, max) = first.min_max().unwrap();
|
||||||
|
assert!(min >= 0.0);
|
||||||
|
assert!(max <= 1.0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
#![cfg(feature = "app")]
|
||||||
|
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn app_binary_starts_under_xvfb_without_panicking() {
|
||||||
|
if Command::new("xvfb-run").arg("--help").output().is_err() {
|
||||||
|
eprintln!("skipping app smoke test because xvfb-run is unavailable");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let bin = env!("CARGO_BIN_EXE_openvistapro_app");
|
||||||
|
let output = Command::new("xvfb-run")
|
||||||
|
.args(["-a", "timeout", "8s", bin])
|
||||||
|
.output()
|
||||||
|
.expect("failed to launch xvfb-run for openvistapro_app");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
output.status.code(),
|
||||||
|
Some(124),
|
||||||
|
"expected the app to start and then be interrupted by timeout; stdout={:?}; stderr={:?}",
|
||||||
|
String::from_utf8_lossy(&output.stdout),
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user