feat: add GUI terrain generation controls #19

Merged
moldybot merged 1 commits from feat/terrain-gen-abstraction into main 2026-09-03 22:41:48 -04:00
4 changed files with 199 additions and 7 deletions
+10 -1
View File
@@ -33,9 +33,18 @@ cargo run --features app --bin openvistapro_app
The crates 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.
+1 -1
View File
@@ -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. |
+51 -2
View File
@@ -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<String>) -> 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<String>) -> 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;
+137 -3
View File
@@ -168,6 +168,9 @@ pub struct AppData {
pub import_path: Option<String>,
/// Path-tool target or summary.
pub path_target: Option<String>,
/// Generated terrain currently active in the preview, if the shell has
/// explicitly generated the current preset.
pub generated_grid: Option<HeightGrid>,
/// 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<dyn std::error::Error>> {
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<HeightGrid, TerrainError> {
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<RgbImage, TerrainError> {
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();