diff --git a/README.md b/README.md index 5bdda85..022b89c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/knowledgebase/feature-inventory.md b/docs/knowledgebase/feature-inventory.md index 8dae398..b2c182a 100644 --- a/docs/knowledgebase/feature-inventory.md +++ b/docs/knowledgebase/feature-inventory.md @@ -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. diff --git a/src/app.rs b/src/app.rs index 61bf48a..12e7ae3 100644 --- a/src/app.rs +++ b/src/app.rs @@ -15,6 +15,7 @@ pub struct OpenVistaProApp { data: AppData, shell: UiShellState, texture: Option, + 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) -> 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 = 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); diff --git a/src/app_state.rs b/src/app_state.rs index 5cefef4..3d69eeb 100644 --- a/src/app_state.rs +++ b/src/app_state.rs @@ -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, pub import_path: Option, @@ -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()); diff --git a/src/cli.rs b/src/cli.rs index 149423d..ce95da1 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -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([ diff --git a/src/render.rs b/src/render.rs index faf7a2b..ea6d552 100644 --- a/src/render.rs +++ b/src/render.rs @@ -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(); diff --git a/src/scene.rs b/src/scene.rs index 7ecf823..6c88b38 100644 --- a/src/scene.rs +++ b/src/scene.rs @@ -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(); diff --git a/src/script_exec.rs b/src/script_exec.rs index f245bbe..dd793e6 100644 --- a/src/script_exec.rs +++ b/src/script_exec.rs @@ -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 { + 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");