diff --git a/examples/demo.ovps b/examples/demo.ovps new file mode 100644 index 0000000..8ecb16c --- /dev/null +++ b/examples/demo.ovps @@ -0,0 +1,13 @@ +# OpenVistaPro demo render script. +# +# Project-owned scripting syntax (see src/script.rs) — not legacy VistaPro. +# Run it with: +# +# cargo run --bin openvistapro -- script run --input examples/demo.ovps +# +# Paths are resolved relative to this script's directory, so the render is +# written next to this file as examples/demo-render.png. + +use preset hill +set thresholds water=1.0 tree=4.0 snow=7.0 +render output "demo-render.png" diff --git a/src/cli.rs b/src/cli.rs index 8ca872a..a730b58 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -6,6 +6,7 @@ use image::ImageError; use crate::render::{demo_camera_for, render_perspective_to_path, render_top_down_to_path}; use crate::scene::Scene; use crate::scene_file::{self, SceneFileError}; +use crate::script_exec::{self, ScriptError}; use crate::terrain::{HeightGrid, TerrainError}; const HILL_PEAK_HEIGHT: f32 = 10.0; @@ -29,6 +30,8 @@ pub enum Command { Render(RenderArgs), /// Work with OpenVistaPro scene files. Scene(SceneArgs), + /// Work with OpenVistaPro render scripts. + Script(ScriptArgs), } #[derive(Debug, Clone, Args)] @@ -73,6 +76,25 @@ pub struct ExportArgs { pub output: PathBuf, } +#[derive(Debug, Clone, Args)] +pub struct ScriptArgs { + #[command(subcommand)] + pub action: ScriptAction, +} + +#[derive(Debug, Clone, Subcommand)] +pub enum ScriptAction { + /// Parse and execute a script file, writing each `render output` to disk. + Run(ScriptRunArgs), +} + +#[derive(Debug, Clone, Args)] +pub struct ScriptRunArgs { + /// Path to the OpenVistaPro script file to execute. + #[arg(long)] + pub input: PathBuf, +} + #[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)] pub enum Preset { Plane, @@ -84,6 +106,7 @@ pub enum CliError { Terrain(TerrainError), Image(ImageError), SceneFile(SceneFileError), + Script(ScriptError), } impl std::fmt::Display for CliError { @@ -92,6 +115,7 @@ impl std::fmt::Display for CliError { CliError::Terrain(e) => write!(f, "terrain error: {e}"), CliError::Image(e) => write!(f, "image error: {e}"), CliError::SceneFile(e) => write!(f, "scene file error: {e}"), + CliError::Script(e) => write!(f, "{e}"), } } } @@ -116,6 +140,12 @@ impl From for CliError { } } +impl From for CliError { + fn from(e: ScriptError) -> Self { + CliError::Script(e) + } +} + pub fn supported_presets() -> &'static [&'static str] { &["plane", "hill"] } @@ -123,11 +153,11 @@ pub fn supported_presets() -> &'static [&'static str] { pub fn supported_importers() -> &'static [&'static str] { #[cfg(feature = "hgt")] { - &["hgt"] + &["heightmap", "hgt"] } #[cfg(not(feature = "hgt"))] { - &[] + &["heightmap"] } } @@ -177,6 +207,15 @@ pub fn execute(cli: Cli) -> Result<(), CliError> { Ok(()) } }, + Command::Script(args) => match args.action { + ScriptAction::Run(run) => { + let report = script_exec::run_script_file(&run.input)?; + for output in &report.outputs { + println!("rendered {}", output.display()); + } + Ok(()) + } + }, } } @@ -264,9 +303,8 @@ mod tests { } #[test] - #[cfg(not(feature = "hgt"))] - fn supported_importers_is_empty_for_now() { - assert!(supported_importers().is_empty()); + fn supported_importers_lists_heightmap() { + assert!(supported_importers().contains(&"heightmap")); } #[test] @@ -670,6 +708,83 @@ mod tests { ); } + #[test] + fn parses_script_run_subcommand() { + let cli = Cli::try_parse_from(["openvistapro", "script", "run", "--input", "demo.ovps"]) + .expect("script run should parse"); + match cli.command { + Command::Script(ScriptArgs { + action: ScriptAction::Run(ScriptRunArgs { input }), + }) => { + assert_eq!(input, PathBuf::from("demo.ovps")); + } + _ => panic!("expected script run subcommand"), + } + } + + #[test] + fn script_run_requires_input_flag() { + let err = Cli::try_parse_from(["openvistapro", "script", "run"]); + assert!(err.is_err(), "script run must require --input"); + } + + fn temp_script_dir(tag: &str) -> PathBuf { + let mut dir = std::env::temp_dir(); + dir.push(format!( + "openvistapro-cli-script-{}-{}", + tag, + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("create temp script dir"); + dir + } + + #[test] + fn execute_script_run_renders_png_output() { + let dir = temp_script_dir("run"); + let script_path = dir.join("run.ovps"); + std::fs::write( + &script_path, + "use preset hill\nset thresholds water=1.0 tree=4.0 snow=7.0\nrender output \"cli-demo.png\"", + ) + .unwrap(); + let cli = Cli::try_parse_from([ + "openvistapro", + "script", + "run", + "--input", + script_path.to_str().unwrap(), + ]) + .unwrap(); + execute(cli).expect("script run should succeed"); + let bytes = std::fs::read(dir.join("cli-demo.png")).expect("rendered png should exist"); + assert!(bytes.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A])); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn execute_script_run_reports_missing_file() { + let missing = std::env::temp_dir().join(format!( + "openvistapro-cli-missing-script-{}.ovps", + std::process::id() + )); + let _ = std::fs::remove_file(&missing); + let cli = Cli::try_parse_from([ + "openvistapro", + "script", + "run", + "--input", + missing.to_str().unwrap(), + ]) + .unwrap(); + let err = execute(cli).expect_err("missing script file should error"); + assert!( + matches!(err, CliError::Script(_)), + "expected CliError::Script, got: {err:?}" + ); + } + #[test] fn execute_render_rejects_zero_dimensions() { let path = temp_output_path("zero"); diff --git a/src/lib.rs b/src/lib.rs index 3091f09..8fc1755 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,4 +8,5 @@ pub mod render; pub mod scene; pub mod scene_file; pub mod script; +pub mod script_exec; pub mod terrain; diff --git a/src/script_exec.rs b/src/script_exec.rs new file mode 100644 index 0000000..7dcee7b --- /dev/null +++ b/src/script_exec.rs @@ -0,0 +1,312 @@ +//! Executes a parsed OpenVistaPro [`Script`] into rendered PNG files. +//! +//! The parser in [`crate::script`] turns script text into a [`Script`] AST but +//! deliberately does no I/O. This module is the integration slice that *runs* +//! that AST: it walks the commands in order, building a [`HeightGrid`] from +//! presets or imported heightmaps, adjusting the [`Scene`], and writing each +//! `render output` command to a PNG on disk. +//! +//! Execution is a small linear interpreter with no nesting: +//! +//! - `use preset ` replaces the current terrain with a built-in preset. +//! - `import heightmap ""` replaces the terrain with a grayscale PNG. +//! - `set thresholds ...` updates the active scene's elevation bands. +//! - `render output ""` writes the current terrain + scene to a PNG. +//! +//! Relative paths in a script are resolved against a caller-supplied base +//! directory (for [`run_script_file`], the directory containing the script). + +use std::io; +use std::path::{Path, PathBuf}; + +use image::ImageError; + +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}; + +/// Edge length of the terrain grid generated for `use preset` commands. +const PRESET_SIZE: u32 = 64; +/// Peak elevation of the `hill` preset, matching the CLI `render` default. +const HILL_PEAK_HEIGHT: f32 = 10.0; +/// Elevation that a fully white (255) heightmap pixel maps to. +const HEIGHTMAP_PEAK_HEIGHT: f32 = 10.0; + +/// Summary of a successful script run. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ExecReport { + /// Absolute-or-base-relative paths written by each `render output` command, + /// in source order. + pub outputs: Vec, +} + +/// An error produced while loading or executing a script. +#[derive(Debug)] +pub enum ScriptError { + /// Failed to read the script file or create an output directory. + Io(io::Error), + /// The script text could not be parsed. + Parse(ParseError), + /// Building a terrain grid failed. + Terrain(TerrainError), + /// Decoding a heightmap or writing a render failed. + Image(ImageError), + /// A `render output` command ran before any terrain was established. + 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::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 for ScriptError { + fn from(e: ParseError) -> Self { + ScriptError::Parse(e) + } +} + +impl From for ScriptError { + fn from(e: TerrainError) -> Self { + ScriptError::Terrain(e) + } +} + +impl From for ScriptError { + fn from(e: ImageError) -> Self { + ScriptError::Image(e) + } +} + +/// Read, parse, and execute the OpenVistaPro script file at `path`. +/// +/// Relative paths inside the script are resolved against the script file's +/// own directory, so a script and its assets can move together. +pub fn run_script_file(path: &Path) -> Result { + let source = std::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) +} + +/// Execute an already-parsed [`Script`], resolving relative paths against +/// `base_dir`. +pub fn run_script(script: &Script, base_dir: &Path) -> Result { + let mut scene = Scene::default(); + let mut grid: Option = 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() { + std::fs::create_dir_all(parent).map_err(ScriptError::Io)?; + } + } + render_top_down_to_path(grid, &scene, &output)?; + report.outputs.push(output); + } + } + } + + Ok(report) +} + +/// Load a grayscale PNG as a [`HeightGrid`], mapping pixel luma 0..=255 onto +/// elevations 0.0..=[`HEIGHTMAP_PEAK_HEIGHT`]. +fn load_heightmap(path: &Path) -> Result { + let image = image::open(path)?.to_luma8(); + let width = image.width(); + let height = image.height(); + let samples = image + .pixels() + .map(|pixel| pixel[0] as f32 / 255.0 * HEIGHTMAP_PEAK_HEIGHT) + .collect(); + Ok(HeightGrid::new(width, height, samples)?) +} + +/// Resolve a script-relative path against `base_dir`; absolute paths pass through. +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("no-terrain"); + let script = parse_script("render output \"x.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_thresholds_change_render_output() { + // Flooding the scene above the hill peak forces an all-water render + // that must differ from a normally-banded render of the same terrain. + let dir = temp_dir("thresholds"); + let flooded = parse_script( + "use preset hill\nset thresholds water=100.0 tree=101.0 snow=102.0\nrender output \"flooded.png\"", + ) + .unwrap(); + let dry = parse_script( + "use preset hill\nset thresholds water=1.0 tree=4.0 snow=7.0\nrender output \"dry.png\"", + ) + .unwrap(); + let mut a = run_script(&flooded, &dir).unwrap(); + let mut b = run_script(&dry, &dir).unwrap(); + let flooded_png = std::fs::read(a.outputs.remove(0)).unwrap(); + let dry_png = std::fs::read(b.outputs.remove(0)).unwrap(); + assert_ne!( + flooded_png, dry_png, + "set thresholds should change the rendered output" + ); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn run_script_imports_heightmap_and_renders() { + let dir = temp_dir("import"); + // Write a tiny 4x4 grayscale PNG to import as terrain. + let mut heightmap = image::GrayImage::new(4, 4); + for (i, pixel) in heightmap.pixels_mut().enumerate() { + *pixel = image::Luma([(i as u32 * 16) as u8]); + } + heightmap.save(dir.join("height.png")).unwrap(); + + let script = + parse_script("import heightmap \"height.png\"\nrender output \"imported.png\"") + .unwrap(); + let report = run_script(&script, &dir).expect("import + render should succeed"); + let rendered = image::open(&report.outputs[0]) + .expect("rendered file should be readable") + .to_rgb8(); + assert_eq!(rendered.width(), 4, "render should match heightmap width"); + assert_eq!(rendered.height(), 4, "render should match heightmap height"); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn run_script_file_reads_parses_and_executes() { + let dir = temp_dir("file"); + let script_path = dir.join("demo.ovps"); + std::fs::write( + &script_path, + "use preset hill\nrender output \"file-demo.png\"", + ) + .unwrap(); + let report = run_script_file(&script_path).expect("run_script_file should succeed"); + assert_eq!(report.outputs.len(), 1); + assert!( + report.outputs[0].exists(), + "render output should be written next to the script" + ); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn run_script_file_reports_parse_errors() { + let dir = temp_dir("parse-err"); + let script_path = dir.join("bad.ovps"); + std::fs::write(&script_path, "spin camera 90").unwrap(); + let err = run_script_file(&script_path).expect_err("invalid script must fail"); + assert!(matches!(err, ScriptError::Parse(_))); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn run_script_file_reports_missing_file() { + let missing = std::env::temp_dir().join(format!( + "openvistapro-script-missing-{}.ovps", + std::process::id() + )); + let _ = std::fs::remove_file(&missing); + let err = run_script_file(&missing).expect_err("missing script must fail"); + assert!(matches!(err, ScriptError::Io(_))); + } +}