use catwalk3d::{
Result, formats,
render::{RenderOptions, write_png},
thumbnail,
};
use clap::Parser;
use std::{io::Write, path::PathBuf};
#[derive(Parser)]
#[command(
name = "catwalk",
version,
about = "Render model preview thumbnails entirely in Rust, without a GPU or display server"
)]
struct Args {
input: Option<PathBuf>,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(short, long, default_value_t = 512, value_parser = clap::value_parser!(u32).range(1..=4096))]
size: u32,
#[arg(long)]
resolution: Option<String>,
#[arg(long, alias = "no-background")]
transparent: bool,
#[arg(long, default_value = "f5f7fa", value_parser = parse_color)]
background: [u8; 3],
#[arg(long, default_value = "64a0dc", value_parser = parse_color)]
color: [u8; 3],
#[arg(long, default_value_t = 2, value_parser = clap::value_parser!(u32).range(1..=4))]
samples: u32,
#[command(flatten)]
preview_mode: PreviewMode,
#[arg(long, alias = "list-readers")]
list_formats: bool,
}
#[derive(clap::Args)]
struct PreviewMode {
#[arg(long, conflicts_with_all = ["size", "resolution", "transparent", "background", "color", "samples"])]
extract_saved_preview: bool,
#[arg(long, conflicts_with = "extract_saved_preview")]
render_geometry: bool,
}
fn parse_color(value: &str) -> std::result::Result<[u8; 3], String> {
let hex = value.strip_prefix('#').unwrap_or(value);
if hex.len() != 6 || !hex.is_ascii() {
return Err("color must have six hexadecimal digits".into());
}
let parse = |offset| {
u8::from_str_radix(&hex[offset..offset + 2], 16)
.map_err(|_| "color must have six hexadecimal digits".to_owned())
};
Ok([parse(0)?, parse(2)?, parse(4)?])
}
fn dimensions(args: &Args) -> Result<(u32, u32)> {
let Some(value) = &args.resolution else {
return Ok((args.size, args.size));
};
let (width, height) = value
.split_once([',', 'x'])
.ok_or("resolution must be WIDTH,HEIGHT or WIDTHxHEIGHT")?;
let pair: (u32, u32) = (width.parse()?, height.parse()?);
if pair.0 == 0 || pair.1 == 0 || pair.0 > 4096 || pair.1 > 4096 {
return Err("resolution dimensions must be between 1 and 4096".into());
}
Ok(pair)
}
fn run(args: &Args) -> Result<()> {
if args.list_formats {
println!(
"Implemented model readers: {}",
formats::MODEL_EXTENSIONS.join(", ")
);
println!(
"Implemented image readers: {}",
formats::IMAGE_EXTENSIONS.join(", ")
);
println!(
"Saved-preview readers: {}",
catwalk3d::saved_preview::EXTENSIONS.join(", ")
);
println!(
"Native document geometry readers (qualified subsets): {}",
formats::NATIVE_GEOMETRY_EXTENSIONS.join(", ")
);
println!(
"Fixed-view surface/point/line and image previews. Readers support documented subsets."
);
println!("Full importer coverage and limitations: docs/format-coverage.md");
return Ok(());
}
let input = args
.input
.as_ref()
.ok_or("provide an input model or use --list-formats")?;
let output = args
.output
.as_ref()
.ok_or("provide --output thumbnail.png (or --output -)")?;
if output != &PathBuf::from("-")
&& input.exists()
&& output.exists()
&& input.canonicalize()? == output.canonicalize()?
{
return Err("input and output must be different files".into());
}
let mut bytes = Vec::new();
if args.preview_mode.extract_saved_preview {
if matches!(formats::extension(input)?.as_str(), "ipt" | "iam") {
bytes = catwalk3d::saved_preview::inventor::extract_png(input)?
.ok_or(catwalk3d::saved_preview::NoSavedPreview)?;
} else {
let image = catwalk3d::saved_preview::extract(input)?
.ok_or(catwalk3d::saved_preview::NoSavedPreview)?;
write_png(&image, &mut bytes)?;
}
} else {
let (width, height) = dimensions(args)?;
let options = RenderOptions {
width,
height,
transparent: args.transparent,
background: args.background,
color: args.color,
supersampling: args.samples,
};
let image = if args.preview_mode.render_geometry {
catwalk3d::geometry_thumbnail(input, &options)?
} else {
thumbnail(input, &options)?
};
write_png(&image, &mut bytes)?;
}
if output == &PathBuf::from("-") {
let stdout = std::io::stdout();
let mut writer = std::io::BufWriter::new(stdout.lock());
writer.write_all(&bytes)?;
writer.flush()?;
} else {
std::fs::write(output, bytes)?;
}
Ok(())
}
fn main() -> std::process::ExitCode {
let args = Args::parse();
match run(&args) {
Ok(()) => std::process::ExitCode::SUCCESS,
Err(error) => {
eprintln!("catwalk: {error}");
if error.is::<catwalk3d::saved_preview::NoSavedPreview>() {
std::process::ExitCode::from(3)
} else {
std::process::ExitCode::FAILURE
}
}
}
}