feat: wire shell placeholders to backend actions #12

Merged
moldybits merged 11 commits from feat/terrain-gen-abstraction into main 2026-05-17 19:30:57 -04:00
5 changed files with 356 additions and 64 deletions
Showing only changes of commit 71d3bff986 - Show all commits
+1 -1
View File
@@ -29,7 +29,7 @@ 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, file/status chrome, script/path placeholders, and a CPU-rendered terrain preview.
Importer status:
+2 -2
View File
@@ -34,9 +34,9 @@ Notes:
| Script language parser | MakePath guide and VistaPro manual describe scripts and “Run Script” workflows. | Partial | `src/script.rs` parser, tests in `src/script.rs`, `README.md` script section. | Parser exists, but script execution is intentionally deferred. |
| Script execution and animation frames | MakePath guide says scripts should render full animations and VistaPro can run scripts from the Script menu. | Planned | No script runner or frame-sequencing engine exists yet. | Add execution semantics once the command model is stable. |
| 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). | Planned | No path generator or motion-model layer exists yet. | This is a separate planner/animation feature, not just a script parser. |
| 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/bin/openvistapro_app.rs`. | Current UI is an egui CPU-preview shell with a small control set, not the legacy menu hierarchy. |
| Modern 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/bin/openvistapro_app.rs`, `docs/knowledgebase/ui-panel-map.md`. | Current UI is a dockable egui CPU-preview shell with terrain, scene/camera, render, viewport, file/status, and scripts/paths surfaces; import/path execution and legacy dialogs remain disabled or planned. |
| Legacy image / landscape export formats | VistaPro manuals mention saving rendered images and landscapes in formats like IFF/IFF24/RGB and DEM/binary landscape files. | Planned | Current output is PNG plus project-owned `.ovp.toml` scenes. | Add separate compatibility/export work only after the clean internal pipeline is stable. |
## Current reconciliation summary
OpenVistaPro already covers the core clean-room pipeline: terrain grids, open importers, scene state, preview/final rendering, project-owned scene files, and a small script parser. The remaining VistaPro-specific gaps cluster around legacy compatibility, richer scene controls, script execution, MakePath-style animation tooling, and the old dense UI/menu workflow.
OpenVistaPro already covers the core clean-room pipeline: terrain grids, open importers, scene state, preview/final rendering, project-owned scene files, and a small script parser. The remaining VistaPro-specific gaps cluster around legacy compatibility, richer scene controls, script execution, MakePath-style animation tooling, and the modernized docked shell work needed to replace the old dense UI/menu workflow.
+49
View File
@@ -0,0 +1,49 @@
# OpenVistaPro UI Panel Map
This is a normalized modern shell map derived from the VistaPro manuals, screenshots, and the current OpenVistaPro codebase. It is intentionally not a 1:1 recreation of the legacy menu hierarchy; instead, it groups the old surfaces into a docked shell that can grow with the product.
## Proposed panel layout
| 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` already 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 placeholder import entry point; 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 and scene-band sliders exist. Lens/range, orientation axes, and richer VistaPro camera semantics are still missing. |
| Render | Preview vs final render, quality/smoothing, detail tradeoffs | Left dock, toolbar, or render tab | Partial | Current code toggles top-down vs perspective render mode, but there is no dedicated quality profile or render preset UI. |
| Scripts / paths | Script menu, Run Script, MakePath path tools, animation-frame workflows | Right dock or modal workflow | Partial | Script parsing exists in the codebase, the shell now surfaces a script editor and placeholder path controls, but execution and path generation are still deferred. |
| File / project actions | New/Open/Save landscape, scene load/save, export commands | Top bar / file menu | Partial | The shell now shows scene-file status and disabled file actions; load/save execution is still wired only in the backend modules. |
| Status / feedback | Coordinate readouts, render state, file path, progress, messages | Bottom status bar | Partial | The shell now has a bottom status bar driven by `AppData::ui_snapshot()`. |
| Deferred features / legacy compatibility | Dense menu hierarchy, advanced lighting, hydrology, vertical exaggeration, palette editing, legacy image/landscape exports | Right dock, tool drawer, or dialogs | Planned | These are best surfaced as future tabs or dialogs rather than cluttering the initial shell. |
## Recommended shell structure
A practical first-pass shell is:
1. Top bar for file/project actions and render mode shortcuts.
2. Left dock for terrain/import and scene/camera controls.
3. Center viewport for preview output.
4. Right dock for scripts/paths and deferred advanced features.
5. Bottom status bar for current scene, source, and render feedback.
That layout preserves the VistaPro workflow while making room for modern discoverability and incremental feature growth.
## Panel-by-panel implementation summary
| Panel | Code support today | Implementation evidence | Priority |
|---|---|---|---|
| Viewport / preview | Present | `src/app.rs` renders the preview into `CentralPanel`; `src/app_state.rs` builds the preview image. | High |
| Terrain / import | Partial | `TerrainPreset` and `AppAction::SetTerrainPreset` in `src/app_state.rs`; terrain radio buttons in `src/app.rs`. | High |
| Scene / camera | Partial | `Scene`, camera position/target controls, and scene-band sliders in `src/app.rs` and `src/app_state.rs`. | High |
| Render | Partial | `RendererMode` plus preview switching in `src/app_state.rs` and `src/app.rs`. | High |
| Scripts / paths | Not yet | `docs/knowledgebase/feature-inventory.md` marks scripts and path generation as planned. | Medium |
| File / project actions | Not yet | `loaded_scene_path` exists in `AppData`, but there is no visible file/project UI yet. | Medium |
| Status / feedback | Not yet | No dedicated status widget or state binding is present. | Medium |
| Deferred features / legacy compatibility | Not yet | These remain future work in the feature inventory. | Low |
## Remaining gaps
- No legacy-style menu/dialog layer for file, export, or script workflows.
- No docked status bar or live feedback line.
- No UI for orientation, lens/range, or vertical exaggeration.
- No dedicated scripts/paths editor surface.
- No palette editor, hydrology controls, or legacy export panels.
+154 -61
View File
@@ -1,7 +1,7 @@
use eframe::egui;
use image::RgbImage;
use crate::app_state::{AppAction, AppData, RendererMode, TerrainPreset};
use crate::app_state::{AppAction, AppData, RendererMode, TerrainPreset, UiShellSnapshot};
use crate::scene::Vec3;
pub const WINDOW_TITLE: &str = "OpenVistaPro";
@@ -39,76 +39,169 @@ impl eframe::App for OpenVistaProApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
let mut changed = false;
egui::SidePanel::left("scene_controls").show(ctx, |ui| {
ui.heading("OpenVistaPro");
ui.label("CPU preview shell");
egui::SidePanel::left("scene_controls")
.resizable(true)
.show(ctx, |ui| {
ui.heading("OpenVistaPro");
ui.label("CPU preview shell");
ui.separator();
ui.label("Terrain");
let mut preset = self.data.terrain_preset;
changed |= ui
.radio_value(&mut preset, TerrainPreset::RadialHill, "Radial hill")
.changed();
changed |= ui
.radio_value(&mut preset, TerrainPreset::Plane, "Plane")
.changed();
if preset != self.data.terrain_preset {
self.data.apply(AppAction::SetTerrainPreset(preset));
}
ui.separator();
ui.label("Terrain");
let mut preset = self.data.terrain_preset;
changed |= ui
.radio_value(&mut preset, TerrainPreset::RadialHill, "Radial hill")
.changed();
changed |= ui
.radio_value(&mut preset, TerrainPreset::Plane, "Plane")
.changed();
if preset != self.data.terrain_preset {
self.data.apply(AppAction::SetTerrainPreset(preset));
}
ui.separator();
ui.label("Renderer");
let mut renderer_mode = self.data.renderer_mode;
changed |= ui
.radio_value(&mut renderer_mode, RendererMode::TopDown, "Top-down")
.changed();
changed |= ui
.radio_value(&mut renderer_mode, RendererMode::Perspective, "Perspective")
.changed();
if renderer_mode != self.data.renderer_mode {
self.data.apply(AppAction::SetRendererMode(renderer_mode));
}
ui.separator();
ui.label("Renderer");
let mut renderer_mode = self.data.renderer_mode;
changed |= ui
.radio_value(&mut renderer_mode, RendererMode::TopDown, "Top-down")
.changed();
changed |= ui
.radio_value(&mut renderer_mode, RendererMode::Perspective, "Perspective")
.changed();
if renderer_mode != self.data.renderer_mode {
self.data.apply(AppAction::SetRendererMode(renderer_mode));
}
ui.separator();
ui.label("Scene bands");
let mut water = self.data.scene.water_level;
let mut trees = self.data.scene.tree_line;
let mut snow = self.data.scene.snow_line;
let mut haze = self.data.scene.haze;
changed |= ui
.add(egui::Slider::new(&mut water, -5.0..=10.0).text("Water"))
.changed();
changed |= ui
.add(egui::Slider::new(&mut trees, -5.0..=12.0).text("Trees"))
.changed();
changed |= ui
.add(egui::Slider::new(&mut snow, -5.0..=15.0).text("Snow"))
.changed();
changed |= ui
.add(egui::Slider::new(&mut haze, 0.0..=1.0).text("Haze"))
.changed();
self.data.apply(AppAction::SetWaterLevel(water));
self.data.apply(AppAction::SetTreeLine(trees));
self.data.apply(AppAction::SetSnowLine(snow));
self.data.apply(AppAction::SetHaze(haze));
ui.separator();
ui.label("Scene bands");
let mut water = self.data.scene.water_level;
let mut trees = self.data.scene.tree_line;
let mut snow = self.data.scene.snow_line;
let mut haze = self.data.scene.haze;
changed |= ui
.add(egui::Slider::new(&mut water, -5.0..=10.0).text("Water"))
.changed();
changed |= ui
.add(egui::Slider::new(&mut trees, -5.0..=12.0).text("Trees"))
.changed();
changed |= ui
.add(egui::Slider::new(&mut snow, -5.0..=15.0).text("Snow"))
.changed();
changed |= ui
.add(egui::Slider::new(&mut haze, 0.0..=1.0).text("Haze"))
.changed();
self.data.apply(AppAction::SetWaterLevel(water));
self.data.apply(AppAction::SetTreeLine(trees));
self.data.apply(AppAction::SetSnowLine(snow));
self.data.apply(AppAction::SetHaze(haze));
ui.separator();
ui.label("Camera");
let mut camera_position = self.data.scene.camera.position;
let mut camera_target = self.data.scene.camera.target;
changed |= vec3_controls(ui, "Position", &mut camera_position);
changed |= vec3_controls(ui, "Target", &mut camera_target);
self.data
.apply(AppAction::SetCameraPosition(camera_position));
self.data.apply(AppAction::SetCameraTarget(camera_target));
});
ui.separator();
ui.label("Camera");
let mut camera_position = self.data.scene.camera.position;
let mut camera_target = self.data.scene.camera.target;
changed |= vec3_controls(ui, "Position", &mut camera_position);
changed |= vec3_controls(ui, "Target", &mut camera_target);
self.data
.apply(AppAction::SetCameraPosition(camera_position));
self.data.apply(AppAction::SetCameraTarget(camera_target));
});
egui::SidePanel::right("entry_points")
.resizable(true)
.show(ctx, |ui| {
ui.heading("Scripts / paths");
ui.separator();
ui.label("Import terrain");
ui.horizontal(|ui| {
let import_path = self
.data
.import_path
.as_deref()
.unwrap_or("No import path selected");
ui.monospace(import_path);
ui.add_enabled(false, egui::Button::new("Import heightmap…"));
});
ui.label(
"Legacy import surfaces remain planned; the shell only shows the entry point.",
);
ui.separator();
ui.label("Script source");
changed |= ui
.add(
egui::TextEdit::multiline(&mut self.data.script_source)
.desired_rows(8)
.lock_focus(true)
.desired_width(f32::INFINITY),
)
.changed();
ui.horizontal(|ui| {
ui.add_enabled(false, egui::Button::new("Run script"));
ui.label("Parser-only MVP; execution stays disabled.");
});
ui.separator();
ui.label("Path tools");
ui.horizontal(|ui| {
let path_target = self
.data
.path_target
.as_deref()
.unwrap_or("No path target selected");
ui.monospace(path_target);
ui.add_enabled(false, egui::Button::new("Make path"));
});
ui.label(
"Path generation is still a planned feature; only the entry point is visible.",
);
});
if changed || self.texture.is_none() {
self.rebuild_texture(ctx);
ctx.request_repaint();
}
let snapshot = self.data.ui_snapshot();
egui::TopBottomPanel::top("project_bar").show(ctx, |ui| {
ui.horizontal_wrapped(|ui| {
ui.label(snapshot.scene_file_label.as_str());
match snapshot.scene_file_path.as_deref() {
Some(path) => ui.monospace(path),
None => ui.weak("No scene file loaded"),
};
ui.separator();
ui.add_enabled(false, egui::Button::new("New"));
ui.add_enabled(false, egui::Button::new("Open…"));
ui.add_enabled(false, egui::Button::new("Save"));
});
});
egui::TopBottomPanel::bottom("status_bar").show(ctx, |ui| {
ui.horizontal_wrapped(|ui| {
ui.label(snapshot.status_line.as_str());
ui.separator();
ui.monospace(format!(
"scripts: {} cmd / {} render / {} import",
snapshot.script_preview.command_count,
snapshot.script_preview.render_commands,
snapshot.script_preview.import_commands,
));
if let Some(error) = snapshot.script_preview.error.as_deref() {
ui.colored_label(ui.visuals().error_fg_color, error);
}
});
});
egui::CentralPanel::default().show(ctx, |ui| {
ui.heading("Preview");
ui.vertical_centered(|ui| {
ui.heading("Preview");
ui.label(format!(
"{} · {}",
snapshot.terrain_preset_label, snapshot.renderer_mode_label
));
});
ui.separator();
if let Some(texture) = &self.texture {
ui.image((texture.id(), texture.size_vec2()));
} else {
+150
View File
@@ -2,6 +2,7 @@ use image::RgbImage;
use crate::render::{render_perspective, render_top_down};
use crate::scene::{Scene, Vec3};
use crate::script::{Command, parse_script};
use crate::terrain::{HeightGrid, TerrainError};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -17,6 +18,14 @@ impl TerrainPreset {
TerrainPreset::RadialHill => HeightGrid::radial_hill(width, height, 10.0),
}
}
/// Human-readable label used by the UI shell.
pub fn label(self) -> &'static str {
match self {
TerrainPreset::Plane => "Plane",
TerrainPreset::RadialHill => "Radial hill",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -25,6 +34,82 @@ pub enum RendererMode {
Perspective,
}
impl RendererMode {
/// Human-readable label used by the UI shell.
pub fn label(self) -> &'static str {
match self {
RendererMode::TopDown => "Top-down",
RendererMode::Perspective => "Perspective",
}
}
}
/// A pure, derived summary of a parsed script, suitable for display in the UI.
///
/// All counts are zero and `error` carries the parser message when the script
/// fails to parse, so the shell can render a single consistent preview shape.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ScriptPreview {
/// Total number of parsed commands.
pub command_count: usize,
/// Number of `render output` commands.
pub render_commands: usize,
/// Number of `import heightmap` commands.
pub import_commands: usize,
/// Parser error message, if the script did not parse.
pub error: Option<String>,
}
impl ScriptPreview {
/// Build a preview by running the backend script parser over `source`.
fn from_source(source: &str) -> Self {
match parse_script(source) {
Ok(script) => {
let render_commands = script
.commands
.iter()
.filter(|command| matches!(command, Command::RenderOutput { .. }))
.count();
let import_commands = script
.commands
.iter()
.filter(|command| matches!(command, Command::ImportHeightmap { .. }))
.count();
Self {
command_count: script.commands.len(),
render_commands,
import_commands,
error: None,
}
}
Err(error) => Self {
error: Some(error.to_string()),
..Self::default()
},
}
}
}
/// A pure, immutable snapshot of the UI shell state.
///
/// The egui app reads this each frame instead of reaching into [`AppData`]
/// directly. Labels are stable strings; optional values are `None` when the
/// corresponding entry point has not been wired to a real workflow yet.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UiShellSnapshot {
pub terrain_preset_label: String,
pub renderer_mode_label: String,
pub scene_file_label: String,
pub import_label: String,
pub script_label: String,
pub path_label: String,
pub scene_file_path: Option<String>,
pub import_path: Option<String>,
pub path_target: Option<String>,
pub status_line: String,
pub script_preview: ScriptPreview,
}
#[derive(Debug, Clone, PartialEq)]
pub struct AppData {
pub scene: Scene,
@@ -32,6 +117,12 @@ pub struct AppData {
pub renderer_mode: RendererMode,
pub preview_size: (u32, u32),
pub loaded_scene_path: Option<String>,
/// Heightmap path selected for import; `None` until import is wired up.
pub import_path: Option<String>,
/// Path-tool target; `None` until path generation is wired up.
pub path_target: Option<String>,
/// Current script source text edited in the Scripts / paths panel.
pub script_source: String,
}
impl Default for AppData {
@@ -42,6 +133,9 @@ impl Default for AppData {
renderer_mode: RendererMode::TopDown,
preview_size: (256, 256),
loaded_scene_path: None,
import_path: None,
path_target: None,
script_source: String::new(),
}
}
}
@@ -61,6 +155,29 @@ impl AppData {
self.preview_size = (width.max(1), height.max(1));
}
AppAction::SetLoadedScenePath(path) => self.loaded_scene_path = path,
AppAction::SetScriptSource(source) => self.script_source = source,
}
}
/// 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;
UiShellSnapshot {
terrain_preset_label: self.terrain_preset.label().to_string(),
renderer_mode_label: self.renderer_mode.label().to_string(),
scene_file_label: "Scene file".to_string(),
import_label: "Import terrain".to_string(),
script_label: "Scripts / paths".to_string(),
path_label: "Path tools".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 · {} · {} · {width}×{height}",
self.terrain_preset.label(),
self.renderer_mode.label(),
),
script_preview: ScriptPreview::from_source(&self.script_source),
}
}
@@ -92,6 +209,7 @@ pub enum AppAction {
SetCameraTarget(Vec3),
SetPreviewSize { width: u32, height: u32 },
SetLoadedScenePath(Option<String>),
SetScriptSource(String),
}
#[cfg(test)]
@@ -165,6 +283,38 @@ mod tests {
assert_eq!(preview.height(), 64);
}
#[test]
fn ui_snapshot_exposes_existing_controls_and_new_entry_points() {
let app = AppData::default();
let shell = app.ui_snapshot();
assert_eq!(shell.terrain_preset_label, "Radial hill");
assert_eq!(shell.renderer_mode_label, "Top-down");
assert_eq!(shell.scene_file_label, "Scene file");
assert_eq!(shell.import_label, "Import terrain");
assert_eq!(shell.script_label, "Scripts / paths");
assert_eq!(shell.path_label, "Path tools");
assert!(shell.scene_file_path.is_none());
assert!(shell.import_path.is_none());
assert!(shell.path_target.is_none());
assert!(shell.status_line.contains("CPU preview"));
}
#[test]
fn ui_snapshot_uses_backend_script_parser_for_command_counts() {
let mut app = AppData::default();
app.apply(AppAction::SetScriptSource(
"use preset plane\nrender output \"out.png\"\n".into(),
));
let shell = app.ui_snapshot();
assert_eq!(shell.script_preview.command_count, 2);
assert_eq!(shell.script_preview.render_commands, 1);
assert_eq!(shell.script_preview.import_commands, 0);
assert!(shell.script_preview.error.is_none());
}
#[cfg(not(feature = "app"))]
#[test]
fn app_feature_is_declared_but_not_enabled_by_default() {