diff --git a/Cargo.lock b/Cargo.lock index 891eae9..cf0219f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -584,6 +584,7 @@ dependencies = [ "egui-wgpu", "egui-winit", "egui_glow", + "glow", "glutin", "glutin-winit", "image", @@ -594,7 +595,6 @@ dependencies = [ "objc2-foundation 0.2.2", "parking_lot", "percent-encoding", - "pollster", "profiling", "raw-window-handle", "static_assertions", @@ -602,7 +602,6 @@ dependencies = [ "wasm-bindgen-futures", "web-sys", "web-time", - "wgpu", "winapi", "windows-sys 0.59.0", "winit", @@ -1990,12 +1989,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "pollster" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" - [[package]] name = "portable-atomic" version = "1.13.1" diff --git a/Cargo.toml b/Cargo.toml index 7293f63..29fb02a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,8 +12,7 @@ import-geotiff = ["dep:geotiff-reader"] [dependencies] clap = { version = "4.6.1", features = ["derive"] } -eframe = { version = "0.32.3", optional = true, default-features = false, features = ["default_fonts", "wayland", "wgpu", "x11"] } -#wgpu = { version = "25.0.2", features = ["metal"] } +eframe = { version = "0.32.3", optional = true, default-features = false, features = ["default_fonts", "glow", "wayland", "x11"] } image = { version = "0.25.9", default-features = false, features = ["png"] } geotiff-reader = { version = "0.4.0", optional = true, default-features = false, features = ["local"] } serde = { version = "1", features = ["derive"] } diff --git a/README.md b/README.md index 022b89c..83d2b44 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ cargo run --features app --bin openvistapro_app The optional app shell is gated behind the `app` feature so default CLI builds stay GPU-free. It opens an `eframe`/`egui` window titled `OpenVistaPro` with scene controls, top-level menus/dialogs, and a CPU-rendered terrain preview. +The native shell uses eframe's Glow renderer under X11/Xvfb, which keeps the app smoke launch independent of WGPU backend feature selection. Importer status: diff --git a/docs/knowledgebase/architecture-notes.md b/docs/knowledgebase/architecture-notes.md index 1339dfc..790839e 100644 --- a/docs/knowledgebase/architecture-notes.md +++ b/docs/knowledgebase/architecture-notes.md @@ -5,7 +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/terrain_gen.rs`: `TerrainGenerationSpec`, `TerrainGenerationSettings`, and `DeterministicTerrainGenerator` for the seeded terrain-generation pipeline; `cargo test terrain_gen` exercises the determinism/seed note and the first shipped `fractal` preset. - `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 index 9636ab6..f983cc8 100644 --- a/docs/knowledgebase/ui-panel-map.md +++ b/docs/knowledgebase/ui-panel-map.md @@ -8,12 +8,12 @@ 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` 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 working heightmap import action; 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, explicit heading/pitch/bank controls, lens/FOV/clip range controls, vertical exaggeration, color-map editing, and hydrology overlays now live in `src/app.rs` and `src/app_state.rs`; `src/scene.rs`, `src/render.rs`, and `src/script_exec.rs` carry the model/render semantics. The shell covers the main VistaPro scene controls, but its camera semantics are intentionally simplified and not yet tied to any map-click placement workflow. | +| Scene / camera | Camera and target gadgets, lens/range, bank/heading/pitch, water/tree/snow/haze controls | Left dock or inspector stack | Partial | Position/target, explicit heading/pitch/bank controls, lens/FOV/clip range controls, vertical exaggeration, lighting direction/intensity gadgets, color-map editing, and hydrology overlays now live in `src/app.rs` and `src/app_state.rs`; `src/scene.rs`, `src/render.rs`, and `src/script_exec.rs` carry the model/render semantics. The shell 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 now exposes preview/balanced/final quality presets alongside top-down vs perspective render mode; the shell still lacks the full legacy menu chrome and fine-grained smoothing sliders. | | Scripts / paths | Script menu, Run Script, MakePath path tools, animation-frame workflows | Right dock or modal workflow | Partial | Script parsing/execution and MakePath-style path generation now run end-to-end in the backend; the shell surfaces a script editor, Run Script, and Make Path controls, but animation-frame export and richer path editing are still future work. | -| 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 working New/Open/Save controls; legacy menu chrome and export dialogs are still missing. | +| File / project actions | New/Open/Save landscape, scene load/save, export commands | Top bar / file menu | Partial | The shell now shows scene-file status plus working New/Open/Save controls, and the top menu mirrors those actions alongside import and help/about entry points; legacy menu chrome and export dialogs are still missing. | | Status / feedback | Coordinate readouts, render state, file path, progress, messages | Bottom status bar | Present | The shell now has a bottom status bar driven by `AppData::ui_snapshot()`. | -| Legacy compatibility / advanced dialogs | Dense menu hierarchy, advanced lighting, hydrology, vertical exaggeration, palette editing, legacy image/landscape exports | Right dock, tool drawer, or dialogs | Partial | These are best surfaced as future tabs or dialogs rather than cluttering the initial shell; some sub-features already exist in the backend, but direct legacy export compatibility is intentionally out of scope for now. | +| Legacy compatibility / advanced dialogs | Dense menu hierarchy, advanced lighting, hydrology, vertical exaggeration, palette editing, legacy image/landscape exports | Right dock, tool drawer, or dialogs | Partial | These are best surfaced as future tabs or dialogs rather than cluttering the initial shell; some sub-features already exist in the backend, and the modern shell now exposes a top menu, about/help dialog, and lighting numeric controls, but the legacy-style menu/dialog workflow is still incomplete. | ## Recommended shell structure diff --git a/docs/legal/asset-policy.md b/docs/legal/asset-policy.md index 3e6f34f..655e709 100644 --- a/docs/legal/asset-policy.md +++ b/docs/legal/asset-policy.md @@ -30,9 +30,15 @@ screenshots, archives, disk images, or local-only reference payloads under `tests/`. Those belong in the git-ignored `reference/` and `.work/` directories and must stay local-only. -Importers parse only open or synthetic formats. The `ovp-text` plain-text -heightfield format read by `src/import.rs` is an original OpenVistaPro format; -it is not a reimplementation of any proprietary VistaPro file layout. +Importers parse only open or synthetic formats. The supported terrain source +boundary currently consists of the original OpenVistaPro `ovp-text` plain-text +heightfield format, script-level PNG heightmaps, and open SRTM/HGT, ESRI ASCII +Grid, and GeoTIFF inputs. These all normalize to the same internal terrain +model and are not reimplementations of any proprietary VistaPro file layout. + +Legacy VistaPro terrain/image compatibility remains out of scope for the clean- +room project unless a future, separately reviewed compatibility investigation +explicitly approves it. ## Reverse engineering boundary diff --git a/docs/plans/terrain-generation.md b/docs/plans/terrain-generation.md index 367c892..12081c8 100644 --- a/docs/plans/terrain-generation.md +++ b/docs/plans/terrain-generation.md @@ -8,7 +8,7 @@ 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_gen.rs` with `TerrainGenerationSpec`, `TerrainGenerationSettings`, 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`. @@ -16,7 +16,7 @@ OpenVistaPro already has: - `src/import.rs` for the open-format import boundary. - `docs/plans/initial-roadmap.md` and `docs/plans/phase-4-formats-scripts-ui.md` for the broader project sequence. -The missing piece is a dedicated procedural terrain generator pipeline that can produce richer synthetic landscapes without mixing algorithm state into `HeightGrid`. +The core seeded procedural terrain pipeline now exists; the remaining work in this roadmap is about future generator variants and richer presets, not the first shipped fractal slice. ## Decision summary @@ -108,6 +108,7 @@ Suggested first presets: - `plain`-like flat terrain via zeroed noise amplitude - `island` / `continental` profile with a radial falloff mask - `mountain` profile with stronger octave contrast +- `fractal` / `noise` preset exposed through the app shell and CLI as the first shipped procedural family Acceptance: @@ -154,6 +155,7 @@ cargo fmt --check cargo test terrain cargo test cargo clippy --all-targets -- -D warnings +cargo run -- render --preset fractal --seed 1337 --width 64 --height 64 --output /tmp/openvistapro-fractal-plan.png ``` Add one feature-specific smoke command for the slice once the generator has a public entry point, for example a tiny render or sample-generation command that writes to `/tmp` and proves the generated grid is usable end-to-end. diff --git a/docs/plans/wgpu-egui-architecture-spike.md b/docs/plans/wgpu-egui-architecture-spike.md new file mode 100644 index 0000000..275b073 --- /dev/null +++ b/docs/plans/wgpu-egui-architecture-spike.md @@ -0,0 +1,124 @@ +# WGPU/egui Interactive App Architecture Spike + +> Goal: introduce an interactive OpenVistaPro app without destabilizing the existing CLI, importer pipeline, or deterministic CPU reference renderer. + +## Decision + +Keep the repository as a single Rust crate for now. + +The current boundary set is still moving, but it is already useful: + +- `src/terrain.rs`, `src/terrain_gen.rs`, `src/import.rs`, `src/scene.rs`, `src/scene_file.rs`, `src/script.rs`, `src/script_exec.rs`, `src/path.rs`, `src/render.rs`, and `src/colormap.rs` are the clean-room core. +- `src/cli.rs` and `src/main.rs` are the command-line adapter. +- `src/app_state.rs`, `src/ui_shell.rs`, `src/app.rs`, and `src/bin/openvistapro_app.rs` are the optional interactive shell. + +A workspace split into `openvistapro-core`, `openvistapro-cli`, and `openvistapro-app` would add maintenance overhead before there is a second renderer/backend boundary that truly justifies it. The better near-term move is to keep a monolith and enforce boundaries with modules, feature flags, and tests. + +Revisit a workspace split only when at least one of these becomes true: + +1. the GPU renderer needs materially different dependencies from the CLI/reference renderer, +2. the core API stabilizes enough that the app shell can depend on it as a published internal crate boundary, or +3. build times / dependency fanout make the single-crate layout painful. + +## Integration recommendation + +Use egui as the interactive shell now, and treat WGPU as the rendering backend that arrives later behind a narrow viewport abstraction. + +Recommended path: + +1. Keep `eframe` for the windowing / egui host while the shell is still mostly controls and state. +2. Keep the current CPU renderer as the canonical reference output for tests and CLI smoke runs. +3. Introduce a tiny viewport trait or adapter boundary before any full GPU renderer lands. +4. Add a WGPU-backed viewport only once the render pipeline actually needs GPU surfaces, textures, and custom passes. + +Why this order: + +- `eframe` already gives a working egui host with the least glue. +- CLI builds stay GPU-free because the app stays behind the `app` feature. +- The CPU renderer remains the reference implementation for determinism and testability. +- WGPU can then become an implementation detail of the viewport, not a rewrite of the app state model. + +## Module boundaries + +Keep the following responsibilities separate: + +### Core domain + +- `terrain.rs`: immutable height grid and sampling rules. +- `terrain_gen.rs`: deterministic procedural generation inputs and output grid creation. +- `import.rs`: import boundary and source metadata. +- `scene.rs`: camera, lighting, hydrology, and palette-linked scene state. +- `scene_file.rs`: `.ovp.toml` persistence. +- `script.rs` / `script_exec.rs`: project-owned script language and executor. +- `path.rs`: MakePath-style camera path generation. +- `render.rs`: deterministic CPU render outputs. +- `colormap.rs`: palette / band mapping. + +### CLI adapter + +- `cli.rs` should stay focused on argument parsing and orchestration. +- Keep it thin enough that the same core operations can be called from future automation or from the app shell. + +### Interactive shell state + +- `app_state.rs` should remain pure state plus reducers / actions. +- `ui_shell.rs` should own section ordering, labels, and placeholder metadata. +- Avoid leaking egui types into either file. + +### egui view/controller + +- `app.rs` should remain the presentation layer that turns `AppData` into panels, menus, and a preview texture. +- The shell should consume state snapshots and call backend actions, not embed render or import logic inline. + +### Future GPU viewport + +- Introduce a narrow backend boundary for viewport rendering before the first custom WGPU renderer lands. +- Keep surface creation, texture upload, and draw passes out of `AppData`. +- Prefer a dedicated GPU adapter module over scattering WGPU setup across the UI code. + +## Minimal first app shell + +The smallest durable shell is: + +- a stable top command bar, +- a left or right dock for section-specific controls, +- a central viewport that can show the CPU preview now and a GPU surface later, +- a status bar for file paths, script status, and render feedback, +- a tiny about/help dialog for orientation. + +That is already enough to validate layout, interaction, and state management without pretending the GPU renderer exists yet. + +## Testing strategy + +Keep the test surface split the same way as the code. + +### Unit tests + +- `AppData` reducers and state transitions. +- `UiShellState` navigation order and section metadata. +- render-mode selection and preview-state plumbing. +- scene / script / path / importer behavior. + +### Smoke commands + +Use existing CLI and app entry points as smoke tests for the architecture: + +- `cargo run -- info` +- `cargo run -- render --preset fractal --seed 1337 --width 64 --height 64 --output /tmp/openvistapro-fractal.png` +- `cargo run --features app --bin openvistapro_app` when a display is available + +### Later integration tests + +Once the GPU viewport exists, add lightweight screenshot or frame-smoke coverage around the viewport adapter rather than baking WGPU assumptions into the core state model. + +## Next tasks + +1. Add a small viewport abstraction so the egui shell can switch between CPU preview and a future GPU backend without changing `AppData`. +2. Keep the CPU renderer as the reference implementation for deterministic validation. +3. Add a dedicated GPU adapter module only when the WGPU surface truly exists. +4. Revisit a workspace split after the GPU path forces a second hard boundary. +5. Keep `docs/knowledgebase/architecture-notes.md` and `docs/knowledgebase/ui-panel-map.md` aligned with any later shell or viewport changes. + +## Recommendation summary + +Stay monolithic now, keep egui as the host shell, and treat WGPU as a future viewport backend rather than the reason to split the crate today. That gives OpenVistaPro the fastest path to a usable interactive app while protecting the CLI and deterministic renderer from churn. diff --git a/src/app.rs b/src/app.rs index 12e7ae3..4355dfe 100644 --- a/src/app.rs +++ b/src/app.rs @@ -765,14 +765,27 @@ fn rgb_image_to_color_image(image: &RgbImage) -> egui::ColorImage { egui::ColorImage::from_rgb(size, image.as_raw()) } -pub fn run() -> eframe::Result<()> { - let options = eframe::NativeOptions { - renderer: eframe::Renderer::Wgpu, +pub(crate) fn native_options() -> eframe::NativeOptions { + eframe::NativeOptions { + renderer: eframe::Renderer::Glow, ..Default::default() - }; + } +} + +pub fn run() -> eframe::Result<()> { eframe::run_native( WINDOW_TITLE, - options, + native_options(), Box::new(|_cc| Ok(Box::new(OpenVistaProApp::new()))), ) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn native_options_prefer_glow_renderer_for_the_interactive_shell() { + assert_eq!(native_options().renderer, eframe::Renderer::Glow); + } +} diff --git a/src/script.rs b/src/script.rs index 23e3bd1..738a60a 100644 --- a/src/script.rs +++ b/src/script.rs @@ -18,7 +18,7 @@ //! Supported commands: //! //! ```text -//! use preset # is `hill` or `plane` +//! use preset # is `fractal`, `hill`, or `plane` //! set thresholds water= tree= snow= //! import heightmap "" //! render output "" @@ -46,6 +46,8 @@ pub struct Script { /// A built-in terrain preset selectable via `use preset `. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PresetName { + /// Seeded procedural fractal terrain. + Fractal, /// A single rolling hill. Hill, /// A flat plane. @@ -182,6 +184,7 @@ fn parse_use(args: &[String], line: usize) -> Result { expect_keyword(args.first(), "preset", line, "use")?; let name = expect_arg(args.get(1), line, "use preset", "a preset name")?; let preset = match name.as_str() { + "fractal" => PresetName::Fractal, "hill" => PresetName::Hill, "plane" => PresetName::Plane, other => return Err(ParseError::new(line, format!("unknown preset {other:?}"))), @@ -278,6 +281,7 @@ mod tests { fn parses_happy_path_script_into_ast() { let script = r#" # OpenVistaPro project-owned script, not legacy VistaPro syntax + use preset fractal use preset hill set thresholds water=0.18 tree=0.42 snow=0.77 import heightmap "data/demo-height.png" @@ -289,6 +293,7 @@ mod tests { assert_eq!( ast.commands, vec![ + Command::UsePreset(PresetName::Fractal), Command::UsePreset(PresetName::Hill), Command::SetThresholds(SceneThresholds { water: 0.18, @@ -307,14 +312,14 @@ mod tests { #[test] fn ignores_comments_blank_lines_and_extra_whitespace() { - let script = "\n # comment\n\n\tuse preset plane \n render output \"out.png\" # trailing comment\n"; + let script = "\n # comment\n\n\tuse preset fractal \n render output \"out.png\" # trailing comment\n"; let ast = parse_script(script).unwrap(); assert_eq!( ast.commands, vec![ - Command::UsePreset(PresetName::Plane), + Command::UsePreset(PresetName::Fractal), Command::RenderOutput { path: "out.png".into() }, diff --git a/src/terrain_gen.rs b/src/terrain_gen.rs index f4fd9b1..cfabc3d 100644 --- a/src/terrain_gen.rs +++ b/src/terrain_gen.rs @@ -1,29 +1,104 @@ 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 DEFAULT_OCTAVE_COUNT: usize = 4; +const DEFAULT_BASE_FREQUENCY: f32 = 2.0; +const DEFAULT_LACUNARITY: f32 = 2.0; +const DEFAULT_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; +fn sanitize_positive(value: f32, fallback: f32) -> f32 { + if value.is_finite() && value > 0.0 { + value + } else { + fallback + } +} + +fn sanitize_gain(value: f32) -> f32 { + if value.is_finite() { + value.clamp(0.0, 1.0) + } else { + DEFAULT_GAIN + } +} + +/// Tunable fractal-noise controls for procedural terrain generation. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct TerrainGenerationSettings { + octave_count: usize, + base_frequency: f32, + lacunarity: f32, + gain: f32, +} + +impl TerrainGenerationSettings { + /// Build a settings bundle, normalizing obviously invalid inputs. + pub fn new(octave_count: usize, base_frequency: f32, lacunarity: f32, gain: f32) -> Self { + Self { + octave_count: octave_count.max(1), + base_frequency: sanitize_positive(base_frequency, DEFAULT_BASE_FREQUENCY), + lacunarity: sanitize_positive(lacunarity, DEFAULT_LACUNARITY), + gain: sanitize_gain(gain), + } + } + + pub fn octave_count(&self) -> usize { + self.octave_count + } + + pub fn base_frequency(&self) -> f32 { + self.base_frequency + } + + pub fn lacunarity(&self) -> f32 { + self.lacunarity + } + + pub fn gain(&self) -> f32 { + self.gain + } +} + +impl Default for TerrainGenerationSettings { + fn default() -> Self { + Self::new( + DEFAULT_OCTAVE_COUNT, + DEFAULT_BASE_FREQUENCY, + DEFAULT_LACUNARITY, + DEFAULT_GAIN, + ) + } +} + /// 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)] +#[derive(Debug, Clone, Copy, PartialEq)] pub struct TerrainGenerationSpec { seed: u64, width: u32, height: u32, + settings: TerrainGenerationSettings, } impl TerrainGenerationSpec { /// Creates a spec, rejecting zero-sized grids with [`TerrainError::ZeroDimension`]. pub fn new(seed: u64, width: u32, height: u32) -> Result { + Self::with_settings(seed, width, height, TerrainGenerationSettings::default()) + } + + /// Creates a spec with explicit fractal settings. + pub fn with_settings( + seed: u64, + width: u32, + height: u32, + settings: TerrainGenerationSettings, + ) -> Result { if width == 0 || height == 0 { return Err(TerrainError::ZeroDimension); } @@ -31,9 +106,28 @@ impl TerrainGenerationSpec { seed, width, height, + settings, }) } + /// Convenience constructor for callers that want to override the default settings inline. + pub fn with_parameters( + seed: u64, + width: u32, + height: u32, + octave_count: usize, + base_frequency: f32, + lacunarity: f32, + gain: f32, + ) -> Result { + Self::with_settings( + seed, + width, + height, + TerrainGenerationSettings::new(octave_count, base_frequency, lacunarity, gain), + ) + } + pub fn seed(&self) -> u64 { self.seed } @@ -45,6 +139,10 @@ impl TerrainGenerationSpec { pub fn height(&self) -> u32 { self.height } + + pub fn settings(&self) -> TerrainGenerationSettings { + self.settings + } } /// Produces a [`HeightGrid`] from a [`TerrainGenerationSpec`]. @@ -116,18 +214,18 @@ fn value_noise(seed: u64, x: f32, y: f32) -> f32 { } #[inline] -fn fbm_height(seed: u64, x: f32, y: f32) -> f32 { +fn fbm_height(seed: u64, x: f32, y: f32, settings: TerrainGenerationSettings) -> f32 { let mut total = 0.0; let mut amplitude = 1.0; - let mut frequency = BASE_FREQUENCY; + let mut frequency = settings.base_frequency(); let mut amplitude_sum = 0.0; - for octave in 0..OCTAVE_COUNT { + for octave in 0..settings.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; + amplitude *= settings.gain(); + frequency *= settings.lacunarity(); } total / amplitude_sum @@ -142,7 +240,7 @@ impl TerrainGenerator for DeterministicTerrainGenerator { 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)); + samples.push(fbm_height(spec.seed(), nx, ny, spec.settings())); } } HeightGrid::new(width, height, samples) @@ -165,6 +263,28 @@ mod tests { ); } + #[test] + fn settings_normalize_invalid_inputs() { + let settings = TerrainGenerationSettings::new(0, -1.0, 0.0, 2.0); + assert_eq!(settings.octave_count(), 1); + assert_eq!(settings.base_frequency(), DEFAULT_BASE_FREQUENCY); + assert_eq!(settings.lacunarity(), DEFAULT_LACUNARITY); + assert_eq!(settings.gain(), 1.0); + } + + #[test] + fn with_parameters_exposes_custom_settings() { + let spec = TerrainGenerationSpec::with_parameters(7, 8, 6, 2, 1.5, 3.0, 0.25).unwrap(); + let settings = spec.settings(); + assert_eq!(spec.seed(), 7); + assert_eq!(spec.width(), 8); + assert_eq!(spec.height(), 6); + assert_eq!(settings.octave_count(), 2); + assert_eq!(settings.base_frequency(), 1.5); + assert_eq!(settings.lacunarity(), 3.0); + assert_eq!(settings.gain(), 0.25); + } + #[test] fn generate_matches_spec_dimensions() { let spec = TerrainGenerationSpec::new(99, 8, 5).unwrap(); @@ -185,4 +305,35 @@ mod tests { .unwrap(); assert_ne!(a.sample(0, 0), b.sample(0, 0)); } + + #[test] + fn generate_is_bounded_and_deterministic_for_exact_samples() { + let spec = TerrainGenerationSpec::new(1337, 4, 4).unwrap(); + let generator = DeterministicTerrainGenerator::new(); + let first = generator.generate(&spec).unwrap(); + let second = generator.generate(&spec).unwrap(); + + 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 in 0..4 { + for x in 0..4 { + let sample = first.sample(x, y).unwrap(); + assert!((0.0..=1.0).contains(&sample)); + assert_eq!(sample, second.sample(x, y).unwrap()); + assert!( + (sample - expected[y as usize][x as usize]).abs() < 1e-6, + "mismatch at ({x}, {y})" + ); + } + } + + let (min, max) = first.min_max().unwrap(); + assert!(min >= 0.0); + assert!(max <= 1.0); + } } diff --git a/tests/app_smoke.rs b/tests/app_smoke.rs new file mode 100644 index 0000000..9bade0d --- /dev/null +++ b/tests/app_smoke.rs @@ -0,0 +1,25 @@ +#![cfg(feature = "app")] + +use std::process::Command; + +#[test] +fn app_binary_starts_under_xvfb_without_panicking() { + if Command::new("xvfb-run").arg("--help").output().is_err() { + eprintln!("skipping app smoke test because xvfb-run is unavailable"); + return; + } + + let bin = env!("CARGO_BIN_EXE_openvistapro_app"); + let output = Command::new("xvfb-run") + .args(["-a", "timeout", "8s", bin]) + .output() + .expect("failed to launch xvfb-run for openvistapro_app"); + + assert_eq!( + output.status.code(), + Some(124), + "expected the app to start and then be interrupted by timeout; stdout={:?}; stderr={:?}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +}