feat: add GUI terrain generation controls #19
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user