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
18 changed files with 1329 additions and 68 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ 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, and an ESRI ASCII Grid parser behind the `ascii-grid-import` feature.
- 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).
## Development
+1
View File
@@ -5,6 +5,7 @@
Start simple, then split into crates when module boundaries stabilize.
- `src/terrain.rs`: height grid, bounds, sampling, and deterministic terrain fixtures.
- `src/terrain_gen.rs`: `TerrainGenerationSpec` and `DeterministicTerrainGenerator` for the seeded terrain-generation pipeline; `cargo test terrain_gen` exercises the determinism/seed note.
- `src/import.rs`: importers for open/safe formats (`ovp-text`, feature-gated HGT, feature-gated ESRI ASCII Grid, and feature-gated GeoTIFF via `src/import/geotiff.rs`); historical compatibility later. Each importer yields the same internal `HeightGrid` plus `TerrainSourceMetadata`, keeping source formats out of renderer code.
- `src/scene.rs` and `src/scene_file.rs`: camera, light, atmosphere, water/vegetation thresholds, and `.ovp.toml` persistence.
- `src/render.rs`: deterministic CPU top-down renderer plus CPU perspective demo renderer; WGPU renderer later.
+50
View File
@@ -0,0 +1,50 @@
# 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, orientation, lens/range, vertical exaggeration, palette, and hydrology controls now live in `src/app.rs` and `src/app_state.rs`; `src/scene.rs` and `src/render.rs` carry the model/render semantics. The shell now 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 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 dedicated scripts/paths editor surface.
- Palette import/export and legacy texture loading remain open.
- Hydrology still lacks routed-water simulation and drainage maps.
- Legacy export and compatibility dialogs remain future work.
+15
View File
@@ -721,6 +721,21 @@ Expected: generated script parses successfully.
## Milestone G: WGPU/egui application after CLI stability
**Status:** Tasks G1G4 have landed. `src/app_state.rs` holds testable app state and
`AppAction` reducers; `src/app.rs` and `src/bin/openvistapro_app.rs` provide the
`app`-feature `eframe`/`egui` shell. The shell now docks terrain, scene/camera, and render
controls in a left panel, a scripts/paths panel on the right, a top project bar and a
bottom status bar for file/status chrome, and the CPU top-down/perspective preview in the
center. Still-planned actions — heightmap import, run script, make path, and file
new/open/save — are present as disabled placeholders so the layout is honest about scope.
The shell map is tracked in [`docs/knowledgebase/ui-panel-map.md`](../knowledgebase/ui-panel-map.md).
Remaining UI roadmap: Task G5 (WGPU renderer backend) plus the still-open gaps — wiring
file/import/script/path actions to their backend modules, legacy menus/dialogs,
orientation and lens/range controls, vertical exaggeration, a palette editor, and
hydrology controls. All of it stays clean-room: no proprietary VistaPro assets, menus, or
screenshots enter the repository.
### Task G1: Create app-state crate/module without a window
**Objective:** Separate UI state from rendering and file formats before adding WGPU.
+2
View File
@@ -8,6 +8,8 @@ Define the next terrain-generation workstream so future slices stay small, deter
OpenVistaPro already has:
- `src/terrain_gen.rs` with `TerrainGenerationSpec` and `DeterministicTerrainGenerator`, plus `cargo test terrain_gen` coverage for the determinism/seed note.
- `src/terrain.rs` with immutable `HeightGrid` storage, safe indexing, min/max, and deterministic `plane` / `radial_hill` fixtures.
- `src/render.rs` with a deterministic top-down preview and a CPU perspective spike that only depends on `HeightGrid` + `Scene`.
- `src/scene.rs` and `src/app_state.rs` for scene controls and preview wiring.
+256 -26
View File
@@ -22,6 +22,24 @@ impl OpenVistaProApp {
Self::default()
}
fn default_scene_path() -> String {
format!(
"{}/target/openvistapro-scene.ovp.toml",
env!("CARGO_MANIFEST_DIR")
)
}
fn default_import_path() -> String {
format!(
"{}/tests/fixtures/open/tiny-heightfield.ovptext",
env!("CARGO_MANIFEST_DIR")
)
}
fn default_script_base_dir() -> &'static str {
env!("CARGO_MANIFEST_DIR")
}
fn rebuild_texture(&mut self, ctx: &egui::Context) {
let Ok(preview) = self.data.render_preview() else {
return;
@@ -60,7 +78,7 @@ impl OpenVistaProApp {
/// an implemented surface still show their labelled placeholder.
///
/// Returns `true` when an edit changed something the preview depends on.
fn controls_panel(&mut self, ctx: &egui::Context) -> bool {
fn controls_panel(&mut self, ctx: &egui::Context, action_note: &mut Option<String>) -> bool {
let mut changed = false;
egui::SidePanel::left("controls_panel")
.resizable(true)
@@ -73,12 +91,10 @@ impl OpenVistaProApp {
ShellSection::Terrain => changed |= self.terrain_controls(ui),
ShellSection::Scene => changed |= self.scene_controls(ui),
ShellSection::Render => changed |= self.render_controls(ui),
ShellSection::Import
| ShellSection::Script
| ShellSection::Path
| ShellSection::Project => {
ui.label(self.shell.placeholder_label());
}
ShellSection::Import => changed |= self.import_controls(ui, action_note),
ShellSection::Script => changed |= self.script_controls(ui, action_note),
ShellSection::Path => changed |= self.path_controls(ui, action_note),
ShellSection::Project => changed |= self.project_controls(ui, action_note),
}
});
changed
@@ -147,6 +163,215 @@ impl OpenVistaProApp {
self.data
.apply(AppAction::SetCameraPosition(camera_position));
self.data.apply(AppAction::SetCameraTarget(camera_target));
ui.separator();
ui.label("Camera orientation and lens");
let mut orientation = self.data.scene.camera.orientation;
changed |= vec3_controls(ui, "Orientation", &mut orientation);
self.data
.apply(AppAction::SetCameraOrientation(orientation));
let mut fov_degrees = self.data.scene.camera.fov_degrees;
let mut near_range = self.data.scene.camera.near_range;
let mut far_range = self.data.scene.camera.far_range;
changed |= ui
.add(egui::Slider::new(&mut fov_degrees, 10.0..=170.0).text("FOV"))
.changed();
changed |= ui
.add(egui::Slider::new(&mut near_range, 0.1..=50.0).text("Near"))
.changed();
changed |= ui
.add(egui::Slider::new(&mut far_range, 1.0..=1000.0).text("Far"))
.changed();
self.data.apply(AppAction::SetCameraLens {
fov_degrees,
near_range,
far_range,
});
ui.separator();
ui.label("Vertical exaggeration");
let mut vertical_exaggeration = self.data.scene.vertical_exaggeration;
changed |= ui
.add(egui::Slider::new(&mut vertical_exaggeration, 0.1..=10.0).text("Scale"))
.changed();
self.data
.apply(AppAction::SetVerticalExaggeration(vertical_exaggeration));
ui.separator();
ui.label("Hydrology");
let mut river_level = self.data.scene.hydrology.river_level;
let mut lake_level = self.data.scene.hydrology.lake_level;
let mut drainage = self.data.scene.hydrology.drainage;
changed |= ui
.add(egui::Slider::new(&mut river_level, -5.0..=10.0).text("River"))
.changed();
changed |= ui
.add(egui::Slider::new(&mut lake_level, -5.0..=10.0).text("Lake"))
.changed();
changed |= ui
.add(egui::Slider::new(&mut drainage, 0.0..=5.0).text("Drainage"))
.changed();
self.data.apply(AppAction::SetHydrology {
river_level,
lake_level,
drainage,
});
ui.separator();
ui.label("Palette");
let mut palette = self.data.scene.palette;
ui.horizontal(|ui| {
ui.label("Water");
changed |= ui.color_edit_button_srgb(&mut palette.water).changed();
});
ui.horizontal(|ui| {
ui.label("Lowland");
changed |= ui.color_edit_button_srgb(&mut palette.lowland).changed();
});
ui.horizontal(|ui| {
ui.label("Highland");
changed |= ui.color_edit_button_srgb(&mut palette.highland).changed();
});
ui.horizontal(|ui| {
ui.label("Snow");
changed |= ui.color_edit_button_srgb(&mut palette.snow).changed();
});
self.data.apply(AppAction::SetPalette(palette));
changed
}
fn import_controls(&mut self, ui: &mut egui::Ui, action_note: &mut Option<String>) -> bool {
let mut changed = false;
ui.label("Import terrain");
let import_path = self
.data
.import_path
.clone()
.unwrap_or_else(Self::default_import_path);
ui.horizontal(|ui| {
ui.monospace(import_path.as_str());
if ui.button("Import heightmap…").clicked() {
let path = std::path::Path::new(import_path.as_str());
match self.data.import_heightmap_from_path(path) {
Ok(()) => {
changed = true;
*action_note = Some(format!("imported heightmap from {import_path}"));
}
Err(error) => *action_note = Some(format!("import failed: {error}")),
}
}
});
if let Some(grid) = self.data.imported_grid.as_ref() {
ui.label(format!("Imported grid: {}×{}", grid.width(), grid.height()));
} else {
ui.label(self.shell.placeholder_label());
}
changed
}
fn script_controls(&mut self, ui: &mut egui::Ui, action_note: &mut Option<String>) -> bool {
let mut changed = false;
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| {
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) => {
changed |= !report.outputs.is_empty();
*action_note =
Some(format!("script wrote {} output(s)", report.outputs.len()));
}
Err(error) => *action_note = Some(format!("script run failed: {error}")),
}
}
ui.label("Parser + executor slice; output writes to disk.");
});
if let Some(report) = self.data.last_script_run.as_ref() {
ui.label(format!("Last run wrote {} output(s)", report.outputs.len()));
} else {
ui.label(self.shell.placeholder_label());
}
changed
}
fn path_controls(&mut self, ui: &mut egui::Ui, action_note: &mut Option<String>) -> bool {
let mut changed = false;
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);
if ui.button("Make path").clicked() {
let path = self.data.make_path();
changed = true;
*action_note = Some(format!(
"generated {}",
self.data
.path_target
.clone()
.unwrap_or_else(|| format!("{} keyframes", path.keyframes().len()))
));
}
});
if let Some(path) = self.data.generated_path.as_ref() {
ui.label(format!(
"Generated path: {} keyframes",
path.keyframes().len()
));
} else {
ui.label(self.shell.placeholder_label());
}
changed
}
fn project_controls(&mut self, ui: &mut egui::Ui, action_note: &mut Option<String>) -> bool {
let mut changed = false;
ui.label("Scene file");
let scene_path = self
.data
.loaded_scene_path
.clone()
.unwrap_or_else(Self::default_scene_path);
ui.horizontal(|ui| {
ui.monospace(scene_path.as_str());
if ui.button("New").clicked() {
self.data.reset_scene();
self.data.loaded_scene_path = Some(scene_path.clone());
changed = true;
*action_note = Some(format!("reset scene and kept {scene_path}"));
}
if ui.button("Open…").clicked() {
let path = std::path::Path::new(&scene_path);
match self.data.open_scene(path) {
Ok(()) => {
changed = true;
*action_note = Some(format!("opened scene from {scene_path}"));
}
Err(error) => *action_note = Some(format!("open failed: {error}")),
}
}
if ui.button("Save").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.label(self.shell.placeholder_label());
changed
}
@@ -170,23 +395,6 @@ impl OpenVistaProApp {
});
}
/// Bottom panel: durable status bar.
fn status_panel(&self, ctx: &egui::Context) {
egui::TopBottomPanel::bottom("status_panel").show(ctx, |ui| {
ui.horizontal(|ui| {
ui.label("Ready");
ui.separator();
ui.label(format!("Active: {}", self.shell.section_title()));
ui.separator();
let scene_label = match &self.data.loaded_scene_path {
Some(path) => format!("Scene: {path}"),
None => "Scene: unsaved".to_owned(),
};
ui.label(scene_label);
});
});
}
/// Central viewport: shows the CPU preview, with a placeholder banner for
/// sections whose dedicated surface is not built yet.
fn viewport_panel(&self, ctx: &egui::Context) {
@@ -203,14 +411,36 @@ impl OpenVistaProApp {
}
});
}
/// Bottom panel: durable status bar.
fn status_panel(&self, ctx: &egui::Context, action_note: Option<&str>) {
egui::TopBottomPanel::bottom("status_panel").show(ctx, |ui| {
ui.horizontal(|ui| {
ui.label("Ready");
ui.separator();
ui.label(format!("Active: {}", self.shell.section_title()));
ui.separator();
let scene_label = match &self.data.loaded_scene_path {
Some(path) => format!("Scene: {path}"),
None => "Scene: unsaved".to_owned(),
};
ui.label(scene_label);
if let Some(note) = action_note {
ui.separator();
ui.label(note);
}
});
});
}
}
impl eframe::App for OpenVistaProApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
let mut action_note: Option<String> = None;
self.command_bar(ctx);
let changed = self.controls_panel(ctx);
let changed = self.controls_panel(ctx, &mut action_note);
self.inspector_panel(ctx);
self.status_panel(ctx);
self.status_panel(ctx, action_note.as_deref());
if changed || self.texture.is_none() {
self.rebuild_texture(ctx);
+350 -5
View File
@@ -1,7 +1,13 @@
use std::path::Path;
use image::RgbImage;
use crate::path::{CameraPath, build_demo_path};
use crate::render::{render_perspective, render_top_down};
use crate::scene::{Scene, Vec3};
use crate::scene_file::{self, SceneFileError};
use crate::script::{Command, parse_script};
use crate::script_exec::{self, ExecReport, ScriptError};
use crate::terrain::{HeightGrid, TerrainError};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -17,6 +23,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 +39,86 @@ 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_controls_label: String,
pub palette_label: String,
pub hydrology_label: String,
pub legacy_dialogs_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,16 +126,40 @@ pub struct AppData {
pub renderer_mode: RendererMode,
pub preview_size: (u32, u32),
pub loaded_scene_path: Option<String>,
/// Heightmap path selected for import.
pub import_path: Option<String>,
/// Path-tool target or summary.
pub path_target: Option<String>,
/// Current script source text edited in the Scripts / paths panel.
pub script_source: String,
/// Height grid currently imported through the shell.
pub imported_grid: Option<HeightGrid>,
/// Generated camera path from the Make Path action.
pub generated_path: Option<CameraPath>,
/// Last script execution report.
pub last_script_run: Option<ExecReport>,
}
impl Default for AppData {
fn default() -> Self {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let scene_path = format!("{manifest_dir}/target/openvistapro-scene.ovp.toml");
let import_path = format!("{manifest_dir}/tests/fixtures/open/tiny-heightfield.ovptext");
let script_output = format!("{manifest_dir}/target/openvistapro-script-preview.png");
Self {
scene: Scene::default(),
terrain_preset: TerrainPreset::RadialHill,
renderer_mode: RendererMode::TopDown,
preview_size: (256, 256),
loaded_scene_path: None,
loaded_scene_path: Some(scene_path),
import_path: Some(import_path.clone()),
path_target: 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"
),
imported_grid: None,
generated_path: None,
last_script_run: None,
}
}
}
@@ -55,18 +173,132 @@ impl AppData {
AppAction::SetTreeLine(value) => self.scene.tree_line = value,
AppAction::SetSnowLine(value) => self.scene.snow_line = value,
AppAction::SetHaze(value) => self.scene.haze = value.clamp(0.0, 1.0),
AppAction::SetVerticalExaggeration(value) => {
self.scene.vertical_exaggeration = value.max(0.0)
}
AppAction::SetCameraPosition(position) => self.scene.camera.position = position,
AppAction::SetCameraTarget(target) => self.scene.camera.target = target,
AppAction::SetCameraOrientation(orientation) => {
self.scene.camera.orientation = orientation
}
AppAction::SetCameraLens {
fov_degrees,
near_range,
far_range,
} => {
self.scene.camera.fov_degrees = fov_degrees.clamp(10.0, 170.0);
self.scene.camera.near_range = near_range.max(0.0);
self.scene.camera.far_range = far_range.max(self.scene.camera.near_range + 0.1);
}
AppAction::SetHydrology {
river_level,
lake_level,
drainage,
} => {
self.scene.hydrology.river_level = river_level;
self.scene.hydrology.lake_level = lake_level;
self.scene.hydrology.drainage = drainage.max(0.0);
}
AppAction::SetPalette(palette) => self.scene.palette = palette,
AppAction::SetPreviewSize { width, height } => {
self.preview_size = (width.max(1), height.max(1));
}
AppAction::SetLoadedScenePath(path) => self.loaded_scene_path = path,
AppAction::SetScriptSource(source) => self.script_source = source,
}
}
pub fn reset_scene(&mut self) {
self.scene = Scene::default();
self.imported_grid = None;
self.generated_path = None;
self.last_script_run = None;
self.terrain_preset = TerrainPreset::RadialHill;
self.renderer_mode = RendererMode::TopDown;
}
pub fn open_scene(&mut self, path: &Path) -> Result<(), SceneFileError> {
let scene = scene_file::load_from_path(path)?;
self.scene = scene;
self.imported_grid = None;
self.generated_path = None;
self.last_script_run = None;
self.loaded_scene_path = Some(path.display().to_string());
Ok(())
}
pub fn save_scene(&mut self, path: &Path) -> Result<(), SceneFileError> {
scene_file::save_to_path(&self.scene, path)?;
self.loaded_scene_path = Some(path.display().to_string());
Ok(())
}
pub fn import_heightmap_from_path(
&mut self,
path: &Path,
) -> Result<(), Box<dyn std::error::Error>> {
let source = std::fs::read_to_string(path)?;
let imported = crate::import::import_ovp_text(&source)?;
self.imported_grid = Some(imported.into_grid());
self.import_path = Some(path.display().to_string());
Ok(())
}
pub fn run_script_from_source(&mut self, base_dir: &Path) -> Result<ExecReport, ScriptError> {
let report = script_exec::run_script_source(&self.script_source, base_dir)?;
self.last_script_run = Some(report.clone());
Ok(report)
}
pub fn make_path(&mut self) -> CameraPath {
let path = build_demo_path(&self.scene);
self.path_target = Some(format!(
"{} keyframes · {:.1}s → {:.1}s",
path.keyframes().len(),
path.start_time(),
path.end_time()
));
self.generated_path = Some(path.clone());
path
}
fn active_height_grid(&self) -> Result<HeightGrid, TerrainError> {
if let Some(grid) = &self.imported_grid {
return Ok(grid.clone());
}
let (width, height) = self.preview_size;
self.terrain_preset.build_grid(width, height)
}
/// 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_controls_label: "Scene / camera / palette".to_string(),
palette_label: "Palette / color map".to_string(),
hydrology_label: "Hydrology".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(),
self.renderer_mode.label(),
self.scene.vertical_exaggeration,
),
script_preview: ScriptPreview::from_source(&self.script_source),
}
}
pub fn build_preview_grid(&self) -> Result<HeightGrid, TerrainError> {
let (width, height) = self.preview_size;
self.terrain_preset.build_grid(width, height)
self.active_height_grid()
}
pub fn render_preview(&self) -> Result<RgbImage, TerrainError> {
@@ -88,10 +320,27 @@ pub enum AppAction {
SetTreeLine(f32),
SetSnowLine(f32),
SetHaze(f32),
SetVerticalExaggeration(f32),
SetCameraPosition(Vec3),
SetCameraTarget(Vec3),
SetPreviewSize { width: u32, height: u32 },
SetCameraOrientation(Vec3),
SetCameraLens {
fov_degrees: f32,
near_range: f32,
far_range: f32,
},
SetHydrology {
river_level: f32,
lake_level: f32,
drainage: f32,
},
SetPalette(crate::scene::Palette),
SetPreviewSize {
width: u32,
height: u32,
},
SetLoadedScenePath(Option<String>),
SetScriptSource(String),
}
#[cfg(test)]
@@ -107,7 +356,8 @@ mod tests {
assert_eq!(app.terrain_preset, TerrainPreset::RadialHill);
assert_eq!(app.renderer_mode, RendererMode::TopDown);
assert_eq!(app.preview_size, (256, 256));
assert_eq!(app.loaded_scene_path, None);
assert!(app.loaded_scene_path.is_some());
assert!(app.script_source.contains("import heightmap"));
}
#[test]
@@ -165,6 +415,101 @@ 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_some());
assert!(shell.import_path.is_some());
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());
}
#[test]
fn importing_a_heightmap_updates_the_preview_grid() {
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 grid = app.build_preview_grid().expect("preview grid");
assert_eq!(grid.width(), 3);
assert_eq!(grid.height(), 2);
assert!(app.imported_grid.is_some());
}
#[test]
fn make_path_produces_a_three_keyframe_summary() {
let mut app = AppData::default();
let path = app.make_path();
assert_eq!(path.keyframes().len(), 3);
assert!(
app.path_target
.as_deref()
.unwrap_or("")
.contains("keyframes")
);
assert!(app.generated_path.is_some());
}
#[test]
fn scene_file_paths_round_trip_through_save_and_open() {
let mut app = AppData::default();
let mut path = std::env::temp_dir();
path.push(format!(
"openvistapro-app-state-scene-{}.ovp.toml",
std::process::id()
));
let _ = std::fs::remove_file(&path);
app.scene.water_level = 2.25;
app.save_scene(&path).expect("save scene");
app.scene.water_level = 0.0;
app.open_scene(&path).expect("open scene");
assert_eq!(app.scene.water_level, 2.25);
assert_eq!(
app.loaded_scene_path.as_deref(),
Some(path.to_string_lossy().as_ref())
);
std::fs::remove_file(&path).ok();
}
#[test]
fn run_script_source_executes_the_default_sample() {
let mut app = AppData::default();
let dir = std::env::temp_dir();
let report = app.run_script_from_source(&dir).expect("script run");
assert!(!report.outputs.is_empty());
assert!(app.last_script_run.is_some());
}
#[cfg(not(feature = "app"))]
#[test]
fn app_feature_is_declared_but_not_enabled_by_default() {
+109 -15
View File
@@ -5,24 +5,37 @@ pub const LOWLAND_COLOR: [u8; 3] = [70, 130, 50];
pub const HIGHLAND_COLOR: [u8; 3] = [120, 100, 80];
pub const SNOW_COLOR: [u8; 3] = [240, 240, 250];
pub fn elevation_to_rgb(elevation: f32, water: f32, tree: f32, snow: f32) -> [u8; 3] {
pub fn elevation_to_rgb(
elevation: f32,
water: f32,
tree: f32,
snow: f32,
palette: &[[u8; 3]; 4],
) -> [u8; 3] {
if elevation <= water {
WATER_COLOR
palette[0]
} else if elevation < tree {
LOWLAND_COLOR
palette[1]
} else if elevation < snow {
HIGHLAND_COLOR
palette[2]
} else {
SNOW_COLOR
palette[3]
}
}
pub fn scene_color(scene: &Scene, elevation: f32) -> [u8; 3] {
let water_level = scene.hydrology.effective_water_level(scene.water_level);
elevation_to_rgb(
elevation,
scene.water_level,
water_level,
scene.tree_line,
scene.snow_line,
&[
scene.palette.water,
scene.palette.lowland,
scene.palette.highland,
scene.palette.snow,
],
)
}
@@ -60,31 +73,112 @@ mod tests {
#[test]
fn elevation_below_water_returns_water_color() {
assert_eq!(elevation_to_rgb(-1.0, 1.0, 4.0, 7.0), WATER_COLOR);
assert_eq!(elevation_to_rgb(0.5, 1.0, 4.0, 7.0), WATER_COLOR);
assert_eq!(
elevation_to_rgb(
-1.0,
1.0,
4.0,
7.0,
&[WATER_COLOR, LOWLAND_COLOR, HIGHLAND_COLOR, SNOW_COLOR],
),
WATER_COLOR
);
assert_eq!(
elevation_to_rgb(
0.5,
1.0,
4.0,
7.0,
&[WATER_COLOR, LOWLAND_COLOR, HIGHLAND_COLOR, SNOW_COLOR],
),
WATER_COLOR
);
}
#[test]
fn elevation_at_water_returns_water_color() {
assert_eq!(elevation_to_rgb(1.0, 1.0, 4.0, 7.0), WATER_COLOR);
assert_eq!(
elevation_to_rgb(
1.0,
1.0,
4.0,
7.0,
&[WATER_COLOR, LOWLAND_COLOR, HIGHLAND_COLOR, SNOW_COLOR]
),
WATER_COLOR
);
}
#[test]
fn elevation_between_water_and_tree_returns_lowland() {
assert_eq!(elevation_to_rgb(2.0, 1.0, 4.0, 7.0), LOWLAND_COLOR);
assert_eq!(elevation_to_rgb(3.9, 1.0, 4.0, 7.0), LOWLAND_COLOR);
assert_eq!(
elevation_to_rgb(
2.0,
1.0,
4.0,
7.0,
&[WATER_COLOR, LOWLAND_COLOR, HIGHLAND_COLOR, SNOW_COLOR]
),
LOWLAND_COLOR
);
assert_eq!(
elevation_to_rgb(
3.9,
1.0,
4.0,
7.0,
&[WATER_COLOR, LOWLAND_COLOR, HIGHLAND_COLOR, SNOW_COLOR]
),
LOWLAND_COLOR
);
}
#[test]
fn elevation_between_tree_and_snow_returns_highland() {
assert_eq!(elevation_to_rgb(4.5, 1.0, 4.0, 7.0), HIGHLAND_COLOR);
assert_eq!(elevation_to_rgb(6.9, 1.0, 4.0, 7.0), HIGHLAND_COLOR);
assert_eq!(
elevation_to_rgb(
4.5,
1.0,
4.0,
7.0,
&[WATER_COLOR, LOWLAND_COLOR, HIGHLAND_COLOR, SNOW_COLOR]
),
HIGHLAND_COLOR
);
assert_eq!(
elevation_to_rgb(
6.9,
1.0,
4.0,
7.0,
&[WATER_COLOR, LOWLAND_COLOR, HIGHLAND_COLOR, SNOW_COLOR]
),
HIGHLAND_COLOR
);
}
#[test]
fn elevation_above_snow_returns_snow() {
assert_eq!(elevation_to_rgb(7.5, 1.0, 4.0, 7.0), SNOW_COLOR);
assert_eq!(elevation_to_rgb(100.0, 1.0, 4.0, 7.0), SNOW_COLOR);
assert_eq!(
elevation_to_rgb(
7.5,
1.0,
4.0,
7.0,
&[WATER_COLOR, LOWLAND_COLOR, HIGHLAND_COLOR, SNOW_COLOR]
),
SNOW_COLOR
);
assert_eq!(
elevation_to_rgb(
100.0,
1.0,
4.0,
7.0,
&[WATER_COLOR, LOWLAND_COLOR, HIGHLAND_COLOR, SNOW_COLOR]
),
SNOW_COLOR
);
}
#[test]
+1
View File
@@ -11,4 +11,5 @@ pub mod scene_file;
pub mod script;
pub mod script_exec;
pub mod terrain;
pub mod terrain_gen;
pub mod ui_shell;
+42 -1
View File
@@ -28,7 +28,7 @@
//! I/O, randomness, or global state, so the same path and time always yield a
//! bit-identical [`Camera`].
use crate::scene::{Camera, Vec3};
use crate::scene::{Camera, Scene, Vec3};
/// A single camera pose pinned to a point in time on a [`CameraPath`].
#[derive(Debug, Clone, Copy, PartialEq)]
@@ -161,10 +161,50 @@ impl CameraPath {
position: catmull_rom_vec3(p0.position, p1.position, p2.position, p3.position, u),
target: catmull_rom_vec3(p0.target, p1.target, p2.target, p3.target, u),
fov_degrees: lerp(p1.fov_degrees, p2.fov_degrees, u),
..p1
}
}
}
/// Build a small orbit-style demo path around the current scene target.
pub fn build_demo_path(scene: &Scene) -> CameraPath {
let focus = scene.camera.target;
let eye = scene.camera.position;
let offset = Vec3::new(eye.x - focus.x, eye.y - focus.y, eye.z - focus.z);
let radius = (offset.x * offset.x + offset.z * offset.z).sqrt().max(20.0);
let height = offset.y.abs().max(20.0);
let base_y = focus.y + height;
let keyframes = vec![
CameraKeyframe::new(
0.0,
Camera {
position: Vec3::new(focus.x + radius * 0.8, base_y, focus.z - radius * 0.2),
target: focus,
..scene.camera
},
),
CameraKeyframe::new(
3.0,
Camera {
position: Vec3::new(focus.x, base_y + 8.0, focus.z + radius * 0.9),
target: focus,
..scene.camera
},
),
CameraKeyframe::new(
6.0,
Camera {
position: Vec3::new(focus.x - radius * 0.8, base_y, focus.z - radius * 0.2),
target: focus,
..scene.camera
},
),
];
CameraPath::try_new(keyframes).expect("demo path must be valid")
}
/// Linear interpolation from `a` to `b` by `u` in `[0, 1]`.
fn lerp(a: f32, b: f32, u: f32) -> f32 {
a + (b - a) * u
@@ -204,6 +244,7 @@ mod tests {
position: Vec3::new(pos[0], pos[1], pos[2]),
target: Vec3::new(target[0], target[1], target[2]),
fov_degrees: fov,
..Camera::default()
},
)
}
+60 -7
View File
@@ -10,9 +10,10 @@ pub fn render_top_down(grid: &HeightGrid, scene: &Scene) -> RgbImage {
let w = grid.width();
let h = grid.height();
let mut img = RgbImage::new(w, h);
let vertical_exaggeration = scene.vertical_exaggeration.max(0.0);
for y in 0..h {
for x in 0..w {
let elevation = grid.sample(x, y).unwrap_or(0.0);
let elevation = grid.sample(x, y).unwrap_or(0.0) * vertical_exaggeration;
let color = scene_color(scene, elevation);
img.put_pixel(x, y, Rgb(color));
}
@@ -83,6 +84,48 @@ fn v_normalize(a: Vec3) -> Vec3 {
v_scale(a, 1.0 / len)
}
fn rotate_around_axis(v: Vec3, axis: Vec3, degrees: f32) -> Vec3 {
let radians = degrees.to_radians();
let axis = v_normalize(axis);
let cos = radians.cos();
let sin = radians.sin();
let dot = v_dot(axis, v);
let cross = v_cross(axis, v);
Vec3::new(
v.x * cos + cross.x * sin + axis.x * dot * (1.0 - cos),
v.y * cos + cross.y * sin + axis.y * dot * (1.0 - cos),
v.z * cos + cross.z * sin + axis.z * dot * (1.0 - cos),
)
}
fn apply_camera_orientation(camera: &Camera) -> Vec3 {
let world_up = Vec3::new(0.0, 1.0, 0.0);
let base_forward = v_normalize(v_sub(camera.target, camera.position));
let heading = camera.orientation.x;
let pitch = camera.orientation.y;
let bank = camera.orientation.z;
let mut forward = rotate_around_axis(base_forward, world_up, heading);
let mut right = v_normalize(v_cross(forward, world_up));
if right.x.is_finite() && right.y.is_finite() && right.z.is_finite() {
forward = rotate_around_axis(forward, right, pitch);
right = v_normalize(v_cross(forward, world_up));
if right.x.is_finite() && right.y.is_finite() && right.z.is_finite() {
let up = rotate_around_axis(world_up, forward, bank);
right = v_normalize(v_cross(forward, up));
if right.x.is_finite() && right.y.is_finite() && right.z.is_finite() {
forward = v_normalize(forward);
}
}
}
forward
}
fn terrain_height(scene: &Scene, raw_height: f32) -> f32 {
raw_height * scene.vertical_exaggeration.max(0.0)
}
/// Bilinear height lookup. Returns `None` if the (x, z) sample falls outside
/// the grid's interior cell range [0, width-1] × [0, height-1].
fn sample_height_bilinear(grid: &HeightGrid, x: f32, z: f32) -> Option<f32> {
@@ -117,10 +160,14 @@ pub fn demo_camera_for(grid: &HeightGrid) -> Camera {
let cz = (h - 1.0) * 0.5;
let cam_y = peak * 1.5 + h * 0.5;
let cam_z = -(h * 0.6);
let far_range = (w + h) * 2.0 + cam_y * 2.0;
Camera {
position: Vec3::new(cx, cam_y, cam_z),
target: Vec3::new(cx, peak * 0.3, cz),
orientation: Vec3::ZERO,
fov_degrees: 55.0,
near_range: 1.0,
far_range,
}
}
@@ -134,10 +181,13 @@ pub fn render_perspective(grid: &HeightGrid, scene: &Scene, width: u32, height:
let mut img = RgbImage::new(width.max(1), height.max(1));
let cam = &scene.camera;
let forward = v_normalize(v_sub(cam.target, cam.position));
let forward = apply_camera_orientation(cam);
let world_up = Vec3::new(0.0, 1.0, 0.0);
let right = v_normalize(v_cross(forward, world_up));
let up = v_cross(right, forward);
let mut right = v_normalize(v_cross(forward, world_up));
if !right.x.is_finite() || !right.y.is_finite() || !right.z.is_finite() {
right = Vec3::new(1.0, 0.0, 0.0);
}
let up = v_normalize(v_cross(right, forward));
let fov_rad = cam.fov_degrees.to_radians();
let tan_half = (fov_rad * 0.5).tan();
@@ -145,7 +195,7 @@ pub fn render_perspective(grid: &HeightGrid, scene: &Scene, width: u32, height:
let grid_w = grid.width() as f32;
let grid_h = grid.height() as f32;
let max_dist = (grid_w + grid_h) * 1.5 + cam.position.y.abs() * 2.0;
let max_dist = cam.far_range.max(cam.near_range + 0.1);
let step = 0.5_f32;
let haze_strength = scene.haze.clamp(0.0, 1.0);
@@ -163,14 +213,16 @@ pub fn render_perspective(grid: &HeightGrid, scene: &Scene, width: u32, height:
));
let sky = sky_color(dir.y);
let mut t = 0.0_f32;
let mut t = cam.near_range.max(0.0);
let mut hit_color: Option<[u8; 3]> = None;
while t < max_dist {
let p = v_add(cam.position, v_scale(dir, t));
if let Some(terrain_h) = sample_height_bilinear(grid, p.x, p.z) {
let terrain_h = terrain_height(scene, terrain_h);
if p.y <= terrain_h {
let band = scene_color(scene, terrain_h);
let fade = (t / max_dist).clamp(0.0, 1.0) * haze_strength;
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));
break;
}
@@ -178,6 +230,7 @@ pub fn render_perspective(grid: &HeightGrid, scene: &Scene, width: u32, height:
t += step;
}
let _ = (grid_w, grid_h);
img.put_pixel(px, py, Rgb(hit_color.unwrap_or(sky)));
}
}
+60
View File
@@ -20,10 +20,15 @@ impl Vec3 {
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Camera {
pub position: Vec3,
pub target: Vec3,
/// Heading, pitch, and bank in degrees.
pub orientation: Vec3,
pub fov_degrees: f32,
pub near_range: f32,
pub far_range: f32,
}
impl Default for Camera {
@@ -31,12 +36,16 @@ impl Default for Camera {
Camera {
position: Vec3::new(0.0, 50.0, 50.0),
target: Vec3::ZERO,
orientation: Vec3::ZERO,
fov_degrees: 60.0,
near_range: 1.0,
far_range: 500.0,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Light {
pub direction: Vec3,
pub intensity: f32,
@@ -52,6 +61,51 @@ impl Default for Light {
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Palette {
pub water: [u8; 3],
pub lowland: [u8; 3],
pub highland: [u8; 3],
pub snow: [u8; 3],
}
impl Default for Palette {
fn default() -> Self {
Self {
water: [30, 70, 130],
lowland: [70, 130, 50],
highland: [120, 100, 80],
snow: [240, 240, 250],
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Hydrology {
pub river_level: f32,
pub lake_level: f32,
pub drainage: f32,
}
impl Hydrology {
pub fn effective_water_level(self, base_water_level: f32) -> f32 {
(base_water_level.max(self.river_level).max(self.lake_level) - self.drainage).max(0.0)
}
}
impl Default for Hydrology {
fn default() -> Self {
Self {
river_level: 0.5,
lake_level: 0.75,
drainage: 0.0,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Scene {
pub camera: Camera,
pub light: Light,
@@ -59,6 +113,9 @@ pub struct Scene {
pub tree_line: f32,
pub snow_line: f32,
pub haze: f32,
pub vertical_exaggeration: f32,
pub hydrology: Hydrology,
pub palette: Palette,
}
impl Default for Scene {
@@ -70,6 +127,9 @@ impl Default for Scene {
tree_line: 4.0,
snow_line: 7.0,
haze: 0.2,
vertical_exaggeration: 1.0,
hydrology: Hydrology::default(),
palette: Palette::default(),
}
}
}
+24 -9
View File
@@ -107,6 +107,11 @@ pub fn from_toml_str(text: &str) -> Result<Scene, SceneFileError> {
/// Write `scene` to `path` as an OpenVistaPro `.ovp.toml` scene file.
pub fn save_to_path(scene: &Scene, path: &Path) -> Result<(), SceneFileError> {
let text = to_toml_string(scene)?;
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
fs::create_dir_all(parent)?;
}
}
fs::write(path, text)?;
Ok(())
}
@@ -120,14 +125,17 @@ pub fn load_from_path(path: &Path) -> Result<Scene, SceneFileError> {
#[cfg(test)]
mod tests {
use super::*;
use crate::scene::{Camera, Light, Scene, Vec3};
use crate::scene::{Camera, Hydrology, Light, Palette, Scene, Vec3};
fn custom_scene() -> Scene {
Scene {
camera: Camera {
position: Vec3::new(1.5, 2.5, 3.5),
target: Vec3::new(-1.0, 0.0, 4.0),
orientation: Vec3::new(5.0, -10.0, 2.5),
fov_degrees: 42.0,
near_range: 0.5,
far_range: 250.0,
},
light: Light {
direction: Vec3::new(0.0, -1.0, 0.0),
@@ -137,6 +145,18 @@ mod tests {
tree_line: 3.0,
snow_line: 8.0,
haze: 0.4,
vertical_exaggeration: 1.35,
hydrology: Hydrology {
river_level: 0.8,
lake_level: 1.1,
drainage: 0.2,
},
palette: Palette {
water: [12, 34, 56],
lowland: [78, 90, 12],
highland: [123, 111, 99],
snow: [250, 249, 248],
},
}
}
@@ -193,7 +213,7 @@ mod tests {
#[test]
fn load_rejects_unknown_schema() {
let bad = format!(
"schema = \"some.other.format\"\nversion = {SCENE_VERSION}\n\n[scene]\nwater_level = 1.0\ntree_line = 4.0\nsnow_line = 7.0\nhaze = 0.2\n\n[scene.camera]\nfov_degrees = 60.0\n\n[scene.camera.position]\nx = 0.0\ny = 50.0\nz = 50.0\n\n[scene.camera.target]\nx = 0.0\ny = 0.0\nz = 0.0\n\n[scene.light]\nintensity = 1.0\n\n[scene.light.direction]\nx = -0.5\ny = -1.0\nz = -0.3\n"
"schema = \"some.other.format\"\nversion = {SCENE_VERSION}\n\n[scene]\nwater_level = 1.0\ntree_line = 4.0\nsnow_line = 7.0\nhaze = 0.2\nvertical_exaggeration = 1.0\n\n[scene.camera]\nfov_degrees = 60.0\nnear_range = 1.0\nfar_range = 500.0\n\n[scene.camera.position]\nx = 0.0\ny = 50.0\nz = 50.0\n\n[scene.camera.target]\nx = 0.0\ny = 0.0\nz = 0.0\n\n[scene.camera.orientation]\nx = 0.0\ny = 0.0\nz = 0.0\n\n[scene.light]\nintensity = 1.0\n\n[scene.light.direction]\nx = -0.5\ny = -1.0\nz = -0.3\n\n[scene.hydrology]\nriver_level = 0.5\nlake_level = 0.75\ndrainage = 0.0\n\n[scene.palette]\nwater = [30, 70, 130]\nlowland = [70, 130, 50]\nhighland = [120, 100, 80]\nsnow = [240, 240, 250]\n"
);
let err = from_toml_str(&bad).expect_err("unknown schema must be rejected");
assert!(
@@ -206,7 +226,7 @@ mod tests {
fn load_rejects_unsupported_version() {
let future_version = SCENE_VERSION + 99;
let bad = format!(
"schema = \"{SCENE_SCHEMA}\"\nversion = {future_version}\n\n[scene]\nwater_level = 1.0\ntree_line = 4.0\nsnow_line = 7.0\nhaze = 0.2\n\n[scene.camera]\nfov_degrees = 60.0\n\n[scene.camera.position]\nx = 0.0\ny = 50.0\nz = 50.0\n\n[scene.camera.target]\nx = 0.0\ny = 0.0\nz = 0.0\n\n[scene.light]\nintensity = 1.0\n\n[scene.light.direction]\nx = -0.5\ny = -1.0\nz = -0.3\n"
"schema = \"{SCENE_SCHEMA}\"\nversion = {future_version}\n\n[scene]\nwater_level = 1.0\ntree_line = 4.0\nsnow_line = 7.0\nhaze = 0.2\nvertical_exaggeration = 1.0\n\n[scene.camera]\nfov_degrees = 60.0\nnear_range = 1.0\nfar_range = 500.0\n\n[scene.camera.position]\nx = 0.0\ny = 50.0\nz = 50.0\n\n[scene.camera.target]\nx = 0.0\ny = 0.0\nz = 0.0\n\n[scene.camera.orientation]\nx = 0.0\ny = 0.0\nz = 0.0\n\n[scene.light]\nintensity = 1.0\n\n[scene.light.direction]\nx = -0.5\ny = -1.0\nz = -0.3\n\n[scene.hydrology]\nriver_level = 0.5\nlake_level = 0.75\ndrainage = 0.0\n\n[scene.palette]\nwater = [30, 70, 130]\nlowland = [70, 130, 50]\nhighland = [120, 100, 80]\nsnow = [240, 240, 250]\n"
);
let err = from_toml_str(&bad).expect_err("unsupported version must be rejected");
assert!(
@@ -217,12 +237,7 @@ mod tests {
#[test]
fn load_returns_io_error_for_missing_file() {
let mut path = std::env::temp_dir();
path.push(format!(
"openvistapro-scenefile-missing-{}.ovp.toml",
std::process::id()
));
let _ = std::fs::remove_file(&path);
let path = temp_path("missing");
let err = load_from_path(&path).expect_err("missing file should error");
assert!(matches!(err, SceneFileError::Io(_)));
}
+26 -3
View File
@@ -21,6 +21,7 @@ use std::path::{Path, PathBuf};
use image::ImageError;
use crate::import::import_ovp_text;
use crate::render::render_top_down_to_path;
use crate::scene::Scene;
use crate::script::{Command, ParseError, PresetName, Script, parse_script};
@@ -50,6 +51,8 @@ pub enum ScriptError {
Parse(ParseError),
/// Building a terrain grid failed.
Terrain(TerrainError),
/// Importing an open-format terrain source failed.
Import(crate::import::ImportError),
/// Decoding a heightmap or writing a render failed.
Image(ImageError),
/// A `render output` command ran before any terrain was established.
@@ -62,6 +65,7 @@ impl std::fmt::Display for ScriptError {
ScriptError::Io(e) => write!(f, "script I/O error: {e}"),
ScriptError::Parse(e) => write!(f, "script parse error: {e}"),
ScriptError::Terrain(e) => write!(f, "script terrain error: {e}"),
ScriptError::Import(e) => write!(f, "script import error: {e}"),
ScriptError::Image(e) => write!(f, "script image error: {e}"),
ScriptError::RenderWithoutTerrain => write!(
f,
@@ -85,6 +89,12 @@ impl From<TerrainError> for ScriptError {
}
}
impl From<crate::import::ImportError> for ScriptError {
fn from(e: crate::import::ImportError) -> Self {
ScriptError::Import(e)
}
}
impl From<ImageError> for ScriptError {
fn from(e: ImageError) -> Self {
ScriptError::Image(e)
@@ -102,6 +112,12 @@ pub fn run_script_file(path: &Path) -> Result<ExecReport, ScriptError> {
run_script(&script, base_dir)
}
/// Parse and execute script source text.
pub fn run_script_source(source: &str, base_dir: &Path) -> Result<ExecReport, ScriptError> {
let script = parse_script(source)?;
run_script(&script, base_dir)
}
/// Execute an already-parsed [`Script`], resolving relative paths against
/// `base_dir`.
pub fn run_script(script: &Script, base_dir: &Path) -> Result<ExecReport, ScriptError> {
@@ -146,10 +162,17 @@ pub fn run_script(script: &Script, base_dir: &Path) -> Result<ExecReport, Script
Ok(report)
}
/// Load a grayscale PNG as a [`HeightGrid`], mapping pixel luma 0..=255 onto
/// elevations 0.0..=[`HEIGHTMAP_PEAK_HEIGHT`].
/// Load an imported heightmap, accepting either the project-owned `ovp-text`
/// fixture format or a grayscale image file.
fn load_heightmap(path: &Path) -> Result<HeightGrid, ScriptError> {
let image = image::open(path)?.to_luma8();
let bytes = std::fs::read(path).map_err(ScriptError::Io)?;
if let Ok(source) = std::str::from_utf8(&bytes) {
if let Ok(imported) = import_ovp_text(source) {
return Ok(imported.into_grid());
}
}
let image = image::load_from_memory(&bytes)?.to_luma8();
let width = image.width();
let height = image.height();
let samples = image
+1 -1
View File
@@ -24,7 +24,7 @@ impl fmt::Display for TerrainError {
impl std::error::Error for TerrainError {}
#[derive(Debug, Clone)]
#[derive(Debug, Clone, PartialEq)]
pub struct HeightGrid {
width: u32,
height: u32,
+188
View File
@@ -0,0 +1,188 @@
use crate::terrain::{HeightGrid, TerrainError};
const OCTAVE_COUNT: usize = 4;
const BASE_FREQUENCY: f32 = 2.0;
const LACUNARITY: f32 = 2.0;
const GAIN: f32 = 0.5;
const LATTICE_SEED_STEP: u64 = 0x9E37_79B9_7F4A_7C15;
const LATTICE_X_MUL: u64 = 0x9E37_79B9_7F4A_7C15;
const LATTICE_Y_MUL: u64 = 0xC2B2_AE3D_27D4_EB4F;
const FINALIZER_MUL1: u64 = 0xBF58_476D_1CE4_E5B9;
const FINALIZER_MUL2: u64 = 0x94D0_49BB_1331_11EB;
/// Describes a terrain generation request.
///
/// Specs are immutable once constructed; identical specs always yield
/// identical [`HeightGrid`]s from a given [`TerrainGenerator`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TerrainGenerationSpec {
seed: u64,
width: u32,
height: u32,
}
impl TerrainGenerationSpec {
/// Creates a spec, rejecting zero-sized grids with [`TerrainError::ZeroDimension`].
pub fn new(seed: u64, width: u32, height: u32) -> Result<Self, TerrainError> {
if width == 0 || height == 0 {
return Err(TerrainError::ZeroDimension);
}
Ok(Self {
seed,
width,
height,
})
}
pub fn seed(&self) -> u64 {
self.seed
}
pub fn width(&self) -> u32 {
self.width
}
pub fn height(&self) -> u32 {
self.height
}
}
/// Produces a [`HeightGrid`] from a [`TerrainGenerationSpec`].
pub trait TerrainGenerator {
fn generate(&self, spec: &TerrainGenerationSpec) -> Result<HeightGrid, TerrainError>;
}
/// A deterministic, in-memory reference generator.
///
/// Heights are derived from a small clean-room seeded value-noise fBm stack so
/// identical specs always produce identical grids. The output stays in
/// `[0.0, 1.0]`, which keeps later renderer and palette integration simple.
#[derive(Debug, Clone, Copy, Default)]
pub struct DeterministicTerrainGenerator;
impl DeterministicTerrainGenerator {
pub fn new() -> Self {
Self
}
}
#[inline]
fn fade(t: f32) -> f32 {
t * t * (3.0 - 2.0 * t)
}
#[inline]
fn normalized_coord(index: u32, size: u32) -> f32 {
if size <= 1 {
0.0
} else {
index as f32 / (size - 1) as f32
}
}
/// Hashes seed and lattice coordinates into a height in `[0.0, 1.0)`.
#[inline]
fn lattice_value(seed: u64, x: i64, y: i64) -> f32 {
let mut h =
seed ^ (x as u64).wrapping_mul(LATTICE_X_MUL) ^ (y as u64).wrapping_mul(LATTICE_Y_MUL);
h ^= h >> 30;
h = h.wrapping_mul(FINALIZER_MUL1);
h ^= h >> 27;
h = h.wrapping_mul(FINALIZER_MUL2);
h ^= h >> 31;
// Take the top 24 bits for an exact f32 fraction in [0, 1).
(h >> 40) as f32 / (1u64 << 24) as f32
}
#[inline]
fn value_noise(seed: u64, x: f32, y: f32) -> f32 {
let x0 = x.floor() as i64;
let y0 = y.floor() as i64;
let x1 = x0 + 1;
let y1 = y0 + 1;
let tx = x - x0 as f32;
let ty = y - y0 as f32;
let sx = fade(tx);
let sy = fade(ty);
let n00 = lattice_value(seed, x0, y0);
let n10 = lattice_value(seed, x1, y0);
let n01 = lattice_value(seed, x0, y1);
let n11 = lattice_value(seed, x1, y1);
let ix0 = n00 * (1.0 - sx) + n10 * sx;
let ix1 = n01 * (1.0 - sx) + n11 * sx;
ix0 * (1.0 - sy) + ix1 * sy
}
#[inline]
fn fbm_height(seed: u64, x: f32, y: f32) -> f32 {
let mut total = 0.0;
let mut amplitude = 1.0;
let mut frequency = BASE_FREQUENCY;
let mut amplitude_sum = 0.0;
for octave in 0..OCTAVE_COUNT {
let octave_seed = seed.wrapping_add((octave as u64).wrapping_mul(LATTICE_SEED_STEP));
total += amplitude * value_noise(octave_seed, x * frequency, y * frequency);
amplitude_sum += amplitude;
amplitude *= GAIN;
frequency *= LACUNARITY;
}
total / amplitude_sum
}
impl TerrainGenerator for DeterministicTerrainGenerator {
fn generate(&self, spec: &TerrainGenerationSpec) -> Result<HeightGrid, TerrainError> {
let width = spec.width();
let height = spec.height();
let mut samples = Vec::with_capacity((width as usize) * (height as usize));
for y in 0..height {
let ny = normalized_coord(y, height);
for x in 0..width {
let nx = normalized_coord(x, width);
samples.push(fbm_height(spec.seed(), nx, ny));
}
}
HeightGrid::new(width, height, samples)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_rejects_zero_dimensions() {
assert_eq!(
TerrainGenerationSpec::new(1, 0, 4).unwrap_err(),
TerrainError::ZeroDimension
);
assert_eq!(
TerrainGenerationSpec::new(1, 4, 0).unwrap_err(),
TerrainError::ZeroDimension
);
}
#[test]
fn generate_matches_spec_dimensions() {
let spec = TerrainGenerationSpec::new(99, 8, 5).unwrap();
let grid = DeterministicTerrainGenerator::new()
.generate(&spec)
.unwrap();
assert_eq!(grid.width(), 8);
assert_eq!(grid.height(), 5);
}
#[test]
fn different_seeds_diverge() {
let a = DeterministicTerrainGenerator::new()
.generate(&TerrainGenerationSpec::new(1, 4, 4).unwrap())
.unwrap();
let b = DeterministicTerrainGenerator::new()
.generate(&TerrainGenerationSpec::new(2, 4, 4).unwrap())
.unwrap();
assert_ne!(a.sample(0, 0), b.sample(0, 0));
}
}
+65
View File
@@ -0,0 +1,65 @@
//! Docs-sync guard for the terrain-generation surface.
//!
//! The terrain generator has landed in `src/terrain_gen.rs` (see
//! `tests/terrain_gen.rs`), but the docs have not been updated to describe it.
//! This test fails by design until README, the terrain-generation plan, and
//! the architecture notes mention the new surface.
use std::fs;
use std::path::Path;
/// Substrings every terrain-aware doc should mention now that the
/// terrain-generation module has landed.
const REQUIRED_TERMS: &[&str] = &[
"src/terrain_gen.rs",
"TerrainGenerationSpec",
"DeterministicTerrainGenerator",
"cargo test terrain_gen",
];
fn read_doc(relative: &str) -> String {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(relative);
fs::read_to_string(&path).unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display()))
}
/// Returns the required terrain-generation terms that `doc` fails to mention.
fn missing_terms(doc: &str) -> Vec<&'static str> {
let mut missing: Vec<&'static str> = REQUIRED_TERMS
.iter()
.copied()
.filter(|term| !doc.contains(term))
.collect();
// A determinism/seed note: the docs should say generation is seeded and
// reproducible, not just reuse the word "deterministic" elsewhere.
let lower = doc.to_lowercase();
if !(lower.contains("seed") && lower.contains("determin")) {
missing.push("a determinism/seed note");
}
missing
}
fn assert_doc_mentions_terrain_gen(relative: &str) {
let doc = read_doc(relative);
let missing = missing_terms(&doc);
assert!(
missing.is_empty(),
"{relative} does not mention the landed terrain-generation surface: {missing:?}"
);
}
#[test]
fn readme_mentions_terrain_generation_surface() {
assert_doc_mentions_terrain_gen("README.md");
}
#[test]
fn terrain_generation_plan_mentions_terrain_generation_surface() {
assert_doc_mentions_terrain_gen("docs/plans/terrain-generation.md");
}
#[test]
fn architecture_notes_mention_terrain_generation_surface() {
assert_doc_mentions_terrain_gen("docs/knowledgebase/architecture-notes.md");
}
+78
View File
@@ -0,0 +1,78 @@
use openvistapro::terrain::{HeightGrid, TerrainError};
use openvistapro::terrain_gen::{
DeterministicTerrainGenerator, TerrainGenerationSpec, TerrainGenerator,
};
fn assert_same_grid(a: &HeightGrid, b: &HeightGrid) {
assert_eq!(a.width(), b.width());
assert_eq!(a.height(), b.height());
for y in 0..a.height() {
for x in 0..a.width() {
assert_eq!(a.sample(x, y), b.sample(x, y), "mismatch at ({x}, {y})");
}
}
}
#[test]
fn terrain_gen_deterministic_generator_returns_requested_dimensions() {
let spec = TerrainGenerationSpec::new(0xfeed_beef, 4, 3).expect("valid spec");
let generator = DeterministicTerrainGenerator::new();
let grid = generator.generate(&spec).expect("generation succeeds");
assert_eq!(grid.width(), 4);
assert_eq!(grid.height(), 3);
}
#[test]
fn terrain_gen_deterministic_generator_is_stable_for_same_seed_and_size() {
let spec = TerrainGenerationSpec::new(42, 6, 5).expect("valid spec");
let generator = DeterministicTerrainGenerator::new();
let first = generator
.generate(&spec)
.expect("first generation succeeds");
let second = generator
.generate(&spec)
.expect("second generation succeeds");
assert_same_grid(&first, &second);
}
#[test]
fn terrain_gen_deterministic_generator_rejects_zero_dimensions() {
let err = TerrainGenerationSpec::new(7, 0, 4).unwrap_err();
assert_eq!(err, TerrainError::ZeroDimension);
let err = TerrainGenerationSpec::new(7, 4, 0).unwrap_err();
assert_eq!(err, TerrainError::ZeroDimension);
}
#[test]
fn terrain_gen_deterministic_generator_produces_value_noise_fbm_sample() {
let spec = TerrainGenerationSpec::new(1337, 4, 4).expect("valid spec");
let generator = DeterministicTerrainGenerator::new();
let grid = generator.generate(&spec).expect("generation succeeds");
let expected = [
[0.706_783_f32, 0.390_681, 0.571_295, 0.454_375],
[0.667_478, 0.549_327, 0.476_506, 0.335_594],
[0.603_516, 0.451_503, 0.516_397, 0.357_081],
[0.308_838, 0.295_558, 0.570_510, 0.666_683],
];
for (y, row) in expected.iter().enumerate() {
for (x, expected_sample) in row.iter().enumerate() {
let actual = grid.sample(x as u32, y as u32).expect("sample in bounds");
assert!(
(actual - expected_sample).abs() < 1e-6,
"mismatch at ({x}, {y}): expected {expected_sample}, got {actual}"
);
}
}
let (min, max) = grid.min_max().expect("non-empty grid has min/max");
assert!(min >= 0.0);
assert!(max <= 1.0);
}