forge-ibl-bake 0.1.0

Drop in an HDRI, get wgpu/Bevy-ready IBL textures. No Blender, no Substance, no C++ build.
Documentation
// SPDX-License-Identifier: MIT OR Apache-2.0
// forge-ibl-bake · deveraux.dev
//! Thin CLI front door for the forge-ibl-bake library.
//!
//! Parses `--input`, `--out`, and tuning flags, then delegates to `bake_all`.
//! All business logic lives in `lib.rs`; this file is < 100 lines of arg parsing.
//! PROVEN: graceful-failure test confirms missing/invalid args produce typed errors.

use std::path::{Path, PathBuf};
use std::process;

use forge_ibl_bake::{bake_all, hdr::{load_hdr_file, HdrImage}, BakeParams};

fn main() {
    match run() {
        Ok(()) => {}
        Err(e) => {
            eprintln!("forge-ibl-bake: error: {e}");
            process::exit(1);
        }
    }
}

fn run() -> Result<(), Box<dyn std::error::Error>> {
    let args: Vec<String> = std::env::args().collect();
    let cfg = parse_args(&args)?;

    eprintln!("forge-ibl-bake: loading {}", cfg.input.display());
    let env = load_input(&cfg.input)?;
    eprintln!("forge-ibl-bake: {}×{} loaded", env.width, env.height);

    eprintln!("forge-ibl-bake: baking → {}", cfg.out.display());
    bake_all(&env, &cfg.params, &cfg.out)?;

    eprintln!("forge-ibl-bake: done");
    Ok(())
}

struct Config {
    input: PathBuf,
    out: PathBuf,
    params: BakeParams,
}

fn parse_args(args: &[String]) -> Result<Config, Box<dyn std::error::Error>> {
    let mut input: Option<PathBuf> = None;
    let mut out: Option<PathBuf> = None;
    let mut params = BakeParams::default();

    let mut i = 1;
    while i < args.len() {
        match args[i].as_str() {
            "--input" | "-i" => {
                i += 1;
                input = Some(PathBuf::from(next_arg(args, i, "--input")?));
            }
            "--out" | "-o" => {
                i += 1;
                out = Some(PathBuf::from(next_arg(args, i, "--out")?));
            }
            "--specular-size" => {
                i += 1;
                params.specular_size = next_arg(args, i, "--specular-size")?.parse()?;
            }
            "--irradiance-size" => {
                i += 1;
                params.irradiance_size = next_arg(args, i, "--irradiance-size")?.parse()?;
            }
            "--lut-size" => {
                i += 1;
                params.lut_size = next_arg(args, i, "--lut-size")?.parse()?;
            }
            "--samples" => {
                i += 1;
                params.samples = next_arg(args, i, "--samples")?.parse()?;
            }
            "--sh" => {
                params.emit_sh = true;
            }
            "--format" => {
                i += 1;
                let fmt = next_arg(args, i, "--format")?;
                if fmt != "ktx2" {
                    return Err(format!("unsupported format '{}'; only ktx2 supported in v0", fmt).into());
                }
            }
            "--seed" => {
                i += 1;
                params.seed = next_arg(args, i, "--seed")?.parse()?;
                // Reserved: v0 bake is deterministic via Hammersley regardless of seed.
            }
            "--help" | "-h" => {
                print_usage();
                process::exit(0);
            }
            other => {
                return Err(format!("unknown flag: {other}\nRun with --help for usage.").into());
            }
        }
        i += 1;
    }

    for (name, val) in [
        ("--specular-size", params.specular_size),
        ("--irradiance-size", params.irradiance_size),
        ("--lut-size", params.lut_size),
    ] {
        if val > 4096 {
            return Err(format!("{name} {val} exceeds maximum of 4096").into());
        }
    }
    let input = input.ok_or("--input <path.hdr> is required")?;
    let out = out.unwrap_or_else(|| PathBuf::from("./baked"));
    Ok(Config { input, out, params })
}

fn next_arg<'a>(args: &'a [String], i: usize, flag: &str) -> Result<&'a str, String> {
    args.get(i)
        .map(|s| s.as_str())
        .ok_or_else(|| format!("{flag} requires a value"))
}

fn load_input(path: &Path) -> Result<HdrImage, Box<dyn std::error::Error>> {
    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
    if ext.eq_ignore_ascii_case("exr") {
        load_exr_input(path)
    } else {
        load_hdr_file(path).map_err(Into::into)
    }
}

#[cfg(feature = "exr")]
fn load_exr_input(path: &Path) -> Result<HdrImage, Box<dyn std::error::Error>> {
    forge_ibl_bake::hdr::load_exr_file(path).map_err(Into::into)
}

#[cfg(not(feature = "exr"))]
fn load_exr_input(path: &Path) -> Result<HdrImage, Box<dyn std::error::Error>> {
    Err(format!(
        "{}: EXR input requires building with --features exr",
        path.display()
    )
    .into())
}

fn print_usage() {
    println!(
        "forge-ibl-bake v{}

USAGE:
  forge-ibl-bake --input env.hdr --out ./baked/ [OPTIONS]

OPTIONS:
  --specular-size <N>    Cubemap face px for specular prefilter (default 256)
  --irradiance-size <N>  Cubemap face px for irradiance (default 32)
  --lut-size <N>         BRDF LUT dimension (default 128)
  --samples <N>          Monte-Carlo samples per texel (default 1024)
  --format ktx2          Output format (default ktx2)
  --sh                   Emit SH9 JSON instead of irradiance cubemap
  --seed <N>             Reserved (v0 bake is deterministic via Hammersley)
  --help                 Show this help",
        env!("CARGO_PKG_VERSION")
    );
}