feat: wire shell placeholders to backend actions #12
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -721,6 +721,21 @@ Expected: generated script parses successfully.
|
||||
|
||||
## Milestone G: WGPU/egui application after CLI stability
|
||||
|
||||
**Status:** Tasks G1–G4 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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
@@ -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
@@ -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
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user