diff --git a/README.md b/README.md index b702ad0..4b0293a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/knowledgebase/architecture-notes.md b/docs/knowledgebase/architecture-notes.md index 7cf014f..84afc36 100644 --- a/docs/knowledgebase/architecture-notes.md +++ b/docs/knowledgebase/architecture-notes.md @@ -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. diff --git a/docs/knowledgebase/ui-panel-map.md b/docs/knowledgebase/ui-panel-map.md new file mode 100644 index 0000000..baf42ad --- /dev/null +++ b/docs/knowledgebase/ui-panel-map.md @@ -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. diff --git a/docs/plans/phase-4-formats-scripts-ui.md b/docs/plans/phase-4-formats-scripts-ui.md index a2a4a2b..39c8a32 100644 --- a/docs/plans/phase-4-formats-scripts-ui.md +++ b/docs/plans/phase-4-formats-scripts-ui.md @@ -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. diff --git a/docs/plans/terrain-generation.md b/docs/plans/terrain-generation.md index 8102071..367c892 100644 --- a/docs/plans/terrain-generation.md +++ b/docs/plans/terrain-generation.md @@ -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. diff --git a/src/app.rs b/src/app.rs index 6d8d475..23f5f37 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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) -> 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) -> 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) -> 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) -> 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) -> 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 = 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); diff --git a/src/app_state.rs b/src/app_state.rs index 162034c..312e84e 100644 --- a/src/app_state.rs +++ b/src/app_state.rs @@ -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, +} + +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, + pub import_path: Option, + pub path_target: Option, + 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, + /// Heightmap path selected for import. + pub import_path: Option, + /// Path-tool target or summary. + pub path_target: Option, + /// 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, + /// Generated camera path from the Make Path action. + pub generated_path: Option, + /// Last script execution report. + pub last_script_run: Option, } 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> { + 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 { + 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 { + 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 { - let (width, height) = self.preview_size; - self.terrain_preset.build_grid(width, height) + self.active_height_grid() } pub fn render_preview(&self) -> Result { @@ -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), + 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() { diff --git a/src/colormap.rs b/src/colormap.rs index c974495..d209439 100644 --- a/src/colormap.rs +++ b/src/colormap.rs @@ -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] diff --git a/src/lib.rs b/src/lib.rs index 64779aa..d2503b0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/src/path.rs b/src/path.rs index 65daaa3..4007d10 100644 --- a/src/path.rs +++ b/src/path.rs @@ -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() }, ) } diff --git a/src/render.rs b/src/render.rs index 44df666..4b8e3c5 100644 --- a/src/render.rs +++ b/src/render.rs @@ -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 { @@ -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))); } } diff --git a/src/scene.rs b/src/scene.rs index 84491e1..058fc9a 100644 --- a/src/scene.rs +++ b/src/scene.rs @@ -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(), } } } diff --git a/src/scene_file.rs b/src/scene_file.rs index c6341b9..8ada155 100644 --- a/src/scene_file.rs +++ b/src/scene_file.rs @@ -107,6 +107,11 @@ pub fn from_toml_str(text: &str) -> Result { /// 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 { #[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(_))); } diff --git a/src/script_exec.rs b/src/script_exec.rs index 7dcee7b..0a70c20 100644 --- a/src/script_exec.rs +++ b/src/script_exec.rs @@ -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 for ScriptError { } } +impl From for ScriptError { + fn from(e: crate::import::ImportError) -> Self { + ScriptError::Import(e) + } +} + impl From for ScriptError { fn from(e: ImageError) -> Self { ScriptError::Image(e) @@ -102,6 +112,12 @@ pub fn run_script_file(path: &Path) -> Result { run_script(&script, base_dir) } +/// Parse and execute script source text. +pub fn run_script_source(source: &str, base_dir: &Path) -> Result { + 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 { @@ -146,10 +162,17 @@ pub fn run_script(script: &Script, base_dir: &Path) -> Result Result { - 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 diff --git a/src/terrain.rs b/src/terrain.rs index 807c50e..5529a0a 100644 --- a/src/terrain.rs +++ b/src/terrain.rs @@ -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, diff --git a/src/terrain_gen.rs b/src/terrain_gen.rs new file mode 100644 index 0000000..f4fd9b1 --- /dev/null +++ b/src/terrain_gen.rs @@ -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 { + 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; +} + +/// 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 { + 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)); + } +} diff --git a/tests/docs_sync.rs b/tests/docs_sync.rs new file mode 100644 index 0000000..9d87b39 --- /dev/null +++ b/tests/docs_sync.rs @@ -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"); +} diff --git a/tests/terrain_gen.rs b/tests/terrain_gen.rs new file mode 100644 index 0000000..9911f23 --- /dev/null +++ b/tests/terrain_gen.rs @@ -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); +}