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