feat: execute render scripts from CLI #7
@@ -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
@@ -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");
|
||||
|
||||
@@ -8,4 +8,5 @@ pub mod render;
|
||||
pub mod scene;
|
||||
pub mod scene_file;
|
||||
pub mod script;
|
||||
pub mod script_exec;
|
||||
pub mod terrain;
|
||||
|
||||
@@ -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(_)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user