From fc70b4bbb0758a95f72eadb05f011885f5a297e9 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Thu, 18 Jun 2026 11:18:46 -0400 Subject: [PATCH] feat: add GUI terrain generation controls --- README.md | 11 ++- docs/knowledgebase/ui-panel-map.md | 2 +- src/app.rs | 53 ++++++++++- src/app_state.rs | 140 ++++++++++++++++++++++++++++- 4 files changed, 199 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 72514c0..a878955 100644 --- a/README.md +++ b/README.md @@ -33,9 +33,18 @@ cargo run --features app --bin openvistapro_app The crate’s default `cargo run` target is the CLI binary (`openvistapro`), so the commands above work without an extra `--bin` flag. Use `--features app --bin openvistapro_app` for the optional GUI shell. The optional app shell is gated behind the `app` feature so default CLI builds stay GPU-free. -It opens an `eframe`/`egui` window titled `OpenVistaPro` with scene controls, top-level menus/dialogs, and a CPU-rendered terrain preview. +It opens an `eframe`/`egui` window titled `OpenVistaPro` with scene controls, top-level menus/dialogs, a Terrain section, and a CPU-rendered terrain preview. The native shell uses eframe's Glow renderer under X11/Xvfb, which keeps the app smoke launch independent of WGPU backend feature selection. +Terrain workflow in the GUI: + +- Open the Terrain section in the left dock, or use Terrain → Generate current preset from the top menu. +- Choose Plane, Radial hill, or Fractal noise; when Fractal noise is selected, edit the Fractal seed field. +- Click Generate current preset to build the active preset into the live preview. +- Generated terrain updates the preview directly; if generation fails, the previous imported terrain stays in place. + +This GUI path is interactive preview-first. The CLI `cargo run -- render --preset fractal --seed 1337 ...` path writes an output image, and script workflows use `use preset fractal` plus `render output ...` to drive the same generation boundary non-interactively. + Importer status: - `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. diff --git a/docs/knowledgebase/ui-panel-map.md b/docs/knowledgebase/ui-panel-map.md index f983cc8..65247de 100644 --- a/docs/knowledgebase/ui-panel-map.md +++ b/docs/knowledgebase/ui-panel-map.md @@ -7,7 +7,7 @@ This is a normalized modern shell map derived from the VistaPro manuals, screens | Modern panel | VistaPro surfaces it absorbs | Suggested placement | Current code support | Notes / gaps | |---|---|---|---|---| | Viewport / preview | Main render window, map preview, perspective view, preview/final render output | Center dock | Partial | `src/app.rs` renders the CPU preview into `CentralPanel`; perspective and top-down preview modes exist, but there is no GPU viewport or direct manipulation overlay yet. | -| Terrain / import | Load Landscape, Import, terrain source selection, generated terrain presets | Left dock or collapsible section | Partial | The current shell exposes project-owned terrain presets (`Plane`, `RadialHill`) and a working heightmap import action; legacy format import UI is still absent. | +| Terrain / import | Load Landscape, Import, terrain source selection, generated terrain presets | Left dock or collapsible section | Partial | The current shell exposes project-owned terrain presets (`Plane`, `Radial hill`, `Fractal noise`) plus a `Fractal seed` field and `Generate current preset` action, alongside a working heightmap import action; the GUI builds terrain into the live preview, while CLI/script workflows drive the same generator non-interactively. Legacy format import UI is still absent. | | Scene / camera | Camera and target gadgets, lens/range, bank/heading/pitch, water/tree/snow/haze controls | Left dock or inspector stack | Partial | Position/target, explicit heading/pitch/bank controls, lens/FOV/clip range controls, vertical exaggeration, lighting direction/intensity gadgets, color-map editing, and hydrology overlays now live in `src/app.rs` and `src/app_state.rs`; `src/scene.rs`, `src/render.rs`, and `src/script_exec.rs` carry the model/render semantics. The shell covers the main VistaPro scene controls, but its camera semantics are intentionally simplified and not yet tied to any map-click placement workflow. | | Render | Preview vs final render, quality/smoothing, detail tradeoffs | Left dock, toolbar, or render tab | Partial | Current code now exposes preview/balanced/final quality presets alongside top-down vs perspective render mode; the shell still lacks the full legacy menu chrome and fine-grained smoothing sliders. | | Scripts / paths | Script menu, Run Script, MakePath path tools, animation-frame workflows | Right dock or modal workflow | Partial | Script parsing/execution and MakePath-style path generation now run end-to-end in the backend; the shell surfaces a script editor, Run Script, and Make Path controls, but animation-frame export and richer path editing are still future work. | diff --git a/src/app.rs b/src/app.rs index 4355dfe..72e7f49 100644 --- a/src/app.rs +++ b/src/app.rs @@ -138,6 +138,13 @@ impl OpenVistaProApp { } }); + ui.menu_button("Terrain", |ui| { + if ui.button("Generate current preset").clicked() { + changed |= self.generate_current_terrain(action_note); + 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()); @@ -200,7 +207,7 @@ impl OpenVistaProApp { ui.label(self.shell.section_summary()); ui.separator(); match self.shell.active_section { - ShellSection::Terrain => changed |= self.terrain_controls(ui), + ShellSection::Terrain => changed |= self.terrain_controls(ui, action_note), ShellSection::Scene => changed |= self.scene_controls(ui), ShellSection::Render => changed |= self.render_controls(ui), ShellSection::Import => changed |= self.import_controls(ui, action_note), @@ -212,7 +219,7 @@ impl OpenVistaProApp { changed } - fn terrain_controls(&mut self, ui: &mut egui::Ui) -> bool { + fn terrain_controls(&mut self, ui: &mut egui::Ui, action_note: &mut Option) -> bool { let mut changed = false; let mut preset = self.data.terrain_preset; changed |= ui @@ -234,6 +241,29 @@ impl OpenVistaProApp { self.data.apply(AppAction::SetTerrainPreset(preset)); } + ui.separator(); + ui.horizontal(|ui| { + if ui.button("Generate current preset").clicked() { + changed |= self.generate_current_terrain(action_note); + } + ui.label("Build the active terrain preset into the preview."); + }); + if let Some(grid) = self.data.generated_grid.as_ref() { + ui.label(format!( + "Generated terrain: {}×{}", + grid.width(), + grid.height() + )); + } else if let Some(grid) = self.data.imported_grid.as_ref() { + ui.label(format!( + "Imported terrain: {}×{}", + grid.width(), + grid.height() + )); + } else { + ui.label("The preview is still showing the current preset directly."); + } + ui.separator(); ui.label("Fractal seed"); let mut seed = self.data.terrain_seed; @@ -248,6 +278,25 @@ impl OpenVistaProApp { changed } + fn generate_current_terrain(&mut self, action_note: &mut Option) -> bool { + let terrain_label = self.data.terrain_preset.label(); + match self.data.generate_terrain() { + Ok(grid) => { + *action_note = Some(format!( + "generated {} terrain into {}×{} preview", + terrain_label, + grid.width(), + grid.height() + )); + true + } + Err(error) => { + *action_note = Some(format!("terrain generation failed: {error}")); + false + } + } + } + fn render_controls(&mut self, ui: &mut egui::Ui) -> bool { let mut changed = false; let mut renderer_mode = self.data.renderer_mode; diff --git a/src/app_state.rs b/src/app_state.rs index 3d69eeb..3a25a7f 100644 --- a/src/app_state.rs +++ b/src/app_state.rs @@ -168,6 +168,9 @@ pub struct AppData { pub import_path: Option, /// Path-tool target or summary. pub path_target: Option, + /// Generated terrain currently active in the preview, if the shell has + /// explicitly generated the current preset. + pub generated_grid: Option, /// Current script source text edited in the Scripts / paths panel. pub script_source: String, /// Height grid currently imported through the shell. @@ -194,6 +197,7 @@ impl Default for AppData { loaded_scene_path: Some(scene_path), import_path: Some(import_path.clone()), path_target: None, + generated_grid: None, script_source: format!( "use preset hill\nimport heightmap \"{import_path}\"\nset thresholds water=1.0 tree=4.0 snow=7.0\nrender output \"{script_output}\"\n" ), @@ -207,8 +211,18 @@ impl Default for AppData { 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::SetTerrainPreset(preset) => { + self.terrain_preset = preset; + if self.generated_grid.is_some() { + let _ = self.refresh_generated_grid(); + } + } + AppAction::SetTerrainSeed(seed) => { + self.terrain_seed = seed; + if self.generated_grid.is_some() { + let _ = self.refresh_generated_grid(); + } + } AppAction::SetRendererMode(mode) => self.renderer_mode = mode, AppAction::SetRenderQuality(quality) => self.render_quality = quality, AppAction::SetWaterLevel(value) => self.scene.water_level = value, @@ -275,6 +289,9 @@ impl AppData { AppAction::SetPalette(palette) => self.scene.palette = palette, AppAction::SetPreviewSize { width, height } => { self.preview_size = (width.max(1), height.max(1)); + if self.generated_grid.is_some() { + let _ = self.refresh_generated_grid(); + } } AppAction::SetLoadedScenePath(path) => self.loaded_scene_path = path, AppAction::SetScriptSource(source) => self.script_source = source, @@ -283,6 +300,7 @@ impl AppData { pub fn reset_scene(&mut self) { self.scene = Scene::default(); + self.generated_grid = None; self.imported_grid = None; self.generated_path = None; self.last_script_run = None; @@ -295,6 +313,7 @@ impl AppData { pub fn open_scene(&mut self, path: &Path) -> Result<(), SceneFileError> { let scene = scene_file::load_from_path(path)?; self.scene = scene; + self.generated_grid = None; self.imported_grid = None; self.generated_path = None; self.last_script_run = None; @@ -314,6 +333,7 @@ impl AppData { ) -> Result<(), Box> { let source = std::fs::read_to_string(path)?; let imported = crate::import::import_ovp_text(&source)?; + self.generated_grid = None; self.imported_grid = Some(imported.into_grid()); self.import_path = Some(path.display().to_string()); Ok(()) @@ -325,6 +345,15 @@ impl AppData { Ok(report) } + /// Generates the current terrain preset and makes it the active preview source. + pub fn generate_terrain(&mut self) -> Result<&HeightGrid, TerrainError> { + self.refresh_generated_grid()?; + Ok(self + .generated_grid + .as_ref() + .expect("generated grid just refreshed")) + } + pub fn make_path(&mut self) -> CameraPath { let path = build_demo_path(&self.scene); self.path_target = Some(format!( @@ -338,6 +367,9 @@ impl AppData { } fn active_height_grid(&self) -> Result { + if let Some(grid) = &self.generated_grid { + return Ok(grid.clone()); + } if let Some(grid) = &self.imported_grid { return Ok(grid.clone()); } @@ -370,7 +402,8 @@ impl AppData { import_path: self.import_path.clone(), path_target: self.path_target.clone(), status_line: format!( - "CPU preview · {} · {} · {} · exag {:.2} · {width}×{height}", + "CPU preview · {} · {} · {} · {} · exag {:.2} · {width}×{height}", + self.terrain_source_label(), terrain_status, self.renderer_mode.label(), self.render_quality.label(), @@ -384,6 +417,24 @@ impl AppData { self.active_height_grid() } + fn refresh_generated_grid(&mut self) -> Result<(), TerrainError> { + let (width, height) = self.preview_size; + let grid = self.terrain_preset.build_grid(width, height)?; + self.imported_grid = None; + self.generated_grid = Some(grid); + Ok(()) + } + + fn terrain_source_label(&self) -> &'static str { + if self.generated_grid.is_some() { + "generated terrain" + } else if self.imported_grid.is_some() { + "imported terrain" + } else { + "preset terrain" + } + } + pub fn render_preview(&self) -> Result { let grid = self.build_preview_grid()?; let (width, height) = self.preview_size; @@ -461,6 +512,7 @@ mod tests { assert_eq!(app.preview_size, (256, 256)); assert!(app.loaded_scene_path.is_some()); assert!(app.script_source.contains("import heightmap")); + assert!(app.generated_grid.is_none()); } #[test] @@ -584,6 +636,49 @@ mod tests { assert_ne!(preview.as_raw(), final_img.as_raw()); } + #[test] + fn perspective_renderer_changes_the_gui_preview_path() { + let mut app = AppData::default(); + app.apply(AppAction::SetPreviewSize { + width: 48, + height: 32, + }); + + let top_down = app.render_preview().unwrap(); + app.apply(AppAction::SetRendererMode(RendererMode::Perspective)); + let perspective = app.render_preview().unwrap(); + let snapshot = app.ui_snapshot(); + + assert_eq!(perspective.width(), 48); + assert_eq!(perspective.height(), 32); + assert_ne!(top_down.as_raw(), perspective.as_raw()); + assert!(snapshot.status_line.contains("Perspective")); + } + + #[test] + fn fractal_seed_changes_the_gui_preview_terrain() { + let mut app = AppData::default(); + app.apply(AppAction::SetPreviewSize { + width: 48, + height: 32, + }); + app.apply(AppAction::SetRendererMode(RendererMode::Perspective)); + app.apply(AppAction::SetTerrainPreset(TerrainPreset::Fractal { + seed: 2, + })); + + let seed_two = app.render_preview().unwrap(); + app.apply(AppAction::SetTerrainSeed(9001)); + app.apply(AppAction::SetTerrainPreset(TerrainPreset::Fractal { + seed: 9001, + })); + let seed_nine_thousand_one = app.render_preview().unwrap(); + + assert_eq!(seed_two.width(), 48); + assert_eq!(seed_two.height(), 32); + assert_ne!(seed_two.as_raw(), seed_nine_thousand_one.as_raw()); + } + #[test] fn ui_snapshot_exposes_existing_controls_and_new_entry_points() { let app = AppData::default(); @@ -601,6 +696,7 @@ mod tests { assert!(shell.import_path.is_some()); assert!(shell.path_target.is_none()); assert!(shell.status_line.contains("CPU preview")); + assert!(shell.status_line.contains("preset terrain")); assert!(shell.status_line.contains("Preview")); } @@ -634,6 +730,44 @@ mod tests { assert!(app.imported_grid.is_some()); } + #[test] + fn generate_terrain_replaces_imported_preview_source() { + let mut app = AppData::default(); + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/open/tiny-heightfield.ovptext"); + + app.import_heightmap_from_path(&fixture) + .expect("import fixture"); + let preview_size = app.preview_size; + let generated_dims = { + let generated = app.generate_terrain().expect("generate terrain"); + (generated.width(), generated.height()) + }; + let grid = app.build_preview_grid().expect("preview grid"); + + assert_eq!(generated_dims.0, preview_size.0); + assert_eq!(generated_dims.1, preview_size.1); + assert_eq!(grid.width(), preview_size.0); + assert!(app.imported_grid.is_none()); + assert!(app.generated_grid.is_some()); + assert!(app.ui_snapshot().status_line.contains("generated terrain")); + } + + #[test] + fn generated_terrain_tracks_preset_changes() { + let mut app = AppData::default(); + app.generate_terrain().expect("generate terrain"); + let before = app + .build_preview_grid() + .expect("preview grid before change"); + + app.apply(AppAction::SetTerrainPreset(TerrainPreset::Plane)); + let after = app.build_preview_grid().expect("preview grid after change"); + + assert!(before.min_max().expect("generated grid has min/max").1 > 0.0); + assert_eq!(after.min_max(), Some((0.0, 0.0))); + } + #[test] fn make_path_produces_a_three_keyframe_summary() { let mut app = AppData::default(); -- 2.39.5