feat: wire shell placeholders to backend actions #12
@@ -4,8 +4,8 @@ This is a normalized reconciliation of the VistaPro manuals, MakePath guide, scr
|
|||||||
|
|
||||||
Status counts by normalized feature family:
|
Status counts by normalized feature family:
|
||||||
- Implemented: 7
|
- Implemented: 7
|
||||||
- Partial: 7
|
- Partial: 9
|
||||||
- Planned: 6
|
- Planned: 4
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- “Implemented” means the current codebase has a working, tested slice for that family.
|
- “Implemented” means the current codebase has a working, tested slice for that family.
|
||||||
@@ -22,19 +22,19 @@ Notes:
|
|||||||
| GeoTIFF terrain import | Modern open terrain source, not a legacy VistaPro format. | Implemented | `src/import/geotiff.rs` behind `import-geotiff`, tests in that module. | Deliberately narrow subset: tiny synthetic single-band raster support only. |
|
| GeoTIFF terrain import | Modern open terrain source, not a legacy VistaPro format. | Implemented | `src/import/geotiff.rs` behind `import-geotiff`, tests in that module. | Deliberately narrow subset: tiny synthetic single-band raster support only. |
|
||||||
| Fractal / synthetic terrain generation | VistaPro overview calls out fractal landscapes and generated terrain. | Partial | `src/terrain.rs` (`plane`, `radial_hill`), `src/app_state.rs` presets. | Current terrain generation is only deterministic fixtures, not a true fractal/noise terrain engine. |
|
| Fractal / synthetic terrain generation | VistaPro overview calls out fractal landscapes and generated terrain. | Partial | `src/terrain.rs` (`plane`, `radial_hill`), `src/app_state.rs` presets. | Current terrain generation is only deterministic fixtures, not a true fractal/noise terrain engine. |
|
||||||
| Camera and target placement | VistaPro 2 / 3 manuals: “Setting Camera and Target”; screenshot workflow uses camera/target gadgets. | Implemented | `src/scene.rs` (`Camera`), `src/app.rs` (camera position/target controls), `src/app_state.rs`. | Only the core position/target slice exists; there is no map-click placement UI yet. |
|
| Camera and target placement | VistaPro 2 / 3 manuals: “Setting Camera and Target”; screenshot workflow uses camera/target gadgets. | Implemented | `src/scene.rs` (`Camera`), `src/app.rs` (camera position/target controls), `src/app_state.rs`. | Only the core position/target slice exists; there is no map-click placement UI yet. |
|
||||||
| Lens / range / orientation controls | VistaPro manuals describe lens/range, bank, heading, and pitch controls. | Partial | `src/scene.rs` (`Camera.fov_degrees`), `src/render.rs` perspective renderer. | No explicit bank/heading/pitch model or legacy lens/range UI yet. |
|
| Lens / range / orientation controls | VistaPro manuals describe lens/range, bank, heading, and pitch controls. | Partial | `src/scene.rs` (`Camera.orientation`, `Camera.near_range`, `Camera.far_range`), `src/render.rs` (orientation-aware CPU perspective raymarch), `src/app.rs` and `src/app_state.rs` (dockable controls). | The shell now exposes heading/pitch/bank plus lens and range sliders, but the camera model is still a simplified modern interpretation rather than a 1:1 legacy clone. |
|
||||||
| Water / sea level, tree line, snow line, haze | Manuals repeatedly mention tree line, snow line, water level, haze, and atmospheric tuning. | Implemented | `src/scene.rs`, `src/app.rs` sliders, `src/colormap.rs`, `src/render.rs`. | Rivers/lakes are still missing, but the core elevation-band controls are present. |
|
| Water / sea level, tree line, snow line, haze | Manuals repeatedly mention tree line, snow line, water level, haze, and atmospheric tuning. | Implemented | `src/scene.rs`, `src/app.rs` sliders, `src/colormap.rs`, `src/render.rs`. | Rivers/lakes are still missing, but the core elevation-band controls are present. |
|
||||||
| Rivers and lakes | VistaPro manuals explicitly mention rivers and lakes as adjustable landscape features. | Planned | Not yet represented in `Scene` or renderer code. | Add hydrology controls/data model before claiming this family. |
|
| Rivers and lakes | VistaPro manuals explicitly mention rivers and lakes as adjustable landscape features. | Partial | `src/scene.rs` (`Hydrology`), `src/app.rs` and `src/app_state.rs` (hydrology sliders), `src/colormap.rs` (water mask uses the hydrology overlay). | The shell exposes river, lake, and drainage controls, but it does not yet simulate flowing water or routed drainage. |
|
||||||
| Light direction and custom lighting | Manuals discuss sunlight placement and lighting experiments. | Partial | `src/scene.rs` (`Light`), `src/render.rs`, `src/app.rs` (light state exists in the scene model even if UI is minimal). | The current model is much simpler than VistaPro’s lighting workflow and lacks richer light controls. |
|
| Light direction and custom lighting | Manuals discuss sunlight placement and lighting experiments. | Partial | `src/scene.rs` (`Light`), `src/render.rs`, `src/app.rs` (light state exists in the scene model even if UI is minimal). | The current model is much simpler than VistaPro’s lighting workflow and lacks richer light controls. |
|
||||||
| Vertical exaggeration | VistaPro manuals describe vertical scaling / scene exaggeration controls. | Planned | No dedicated field or control in the current scene model. | Add an explicit vertical-scale parameter and render integration. |
|
| Vertical exaggeration | VistaPro manuals describe vertical scaling / scene exaggeration controls. | Partial | `src/scene.rs` (`Scene.vertical_exaggeration`), `src/app.rs` (slider), `src/app_state.rs`, `src/render.rs` (top-down and perspective render scaling). | The shell now scales the preview terrain vertically, but it still uses a single global factor rather than the richer legacy exaggeration workflows. |
|
||||||
| Color maps / palettes / texture image loading | VistaPro 3 manual includes loading PCX images, adding texture, and saving/loading color maps. | Partial | `src/colormap.rs` fixed bands, `src/render.rs` uses scene thresholds. | No color-map editor, no palette import/export, and no PCX/texture loading yet. |
|
| Color maps / palettes / texture image loading | VistaPro 3 manual includes loading PCX images, adding texture, and saving/loading color maps. | Partial | `src/scene.rs` (`Palette`), `src/app.rs` (RGB sliders), `src/app_state.rs`, `src/colormap.rs` (palette-aware band lookup). | The shell now exposes an editable color map, but palette import/export and legacy texture loading remain open gaps. |
|
||||||
| Preview / final render workflow | VistaPro manuals describe rough preview rendering and full render output. | Implemented | `src/render.rs` (`render_top_down`, `render_perspective`), `src/cli.rs` (`render`), tests in `src/render.rs`. | The preview/final split is still simplified, but the core render outputs are working. |
|
| Preview / final render workflow | VistaPro manuals describe rough preview rendering and full render output. | Implemented | `src/render.rs` (`render_top_down`, `render_perspective`), `src/cli.rs` (`render`), tests in `src/render.rs`. | The preview/final split is still simplified, but the core render outputs are working. |
|
||||||
| Render quality presets / smoothing / detail tradeoffs | VistaPro manuals describe quality menus and poly/detail tradeoffs. | Planned | No dedicated quality preset system in current code. | Add explicit quality presets or a render-quality profile object. |
|
| Render quality presets / smoothing / detail tradeoffs | VistaPro manuals describe quality menus and poly/detail tradeoffs. | Planned | No dedicated quality preset system in current code. | Add explicit quality presets or a render-quality profile object. |
|
||||||
| Scene file save/load (`.ovp.toml`) | Not a VistaPro legacy format; this is the clean-room OpenVistaPro scene format. | Implemented | `src/scene_file.rs`, `src/cli.rs` (`scene export`), tests in `src/scene_file.rs`. | No gap for the project-owned scene format slice. |
|
| Scene file save/load (`.ovp.toml`) | Not a VistaPro legacy format; this is the clean-room OpenVistaPro scene format. | Implemented | `src/scene_file.rs`, `src/cli.rs` (`scene export`), tests in `src/scene_file.rs`. | No gap for the project-owned scene format slice. |
|
||||||
| Script language parser | MakePath guide and VistaPro manual describe scripts and “Run Script” workflows. | Partial | `src/script.rs` parser, tests in `src/script.rs`, `README.md` script section. | Parser exists, but script execution is intentionally deferred. |
|
| Script language parser | MakePath guide and VistaPro manual describe scripts and “Run Script” workflows. | Partial | `src/script.rs` parser, tests in `src/script.rs`, `src/app_state.rs` script preview wiring, `README.md` script section. | Parser exists, and the shell now routes script text into a runnable executor slice. |
|
||||||
| Script execution and animation frames | MakePath guide says scripts should render full animations and VistaPro can run scripts from the Script menu. | Planned | No script runner or frame-sequencing engine exists yet. | Add execution semantics once the command model is stable. |
|
| Script execution and animation frames | MakePath guide says scripts should render full animations and VistaPro can run scripts from the Script menu. | Partial | `src/script_exec.rs`, `src/app_state.rs` (`run_script_from_source`), `src/app.rs` Run script button, tests in `src/script_exec.rs`. | Executor slice is wired, but multi-frame animation sequencing is still open. |
|
||||||
| MakePath-style path generation and motion models | MakePath guide describes spline nodes, previewing a path, and vehicle models (jet, glider, dune buggy, motorcycle, helicopter, cruise missile, custom). | Planned | No path generator or motion-model layer exists yet. | This is a separate planner/animation feature, not just a script parser. |
|
| MakePath-style path generation and motion models | MakePath guide describes spline nodes, previewing a path, and vehicle models (jet, glider, dune buggy, motorcycle, helicopter, cruise missile, custom). | Partial | `src/path.rs`, `src/app_state.rs` (`make_path`), `src/app.rs` Make path button, tests in `src/path.rs`. | Demo path generation is wired, but the full MakePath motion-model matrix remains open. |
|
||||||
| Modern UI shell, menus, dialogs, and numeric gadgets | VistaPro screenshots/manuals show dense menus, dialogs, map tools, and numeric gadgets. | Partial | `src/app.rs`, `src/app_state.rs`, `src/bin/openvistapro_app.rs`, `docs/knowledgebase/ui-panel-map.md`. | Current UI is a docked egui CPU-preview shell — left scene/terrain/render controls, a right scripts/paths panel, a top project bar, and a bottom status bar around the central viewport preview. Still-planned actions (heightmap import, run script, make path, file new/open/save) are wired as disabled placeholders; legacy menus/dialogs and numeric-gadget surfaces remain planned. |
|
| Modern UI shell, menus, dialogs, and numeric gadgets | VistaPro screenshots/manuals show dense menus, dialogs, map tools, and numeric gadgets. | Partial | `src/app.rs`, `src/app_state.rs`, `src/bin/openvistapro_app.rs`, `docs/knowledgebase/ui-panel-map.md`. | Current UI is a docked egui CPU-preview shell — left scene/terrain/render controls, a right scripts/paths panel, a top project bar, a bottom status bar, and now visible camera/orientation, vertical exaggeration, palette, hydrology, and reserved legacy-dialog surfaces. Heightmap import, script run, Make Path, and file actions are still backend-driven entry points rather than full legacy dialogs. |
|
||||||
| Legacy image / landscape export formats | VistaPro manuals mention saving rendered images and landscapes in formats like IFF/IFF24/RGB and DEM/binary landscape files. | Planned | Current output is PNG plus project-owned `.ovp.toml` scenes. | Add separate compatibility/export work only after the clean internal pipeline is stable. |
|
| Legacy image / landscape export formats | VistaPro manuals mention saving rendered images and landscapes in formats like IFF/IFF24/RGB and DEM/binary landscape files. | Planned | Current output is PNG plus project-owned `.ovp.toml` scenes. | Add separate compatibility/export work only after the clean internal pipeline is stable. |
|
||||||
|
|
||||||
## Current reconciliation summary
|
## Current reconciliation summary
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ This is a normalized modern shell map derived from the VistaPro manuals, screens
|
|||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| 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. |
|
| 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. |
|
| Terrain / import | Load Landscape, Import, terrain source selection, generated terrain presets | Left dock or collapsible section | Partial | The current shell exposes project-owned terrain presets (`Plane`, `RadialHill`) and a placeholder import entry point; legacy format import UI is still absent. |
|
||||||
| Scene / camera | Camera and target gadgets, lens/range, bank/heading/pitch, water/tree/snow/haze controls | Left dock or inspector stack | Partial | Position/target and scene-band sliders exist. Lens/range, orientation axes, and richer VistaPro camera semantics are still missing. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
||||||
@@ -44,6 +44,7 @@ That layout preserves the VistaPro workflow while making room for modern discove
|
|||||||
|
|
||||||
- No legacy-style menu/dialog layer for file, export, or script workflows.
|
- No legacy-style menu/dialog layer for file, export, or script workflows.
|
||||||
- No docked status bar or live feedback line.
|
- No docked status bar or live feedback line.
|
||||||
- No UI for orientation, lens/range, or vertical exaggeration.
|
|
||||||
- No dedicated scripts/paths editor surface.
|
- No dedicated scripts/paths editor surface.
|
||||||
- No palette editor, hydrology controls, or legacy export panels.
|
- 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.
|
||||||
|
|||||||
+99
-17
@@ -1,7 +1,9 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
use image::RgbImage;
|
use image::RgbImage;
|
||||||
|
|
||||||
use crate::app_state::{AppAction, AppData, RendererMode, TerrainPreset, UiShellSnapshot};
|
use crate::app_state::{AppAction, AppData, RendererMode, TerrainPreset};
|
||||||
use crate::scene::Vec3;
|
use crate::scene::Vec3;
|
||||||
|
|
||||||
pub const WINDOW_TITLE: &str = "OpenVistaPro";
|
pub const WINDOW_TITLE: &str = "OpenVistaPro";
|
||||||
@@ -33,11 +35,30 @@ impl OpenVistaProApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl eframe::App for OpenVistaProApp {
|
impl eframe::App for OpenVistaProApp {
|
||||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||||
let mut changed = false;
|
let mut changed = false;
|
||||||
|
let mut action_note: Option<String> = None;
|
||||||
|
|
||||||
egui::SidePanel::left("scene_controls")
|
egui::SidePanel::left("scene_controls")
|
||||||
.resizable(true)
|
.resizable(true)
|
||||||
@@ -116,14 +137,24 @@ impl eframe::App for OpenVistaProApp {
|
|||||||
let import_path = self
|
let import_path = self
|
||||||
.data
|
.data
|
||||||
.import_path
|
.import_path
|
||||||
.as_deref()
|
.clone()
|
||||||
.unwrap_or("No import path selected");
|
.unwrap_or_else(Self::default_import_path);
|
||||||
ui.monospace(import_path);
|
ui.monospace(import_path.as_str());
|
||||||
ui.add_enabled(false, egui::Button::new("Import heightmap…"));
|
if ui.button("Import heightmap…").clicked() {
|
||||||
|
let path = Path::new(import_path.as_str());
|
||||||
|
match self.data.import_heightmap_from_path(path) {
|
||||||
|
Ok(()) => changed = true,
|
||||||
|
Err(error) => action_note = Some(format!("import failed: {error}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
ui.label(
|
if let Some(grid) = self.data.imported_grid.as_ref() {
|
||||||
"Legacy import surfaces remain planned; the shell only shows the entry point.",
|
ui.label(format!("Imported grid: {}×{}", grid.width(), grid.height()));
|
||||||
);
|
} else {
|
||||||
|
ui.label(
|
||||||
|
"Legacy import surfaces remain planned; the shell shows the entry point.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
ui.separator();
|
ui.separator();
|
||||||
ui.label("Script source");
|
ui.label("Script source");
|
||||||
@@ -136,9 +167,22 @@ impl eframe::App for OpenVistaProApp {
|
|||||||
)
|
)
|
||||||
.changed();
|
.changed();
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.add_enabled(false, egui::Button::new("Run script"));
|
if ui.button("Run script").clicked() {
|
||||||
ui.label("Parser-only MVP; execution stays disabled.");
|
let base_dir = Path::new(Self::default_script_base_dir());
|
||||||
|
match self.data.run_script_from_source(base_dir) {
|
||||||
|
Ok(report) => {
|
||||||
|
if !report.outputs.is_empty() {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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()));
|
||||||
|
}
|
||||||
|
|
||||||
ui.separator();
|
ui.separator();
|
||||||
ui.label("Path tools");
|
ui.label("Path tools");
|
||||||
@@ -149,11 +193,16 @@ impl eframe::App for OpenVistaProApp {
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.unwrap_or("No path target selected");
|
.unwrap_or("No path target selected");
|
||||||
ui.monospace(path_target);
|
ui.monospace(path_target);
|
||||||
ui.add_enabled(false, egui::Button::new("Make path"));
|
if ui.button("Make path").clicked() {
|
||||||
|
self.data.make_path();
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
ui.label(
|
if let Some(path) = self.data.generated_path.as_ref() {
|
||||||
"Path generation is still a planned feature; only the entry point is visible.",
|
ui.label(format!("Generated path: {}", path.summary()));
|
||||||
);
|
} else {
|
||||||
|
ui.label("Path generation is now wired to the backend demo path builder.");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if changed || self.texture.is_none() {
|
if changed || self.texture.is_none() {
|
||||||
@@ -171,9 +220,38 @@ impl eframe::App for OpenVistaProApp {
|
|||||||
None => ui.weak("No scene file loaded"),
|
None => ui.weak("No scene file loaded"),
|
||||||
};
|
};
|
||||||
ui.separator();
|
ui.separator();
|
||||||
ui.add_enabled(false, egui::Button::new("New"));
|
if ui.button("New").clicked() {
|
||||||
ui.add_enabled(false, egui::Button::new("Open…"));
|
let path = self
|
||||||
ui.add_enabled(false, egui::Button::new("Save"));
|
.data
|
||||||
|
.loaded_scene_path
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(Self::default_scene_path);
|
||||||
|
self.data.reset_scene();
|
||||||
|
self.data.loaded_scene_path = Some(path);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if ui.button("Open…").clicked() {
|
||||||
|
let path = self
|
||||||
|
.data
|
||||||
|
.loaded_scene_path
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(Self::default_scene_path);
|
||||||
|
match self.data.open_scene(Path::new(&path)) {
|
||||||
|
Ok(()) => changed = true,
|
||||||
|
Err(error) => action_note = Some(format!("open failed: {error}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ui.button("Save").clicked() {
|
||||||
|
let path = self
|
||||||
|
.data
|
||||||
|
.loaded_scene_path
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(Self::default_scene_path);
|
||||||
|
match self.data.save_scene(Path::new(&path)) {
|
||||||
|
Ok(()) => action_note = Some(format!("saved scene to {path}")),
|
||||||
|
Err(error) => action_note = Some(format!("save failed: {error}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -190,6 +268,10 @@ impl eframe::App for OpenVistaProApp {
|
|||||||
if let Some(error) = snapshot.script_preview.error.as_deref() {
|
if let Some(error) = snapshot.script_preview.error.as_deref() {
|
||||||
ui.colored_label(ui.visuals().error_fg_color, error);
|
ui.colored_label(ui.visuals().error_fg_color, error);
|
||||||
}
|
}
|
||||||
|
if let Some(note) = action_note.as_deref() {
|
||||||
|
ui.separator();
|
||||||
|
ui.label(note);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+202
-12
@@ -1,8 +1,13 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
use image::RgbImage;
|
use image::RgbImage;
|
||||||
|
|
||||||
|
use crate::path::{CameraPath, build_demo_path};
|
||||||
use crate::render::{render_perspective, render_top_down};
|
use crate::render::{render_perspective, render_top_down};
|
||||||
use crate::scene::{Scene, Vec3};
|
use crate::scene::{Scene, Vec3};
|
||||||
|
use crate::scene_file::{self, SceneFileError};
|
||||||
use crate::script::{Command, parse_script};
|
use crate::script::{Command, parse_script};
|
||||||
|
use crate::script_exec::{self, ExecReport, ScriptError};
|
||||||
use crate::terrain::{HeightGrid, TerrainError};
|
use crate::terrain::{HeightGrid, TerrainError};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -103,6 +108,10 @@ pub struct UiShellSnapshot {
|
|||||||
pub import_label: String,
|
pub import_label: String,
|
||||||
pub script_label: String,
|
pub script_label: String,
|
||||||
pub path_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 scene_file_path: Option<String>,
|
||||||
pub import_path: Option<String>,
|
pub import_path: Option<String>,
|
||||||
pub path_target: Option<String>,
|
pub path_target: Option<String>,
|
||||||
@@ -117,25 +126,40 @@ pub struct AppData {
|
|||||||
pub renderer_mode: RendererMode,
|
pub renderer_mode: RendererMode,
|
||||||
pub preview_size: (u32, u32),
|
pub preview_size: (u32, u32),
|
||||||
pub loaded_scene_path: Option<String>,
|
pub loaded_scene_path: Option<String>,
|
||||||
/// Heightmap path selected for import; `None` until import is wired up.
|
/// Heightmap path selected for import.
|
||||||
pub import_path: Option<String>,
|
pub import_path: Option<String>,
|
||||||
/// Path-tool target; `None` until path generation is wired up.
|
/// Path-tool target or summary.
|
||||||
pub path_target: Option<String>,
|
pub path_target: Option<String>,
|
||||||
/// Current script source text edited in the Scripts / paths panel.
|
/// Current script source text edited in the Scripts / paths panel.
|
||||||
pub script_source: String,
|
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 {
|
impl Default for AppData {
|
||||||
fn default() -> Self {
|
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 {
|
Self {
|
||||||
scene: Scene::default(),
|
scene: Scene::default(),
|
||||||
terrain_preset: TerrainPreset::RadialHill,
|
terrain_preset: TerrainPreset::RadialHill,
|
||||||
renderer_mode: RendererMode::TopDown,
|
renderer_mode: RendererMode::TopDown,
|
||||||
preview_size: (256, 256),
|
preview_size: (256, 256),
|
||||||
loaded_scene_path: None,
|
loaded_scene_path: Some(scene_path),
|
||||||
import_path: None,
|
import_path: Some(import_path.clone()),
|
||||||
path_target: None,
|
path_target: None,
|
||||||
script_source: String::new(),
|
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,8 +173,33 @@ impl AppData {
|
|||||||
AppAction::SetTreeLine(value) => self.scene.tree_line = value,
|
AppAction::SetTreeLine(value) => self.scene.tree_line = value,
|
||||||
AppAction::SetSnowLine(value) => self.scene.snow_line = value,
|
AppAction::SetSnowLine(value) => self.scene.snow_line = value,
|
||||||
AppAction::SetHaze(value) => self.scene.haze = value.clamp(0.0, 1.0),
|
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::SetCameraPosition(position) => self.scene.camera.position = position,
|
||||||
AppAction::SetCameraTarget(target) => self.scene.camera.target = target,
|
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 } => {
|
AppAction::SetPreviewSize { width, height } => {
|
||||||
self.preview_size = (width.max(1), height.max(1));
|
self.preview_size = (width.max(1), height.max(1));
|
||||||
}
|
}
|
||||||
@@ -159,6 +208,63 @@ impl AppData {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(path.summary());
|
||||||
|
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.
|
/// Build a pure snapshot of the UI shell state for the egui app to render.
|
||||||
pub fn ui_snapshot(&self) -> UiShellSnapshot {
|
pub fn ui_snapshot(&self) -> UiShellSnapshot {
|
||||||
let (width, height) = self.preview_size;
|
let (width, height) = self.preview_size;
|
||||||
@@ -169,21 +275,25 @@ impl AppData {
|
|||||||
import_label: "Import terrain".to_string(),
|
import_label: "Import terrain".to_string(),
|
||||||
script_label: "Scripts / paths".to_string(),
|
script_label: "Scripts / paths".to_string(),
|
||||||
path_label: "Path tools".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(),
|
scene_file_path: self.loaded_scene_path.clone(),
|
||||||
import_path: self.import_path.clone(),
|
import_path: self.import_path.clone(),
|
||||||
path_target: self.path_target.clone(),
|
path_target: self.path_target.clone(),
|
||||||
status_line: format!(
|
status_line: format!(
|
||||||
"CPU preview · {} · {} · {width}×{height}",
|
"CPU preview · {} · {} · exag {:.2} · {width}×{height}",
|
||||||
self.terrain_preset.label(),
|
self.terrain_preset.label(),
|
||||||
self.renderer_mode.label(),
|
self.renderer_mode.label(),
|
||||||
|
self.scene.vertical_exaggeration,
|
||||||
),
|
),
|
||||||
script_preview: ScriptPreview::from_source(&self.script_source),
|
script_preview: ScriptPreview::from_source(&self.script_source),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build_preview_grid(&self) -> Result<HeightGrid, TerrainError> {
|
pub fn build_preview_grid(&self) -> Result<HeightGrid, TerrainError> {
|
||||||
let (width, height) = self.preview_size;
|
self.active_height_grid()
|
||||||
self.terrain_preset.build_grid(width, height)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn render_preview(&self) -> Result<RgbImage, TerrainError> {
|
pub fn render_preview(&self) -> Result<RgbImage, TerrainError> {
|
||||||
@@ -205,9 +315,25 @@ pub enum AppAction {
|
|||||||
SetTreeLine(f32),
|
SetTreeLine(f32),
|
||||||
SetSnowLine(f32),
|
SetSnowLine(f32),
|
||||||
SetHaze(f32),
|
SetHaze(f32),
|
||||||
|
SetVerticalExaggeration(f32),
|
||||||
SetCameraPosition(Vec3),
|
SetCameraPosition(Vec3),
|
||||||
SetCameraTarget(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>),
|
SetLoadedScenePath(Option<String>),
|
||||||
SetScriptSource(String),
|
SetScriptSource(String),
|
||||||
}
|
}
|
||||||
@@ -225,7 +351,8 @@ mod tests {
|
|||||||
assert_eq!(app.terrain_preset, TerrainPreset::RadialHill);
|
assert_eq!(app.terrain_preset, TerrainPreset::RadialHill);
|
||||||
assert_eq!(app.renderer_mode, RendererMode::TopDown);
|
assert_eq!(app.renderer_mode, RendererMode::TopDown);
|
||||||
assert_eq!(app.preview_size, (256, 256));
|
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]
|
#[test]
|
||||||
@@ -294,8 +421,8 @@ mod tests {
|
|||||||
assert_eq!(shell.import_label, "Import terrain");
|
assert_eq!(shell.import_label, "Import terrain");
|
||||||
assert_eq!(shell.script_label, "Scripts / paths");
|
assert_eq!(shell.script_label, "Scripts / paths");
|
||||||
assert_eq!(shell.path_label, "Path tools");
|
assert_eq!(shell.path_label, "Path tools");
|
||||||
assert!(shell.scene_file_path.is_none());
|
assert!(shell.scene_file_path.is_some());
|
||||||
assert!(shell.import_path.is_none());
|
assert!(shell.import_path.is_some());
|
||||||
assert!(shell.path_target.is_none());
|
assert!(shell.path_target.is_none());
|
||||||
assert!(shell.status_line.contains("CPU preview"));
|
assert!(shell.status_line.contains("CPU preview"));
|
||||||
}
|
}
|
||||||
@@ -315,6 +442,69 @@ mod tests {
|
|||||||
assert!(shell.script_preview.error.is_none());
|
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"))]
|
#[cfg(not(feature = "app"))]
|
||||||
#[test]
|
#[test]
|
||||||
fn app_feature_is_declared_but_not_enabled_by_default() {
|
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 HIGHLAND_COLOR: [u8; 3] = [120, 100, 80];
|
||||||
pub const SNOW_COLOR: [u8; 3] = [240, 240, 250];
|
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 {
|
if elevation <= water {
|
||||||
WATER_COLOR
|
palette[0]
|
||||||
} else if elevation < tree {
|
} else if elevation < tree {
|
||||||
LOWLAND_COLOR
|
palette[1]
|
||||||
} else if elevation < snow {
|
} else if elevation < snow {
|
||||||
HIGHLAND_COLOR
|
palette[2]
|
||||||
} else {
|
} else {
|
||||||
SNOW_COLOR
|
palette[3]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn scene_color(scene: &Scene, elevation: f32) -> [u8; 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_to_rgb(
|
||||||
elevation,
|
elevation,
|
||||||
scene.water_level,
|
water_level,
|
||||||
scene.tree_line,
|
scene.tree_line,
|
||||||
scene.snow_line,
|
scene.snow_line,
|
||||||
|
&[
|
||||||
|
scene.palette.water,
|
||||||
|
scene.palette.lowland,
|
||||||
|
scene.palette.highland,
|
||||||
|
scene.palette.snow,
|
||||||
|
],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,31 +73,112 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn elevation_below_water_returns_water_color() {
|
fn elevation_below_water_returns_water_color() {
|
||||||
assert_eq!(elevation_to_rgb(-1.0, 1.0, 4.0, 7.0), WATER_COLOR);
|
assert_eq!(
|
||||||
assert_eq!(elevation_to_rgb(0.5, 1.0, 4.0, 7.0), WATER_COLOR);
|
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]
|
#[test]
|
||||||
fn elevation_at_water_returns_water_color() {
|
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]
|
#[test]
|
||||||
fn elevation_between_water_and_tree_returns_lowland() {
|
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!(
|
||||||
assert_eq!(elevation_to_rgb(3.9, 1.0, 4.0, 7.0), LOWLAND_COLOR);
|
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]
|
#[test]
|
||||||
fn elevation_between_tree_and_snow_returns_highland() {
|
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!(
|
||||||
assert_eq!(elevation_to_rgb(6.9, 1.0, 4.0, 7.0), HIGHLAND_COLOR);
|
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]
|
#[test]
|
||||||
fn elevation_above_snow_returns_snow() {
|
fn elevation_above_snow_returns_snow() {
|
||||||
assert_eq!(elevation_to_rgb(7.5, 1.0, 4.0, 7.0), SNOW_COLOR);
|
assert_eq!(
|
||||||
assert_eq!(elevation_to_rgb(100.0, 1.0, 4.0, 7.0), SNOW_COLOR);
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ pub mod app_state;
|
|||||||
pub mod cli;
|
pub mod cli;
|
||||||
pub mod colormap;
|
pub mod colormap;
|
||||||
pub mod import;
|
pub mod import;
|
||||||
|
pub mod path;
|
||||||
pub mod render;
|
pub mod render;
|
||||||
pub mod scene;
|
pub mod scene;
|
||||||
pub mod scene_file;
|
pub mod scene_file;
|
||||||
pub mod script;
|
pub mod script;
|
||||||
|
pub mod script_exec;
|
||||||
pub mod terrain;
|
pub mod terrain;
|
||||||
pub mod terrain_gen;
|
pub mod terrain_gen;
|
||||||
|
|||||||
+113
@@ -0,0 +1,113 @@
|
|||||||
|
use crate::scene::{Camera, Scene, Vec3};
|
||||||
|
|
||||||
|
/// A single camera pose in a generated camera path.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub struct CameraKeyframe {
|
||||||
|
pub time: f32,
|
||||||
|
pub camera: Camera,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CameraKeyframe {
|
||||||
|
pub const fn new(time: f32, camera: Camera) -> Self {
|
||||||
|
Self { time, camera }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A lightweight generated camera path used by the shell's Make Path action.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct CameraPath {
|
||||||
|
keyframes: Vec<CameraKeyframe>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CameraPath {
|
||||||
|
pub fn new(keyframes: Vec<CameraKeyframe>) -> Self {
|
||||||
|
Self { keyframes }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn keyframes(&self) -> &[CameraKeyframe] {
|
||||||
|
&self.keyframes
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn summary(&self) -> String {
|
||||||
|
match (self.keyframes.first(), self.keyframes.last()) {
|
||||||
|
(Some(first), Some(last)) => format!(
|
||||||
|
"{} keyframes · {:.1}s → {:.1}s",
|
||||||
|
self.keyframes.len(),
|
||||||
|
first.time,
|
||||||
|
last.time
|
||||||
|
),
|
||||||
|
_ => "empty camera path".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn vec3_add(a: Vec3, b: Vec3) -> Vec3 {
|
||||||
|
Vec3::new(a.x + b.x, a.y + b.y, a.z + b.z)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn vec3_from_angle(radius: f32, height: f32, angle_radians: f32) -> Vec3 {
|
||||||
|
Vec3::new(
|
||||||
|
radius * angle_radians.cos(),
|
||||||
|
height,
|
||||||
|
radius * angle_radians.sin(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a small orbit-style 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_add(focus, vec3_from_angle(radius, base_y, -0.6)),
|
||||||
|
target: focus,
|
||||||
|
..scene.camera
|
||||||
|
},
|
||||||
|
),
|
||||||
|
CameraKeyframe::new(
|
||||||
|
3.0,
|
||||||
|
Camera {
|
||||||
|
position: vec3_add(focus, vec3_from_angle(radius * 1.1, base_y + 8.0, 0.0)),
|
||||||
|
target: focus,
|
||||||
|
..scene.camera
|
||||||
|
},
|
||||||
|
),
|
||||||
|
CameraKeyframe::new(
|
||||||
|
6.0,
|
||||||
|
Camera {
|
||||||
|
position: vec3_add(focus, vec3_from_angle(radius, base_y, 0.6)),
|
||||||
|
target: focus,
|
||||||
|
..scene.camera
|
||||||
|
},
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
CameraPath::new(keyframes)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn demo_path_builds_three_keyframes() {
|
||||||
|
let path = build_demo_path(&Scene::default());
|
||||||
|
assert_eq!(path.keyframes().len(), 3);
|
||||||
|
assert!(path.summary().contains("3 keyframes"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keyframe_constructor_preserves_fields() {
|
||||||
|
let camera = Camera::default();
|
||||||
|
let keyframe = CameraKeyframe::new(1.25, camera);
|
||||||
|
assert_eq!(keyframe.time, 1.25);
|
||||||
|
assert_eq!(keyframe.camera, camera);
|
||||||
|
}
|
||||||
|
}
|
||||||
+60
-7
@@ -10,9 +10,10 @@ pub fn render_top_down(grid: &HeightGrid, scene: &Scene) -> RgbImage {
|
|||||||
let w = grid.width();
|
let w = grid.width();
|
||||||
let h = grid.height();
|
let h = grid.height();
|
||||||
let mut img = RgbImage::new(w, h);
|
let mut img = RgbImage::new(w, h);
|
||||||
|
let vertical_exaggeration = scene.vertical_exaggeration.max(0.0);
|
||||||
for y in 0..h {
|
for y in 0..h {
|
||||||
for x in 0..w {
|
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);
|
let color = scene_color(scene, elevation);
|
||||||
img.put_pixel(x, y, Rgb(color));
|
img.put_pixel(x, y, Rgb(color));
|
||||||
}
|
}
|
||||||
@@ -83,6 +84,48 @@ fn v_normalize(a: Vec3) -> Vec3 {
|
|||||||
v_scale(a, 1.0 / len)
|
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
|
/// Bilinear height lookup. Returns `None` if the (x, z) sample falls outside
|
||||||
/// the grid's interior cell range [0, width-1] × [0, height-1].
|
/// the grid's interior cell range [0, width-1] × [0, height-1].
|
||||||
fn sample_height_bilinear(grid: &HeightGrid, x: f32, z: f32) -> Option<f32> {
|
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 cz = (h - 1.0) * 0.5;
|
||||||
let cam_y = peak * 1.5 + h * 0.5;
|
let cam_y = peak * 1.5 + h * 0.5;
|
||||||
let cam_z = -(h * 0.6);
|
let cam_z = -(h * 0.6);
|
||||||
|
let far_range = (w + h) * 2.0 + cam_y * 2.0;
|
||||||
Camera {
|
Camera {
|
||||||
position: Vec3::new(cx, cam_y, cam_z),
|
position: Vec3::new(cx, cam_y, cam_z),
|
||||||
target: Vec3::new(cx, peak * 0.3, cz),
|
target: Vec3::new(cx, peak * 0.3, cz),
|
||||||
|
orientation: Vec3::ZERO,
|
||||||
fov_degrees: 55.0,
|
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 mut img = RgbImage::new(width.max(1), height.max(1));
|
||||||
|
|
||||||
let cam = &scene.camera;
|
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 world_up = Vec3::new(0.0, 1.0, 0.0);
|
||||||
let right = v_normalize(v_cross(forward, world_up));
|
let mut right = v_normalize(v_cross(forward, world_up));
|
||||||
let up = v_cross(right, forward);
|
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 fov_rad = cam.fov_degrees.to_radians();
|
||||||
let tan_half = (fov_rad * 0.5).tan();
|
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_w = grid.width() as f32;
|
||||||
let grid_h = grid.height() 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 step = 0.5_f32;
|
||||||
|
|
||||||
let haze_strength = scene.haze.clamp(0.0, 1.0);
|
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 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;
|
let mut hit_color: Option<[u8; 3]> = None;
|
||||||
while t < max_dist {
|
while t < max_dist {
|
||||||
let p = v_add(cam.position, v_scale(dir, t));
|
let p = v_add(cam.position, v_scale(dir, t));
|
||||||
if let Some(terrain_h) = sample_height_bilinear(grid, p.x, p.z) {
|
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 {
|
if p.y <= terrain_h {
|
||||||
let band = scene_color(scene, 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));
|
hit_color = Some(mix_color(band, sky, fade));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -178,6 +230,7 @@ pub fn render_perspective(grid: &HeightGrid, scene: &Scene, width: u32, height:
|
|||||||
t += step;
|
t += step;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let _ = (grid_w, grid_h);
|
||||||
img.put_pixel(px, py, Rgb(hit_color.unwrap_or(sky)));
|
img.put_pixel(px, py, Rgb(hit_color.unwrap_or(sky)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,10 +20,15 @@ impl Vec3 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
pub struct Camera {
|
pub struct Camera {
|
||||||
pub position: Vec3,
|
pub position: Vec3,
|
||||||
pub target: Vec3,
|
pub target: Vec3,
|
||||||
|
/// Heading, pitch, and bank in degrees.
|
||||||
|
pub orientation: Vec3,
|
||||||
pub fov_degrees: f32,
|
pub fov_degrees: f32,
|
||||||
|
pub near_range: f32,
|
||||||
|
pub far_range: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Camera {
|
impl Default for Camera {
|
||||||
@@ -31,12 +36,16 @@ impl Default for Camera {
|
|||||||
Camera {
|
Camera {
|
||||||
position: Vec3::new(0.0, 50.0, 50.0),
|
position: Vec3::new(0.0, 50.0, 50.0),
|
||||||
target: Vec3::ZERO,
|
target: Vec3::ZERO,
|
||||||
|
orientation: Vec3::ZERO,
|
||||||
fov_degrees: 60.0,
|
fov_degrees: 60.0,
|
||||||
|
near_range: 1.0,
|
||||||
|
far_range: 500.0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
pub struct Light {
|
pub struct Light {
|
||||||
pub direction: Vec3,
|
pub direction: Vec3,
|
||||||
pub intensity: f32,
|
pub intensity: f32,
|
||||||
@@ -52,6 +61,51 @@ impl Default for Light {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
#[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 struct Scene {
|
||||||
pub camera: Camera,
|
pub camera: Camera,
|
||||||
pub light: Light,
|
pub light: Light,
|
||||||
@@ -59,6 +113,9 @@ pub struct Scene {
|
|||||||
pub tree_line: f32,
|
pub tree_line: f32,
|
||||||
pub snow_line: f32,
|
pub snow_line: f32,
|
||||||
pub haze: f32,
|
pub haze: f32,
|
||||||
|
pub vertical_exaggeration: f32,
|
||||||
|
pub hydrology: Hydrology,
|
||||||
|
pub palette: Palette,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Scene {
|
impl Default for Scene {
|
||||||
@@ -70,6 +127,9 @@ impl Default for Scene {
|
|||||||
tree_line: 4.0,
|
tree_line: 4.0,
|
||||||
snow_line: 7.0,
|
snow_line: 7.0,
|
||||||
haze: 0.2,
|
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.
|
/// Write `scene` to `path` as an OpenVistaPro `.ovp.toml` scene file.
|
||||||
pub fn save_to_path(scene: &Scene, path: &Path) -> Result<(), SceneFileError> {
|
pub fn save_to_path(scene: &Scene, path: &Path) -> Result<(), SceneFileError> {
|
||||||
let text = to_toml_string(scene)?;
|
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)?;
|
fs::write(path, text)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -120,14 +125,17 @@ pub fn load_from_path(path: &Path) -> Result<Scene, SceneFileError> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::scene::{Camera, Light, Scene, Vec3};
|
use crate::scene::{Camera, Hydrology, Light, Palette, Scene, Vec3};
|
||||||
|
|
||||||
fn custom_scene() -> Scene {
|
fn custom_scene() -> Scene {
|
||||||
Scene {
|
Scene {
|
||||||
camera: Camera {
|
camera: Camera {
|
||||||
position: Vec3::new(1.5, 2.5, 3.5),
|
position: Vec3::new(1.5, 2.5, 3.5),
|
||||||
target: Vec3::new(-1.0, 0.0, 4.0),
|
target: Vec3::new(-1.0, 0.0, 4.0),
|
||||||
|
orientation: Vec3::new(5.0, -10.0, 2.5),
|
||||||
fov_degrees: 42.0,
|
fov_degrees: 42.0,
|
||||||
|
near_range: 0.5,
|
||||||
|
far_range: 250.0,
|
||||||
},
|
},
|
||||||
light: Light {
|
light: Light {
|
||||||
direction: Vec3::new(0.0, -1.0, 0.0),
|
direction: Vec3::new(0.0, -1.0, 0.0),
|
||||||
@@ -137,6 +145,18 @@ mod tests {
|
|||||||
tree_line: 3.0,
|
tree_line: 3.0,
|
||||||
snow_line: 8.0,
|
snow_line: 8.0,
|
||||||
haze: 0.4,
|
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]
|
#[test]
|
||||||
fn load_rejects_unknown_schema() {
|
fn load_rejects_unknown_schema() {
|
||||||
let bad = format!(
|
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");
|
let err = from_toml_str(&bad).expect_err("unknown schema must be rejected");
|
||||||
assert!(
|
assert!(
|
||||||
@@ -206,7 +226,7 @@ mod tests {
|
|||||||
fn load_rejects_unsupported_version() {
|
fn load_rejects_unsupported_version() {
|
||||||
let future_version = SCENE_VERSION + 99;
|
let future_version = SCENE_VERSION + 99;
|
||||||
let bad = format!(
|
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");
|
let err = from_toml_str(&bad).expect_err("unsupported version must be rejected");
|
||||||
assert!(
|
assert!(
|
||||||
@@ -217,12 +237,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn load_returns_io_error_for_missing_file() {
|
fn load_returns_io_error_for_missing_file() {
|
||||||
let mut path = std::env::temp_dir();
|
let path = temp_path("missing");
|
||||||
path.push(format!(
|
|
||||||
"openvistapro-scenefile-missing-{}.ovp.toml",
|
|
||||||
std::process::id()
|
|
||||||
));
|
|
||||||
let _ = std::fs::remove_file(&path);
|
|
||||||
let err = load_from_path(&path).expect_err("missing file should error");
|
let err = load_from_path(&path).expect_err("missing file should error");
|
||||||
assert!(matches!(err, SceneFileError::Io(_)));
|
assert!(matches!(err, SceneFileError::Io(_)));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::io;
|
||||||
|
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};
|
||||||
|
use crate::terrain::{HeightGrid, TerrainError};
|
||||||
|
|
||||||
|
const PRESET_SIZE: u32 = 64;
|
||||||
|
const HILL_PEAK_HEIGHT: f32 = 10.0;
|
||||||
|
|
||||||
|
/// Summary of a successful script run.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
|
pub struct ExecReport {
|
||||||
|
pub outputs: Vec<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Errors produced while loading or executing a script.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum ScriptError {
|
||||||
|
Io(io::Error),
|
||||||
|
Parse(ParseError),
|
||||||
|
Import(crate::import::ImportError),
|
||||||
|
Terrain(TerrainError),
|
||||||
|
Image(ImageError),
|
||||||
|
RenderWithoutTerrain,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for ScriptError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
ScriptError::Io(e) => write!(f, "script I/O error: {e}"),
|
||||||
|
ScriptError::Parse(e) => write!(f, "script parse error: {e}"),
|
||||||
|
ScriptError::Import(e) => write!(f, "script import error: {e}"),
|
||||||
|
ScriptError::Terrain(e) => write!(f, "script terrain error: {e}"),
|
||||||
|
ScriptError::Image(e) => write!(f, "script image error: {e}"),
|
||||||
|
ScriptError::RenderWithoutTerrain => write!(
|
||||||
|
f,
|
||||||
|
"`render output` reached before any `use preset` or `import heightmap`"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for ScriptError {}
|
||||||
|
|
||||||
|
impl From<ParseError> for ScriptError {
|
||||||
|
fn from(e: ParseError) -> Self {
|
||||||
|
ScriptError::Parse(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<crate::import::ImportError> for ScriptError {
|
||||||
|
fn from(e: crate::import::ImportError) -> Self {
|
||||||
|
ScriptError::Import(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<TerrainError> for ScriptError {
|
||||||
|
fn from(e: TerrainError) -> Self {
|
||||||
|
ScriptError::Terrain(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ImageError> for ScriptError {
|
||||||
|
fn from(e: ImageError) -> Self {
|
||||||
|
ScriptError::Image(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read, parse, and execute a script file.
|
||||||
|
pub fn run_script_file(path: &Path) -> Result<ExecReport, ScriptError> {
|
||||||
|
let source = fs::read_to_string(path).map_err(ScriptError::Io)?;
|
||||||
|
let script = parse_script(&source)?;
|
||||||
|
let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
|
||||||
|
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.
|
||||||
|
pub fn run_script(script: &Script, base_dir: &Path) -> Result<ExecReport, ScriptError> {
|
||||||
|
let mut scene = Scene::default();
|
||||||
|
let mut grid: Option<HeightGrid> = None;
|
||||||
|
let mut report = ExecReport::default();
|
||||||
|
|
||||||
|
for command in &script.commands {
|
||||||
|
match command {
|
||||||
|
Command::UsePreset(PresetName::Hill) => {
|
||||||
|
grid = Some(HeightGrid::radial_hill(
|
||||||
|
PRESET_SIZE,
|
||||||
|
PRESET_SIZE,
|
||||||
|
HILL_PEAK_HEIGHT,
|
||||||
|
)?);
|
||||||
|
}
|
||||||
|
Command::UsePreset(PresetName::Plane) => {
|
||||||
|
grid = Some(HeightGrid::plane(PRESET_SIZE, PRESET_SIZE)?);
|
||||||
|
}
|
||||||
|
Command::SetThresholds(thresholds) => {
|
||||||
|
scene.water_level = thresholds.water;
|
||||||
|
scene.tree_line = thresholds.tree;
|
||||||
|
scene.snow_line = thresholds.snow;
|
||||||
|
}
|
||||||
|
Command::ImportHeightmap { path } => {
|
||||||
|
grid = Some(load_heightmap(&resolve(base_dir, path))?);
|
||||||
|
}
|
||||||
|
Command::RenderOutput { path } => {
|
||||||
|
let grid = grid.as_ref().ok_or(ScriptError::RenderWithoutTerrain)?;
|
||||||
|
let output = resolve(base_dir, path);
|
||||||
|
if let Some(parent) = output.parent() {
|
||||||
|
if !parent.as_os_str().is_empty() {
|
||||||
|
fs::create_dir_all(parent).map_err(ScriptError::Io)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
render_top_down_to_path(grid, &scene, &output)?;
|
||||||
|
report.outputs.push(output);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(report)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_heightmap(path: &Path) -> Result<HeightGrid, ScriptError> {
|
||||||
|
let source = fs::read_to_string(path).map_err(ScriptError::Io)?;
|
||||||
|
let imported = import_ovp_text(&source)?;
|
||||||
|
Ok(imported.into_grid())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve(base_dir: &Path, path: &str) -> PathBuf {
|
||||||
|
let candidate = Path::new(path);
|
||||||
|
if candidate.is_absolute() {
|
||||||
|
candidate.to_path_buf()
|
||||||
|
} else {
|
||||||
|
base_dir.join(candidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn temp_dir(tag: &str) -> PathBuf {
|
||||||
|
let mut dir = std::env::temp_dir();
|
||||||
|
dir.push(format!(
|
||||||
|
"openvistapro-script-exec-{}-{}",
|
||||||
|
tag,
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(&dir).expect("create temp dir");
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
const PNG_MAGIC: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn run_script_renders_preset_to_png() {
|
||||||
|
let dir = temp_dir("preset");
|
||||||
|
let script = parse_script(
|
||||||
|
"use preset hill\nset thresholds water=1.0 tree=4.0 snow=7.0\nrender output \"demo.png\"",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let report = run_script(&script, &dir).expect("script should execute");
|
||||||
|
assert_eq!(report.outputs.len(), 1);
|
||||||
|
let bytes = std::fs::read(&report.outputs[0]).expect("output png should exist");
|
||||||
|
assert!(
|
||||||
|
bytes.starts_with(&PNG_MAGIC),
|
||||||
|
"rendered file should be a PNG"
|
||||||
|
);
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn run_script_creates_missing_output_directories() {
|
||||||
|
let dir = temp_dir("nested");
|
||||||
|
let script = parse_script("use preset plane\nrender output \"out/nested/p.png\"").unwrap();
|
||||||
|
let report = run_script(&script, &dir).expect("script should execute");
|
||||||
|
assert!(
|
||||||
|
report.outputs[0].exists(),
|
||||||
|
"executor should create missing parent directories"
|
||||||
|
);
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn run_script_rejects_render_before_terrain() {
|
||||||
|
let dir = temp_dir("missing-terrain");
|
||||||
|
let script = parse_script("render output \"demo.png\"").unwrap();
|
||||||
|
let err = run_script(&script, &dir).expect_err("render without terrain must fail");
|
||||||
|
assert!(matches!(err, ScriptError::RenderWithoutTerrain));
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn run_script_loads_open_heightmap_from_text_fixture() {
|
||||||
|
let dir = temp_dir("import");
|
||||||
|
let fixture = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("tests/fixtures/open/tiny-heightfield.ovptext");
|
||||||
|
let script = parse_script(&format!(
|
||||||
|
"import heightmap \"{}\"\nrender output \"demo.png\"",
|
||||||
|
fixture.display()
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let report = run_script(&script, &dir).expect("script should execute");
|
||||||
|
assert_eq!(report.outputs.len(), 1);
|
||||||
|
let bytes = std::fs::read(&report.outputs[0]).expect("output png should exist");
|
||||||
|
assert!(bytes.starts_with(&PNG_MAGIC));
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -24,7 +24,7 @@ impl fmt::Display for TerrainError {
|
|||||||
|
|
||||||
impl std::error::Error for TerrainError {}
|
impl std::error::Error for TerrainError {}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct HeightGrid {
|
pub struct HeightGrid {
|
||||||
width: u32,
|
width: u32,
|
||||||
height: u32,
|
height: u32,
|
||||||
|
|||||||
Reference in New Issue
Block a user