scena 1.0.0

A Rust-native scene-graph renderer with typed scene state, glTF assets, and explicit prepare/render lifecycles.
Documentation
use std::env;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::process::{self, Command};

#[derive(Debug, Clone, PartialEq, Eq)]
struct ConvertOptions {
    input: PathBuf,
    output: PathBuf,
    tool: String,
    dry_run: bool,
}

fn main() {
    match run(env::args().skip(1).collect()) {
        Ok(()) => {}
        Err(error) => {
            eprintln!("{error}");
            process::exit(2);
        }
    }
}

fn run(args: Vec<String>) -> Result<(), String> {
    if args.is_empty() || args.iter().any(|arg| arg == "--help" || arg == "-h") {
        print_help();
        return Ok(());
    }

    let options = parse_options(args)?;
    validate_extensions(&options)?;

    let input = options.input.to_string_lossy();
    let output = options.output.to_string_lossy();
    let command = [
        options.tool.as_str(),
        "--input",
        &input,
        "--output",
        &output,
    ];

    if options.dry_run {
        println!(
            "{{\"status\":\"planned\",\"workflow\":\"FBX to glTF\",\"tool\":\"{}\",\"input\":\"{}\",\"output\":\"{}\",\"command\":[\"{}\",\"{}\",\"{}\",\"{}\",\"{}\"]}}",
            json_escape(&options.tool),
            json_escape(&options.input.to_string_lossy()),
            json_escape(&options.output.to_string_lossy()),
            json_escape(command[0]),
            json_escape(command[1]),
            json_escape(command[2]),
            json_escape(command[3]),
            json_escape(command[4]),
        );
        return Ok(());
    }

    let status = Command::new(&options.tool)
        .arg("--input")
        .arg(&options.input)
        .arg("--output")
        .arg(&options.output)
        .status()
        .map_err(|error| {
            format!(
                "failed to start {}: {error}; install FBX2glTF or pass --tool <converter>",
                options.tool
            )
        })?;

    if status.success() {
        Ok(())
    } else {
        Err(format!(
            "{} exited with status {status}; inspect converter diagnostics",
            options.tool
        ))
    }
}

fn parse_options(args: Vec<String>) -> Result<ConvertOptions, String> {
    let mut input = None;
    let mut output = None;
    let mut tool = "FBX2glTF".to_string();
    let mut dry_run = false;
    let mut args = args.into_iter();

    while let Some(arg) = args.next() {
        match arg.as_str() {
            "--input" | "-i" => input = args.next().map(PathBuf::from),
            "--output" | "-o" => output = args.next().map(PathBuf::from),
            "--tool" => {
                tool = args
                    .next()
                    .filter(|value| !value.is_empty())
                    .ok_or("--tool requires a converter executable".to_string())?;
            }
            "--dry-run" => dry_run = true,
            other => {
                return Err(format!(
                    "unknown argument '{other}'; run scena-convert --help"
                ));
            }
        }
    }

    Ok(ConvertOptions {
        input: input.ok_or("--input <file.fbx> is required".to_string())?,
        output: output.ok_or("--output <file.gltf|file.glb> is required".to_string())?,
        tool,
        dry_run,
    })
}

fn validate_extensions(options: &ConvertOptions) -> Result<(), String> {
    if !has_extension(&options.input, "fbx") {
        return Err(format!(
            "input must be an FBX file, got {}",
            options.input.display()
        ));
    }
    if !(has_extension(&options.output, "gltf") || has_extension(&options.output, "glb")) {
        return Err(format!(
            "output must end in .gltf or .glb, got {}",
            options.output.display()
        ));
    }
    Ok(())
}

fn has_extension(path: &Path, expected: &str) -> bool {
    path.extension()
        .and_then(OsStr::to_str)
        .is_some_and(|extension| extension.eq_ignore_ascii_case(expected))
}

fn json_escape(value: &str) -> String {
    value.replace('\\', "\\\\").replace('"', "\\\"")
}

fn print_help() {
    println!(
        "scena-convert\n\nPlans or runs the FBX to glTF/GLB asset-conversion workflow.\n\nUsage:\n  scena-convert --input model.fbx --output model.glb [--tool FBX2glTF] [--dry-run]\n\nThe command delegates actual conversion to FBX2glTF or a compatible converter. Use --dry-run in CI to verify the workflow without requiring the external tool."
    );
}