feat: execute render scripts from CLI #7

Merged
moldybits merged 1 commits from feat/script-run-cli into main 2026-05-16 10:43:17 -04:00
4 changed files with 446 additions and 5 deletions
+13
View File
@@ -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"
+120 -5
View File
@@ -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<SceneFileError> for CliError {
}
}
impl From<ScriptError> 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");
+1
View File
@@ -8,4 +8,5 @@ pub mod render;
pub mod scene;
pub mod scene_file;
pub mod script;
pub mod script_exec;
pub mod terrain;
+312
View File
@@ -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 <name>` replaces the current terrain with a built-in preset.
//! - `import heightmap "<path>"` replaces the terrain with a grayscale PNG.
//! - `set thresholds ...` updates the active scene's elevation bands.
//! - `render output "<path>"` 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<PathBuf>,
}
/// 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<ParseError> for ScriptError {
fn from(e: ParseError) -> Self {
ScriptError::Parse(e)
}
}
impl From<TerrainError> for ScriptError {
fn from(e: TerrainError) -> Self {
ScriptError::Terrain(e)
}
}
impl From<ImageError> for ScriptError {
fn from(e: ImageError) -> Self {
ScriptError::Image(e)
}
}
/// Read, parse, and execute 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<ExecReport, ScriptError> {
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<ExecReport, ScriptError> {
let mut scene = Scene::default();
let mut grid: Option<HeightGrid> = None;
let mut report = ExecReport::default();
for command in &script.commands {
match command {
Command::UsePreset(PresetName::Hill) => {
grid = Some(HeightGrid::radial_hill(
PRESET_SIZE,
PRESET_SIZE,
HILL_PEAK_HEIGHT,
)?);
}
Command::UsePreset(PresetName::Plane) => {
grid = Some(HeightGrid::plane(PRESET_SIZE, PRESET_SIZE)?);
}
Command::SetThresholds(thresholds) => {
scene.water_level = thresholds.water;
scene.tree_line = thresholds.tree;
scene.snow_line = thresholds.snow;
}
Command::ImportHeightmap { path } => {
grid = Some(load_heightmap(&resolve(base_dir, path))?);
}
Command::RenderOutput { path } => {
let grid = grid.as_ref().ok_or(ScriptError::RenderWithoutTerrain)?;
let output = resolve(base_dir, path);
if let Some(parent) = output.parent() {
if !parent.as_os_str().is_empty() {
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<HeightGrid, ScriptError> {
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(_)));
}
}