use std::fs;
use oxideav_core::{Error, Result};
use oxideav_mesh3d::{Mesh3DEncoder, Mesh3DRegistry};
use crate::op::{GltfFormatChoice, Mesh3DOptions, StlFormatChoice};
const MESH3D_INPUT_EXTS: &[&str] = &["stl", "obj", "gltf", "glb", "usdz", "mtl", "fbx"];
const MESH3D_OUTPUT_EXTS: &[&str] = &["stl", "obj", "gltf", "glb", "mtl", "usdz"];
pub fn is_mesh3d_input(input: &str) -> bool {
matches_known_ext(input, MESH3D_INPUT_EXTS)
}
pub fn is_mesh3d_output(output: &str) -> bool {
matches_known_ext(output, MESH3D_OUTPUT_EXTS)
}
fn matches_known_ext(path: &str, set: &[&str]) -> bool {
let Some(ext) = ext_of(path) else {
return false;
};
let lc = ext.to_ascii_lowercase();
set.iter().any(|e| *e == lc)
}
fn ext_of(path: &str) -> Option<&str> {
let last = path.rsplit('/').next().unwrap_or(path);
let last = last.split('?').next().unwrap_or(last);
let dot = last.rfind('.')?;
Some(&last[dot + 1..])
}
pub fn run(input_path: &str, output_path: &str, options: &Mesh3DOptions) -> Result<()> {
let in_ext = ext_of(input_path)
.ok_or_else(|| {
Error::invalid(format!(
"convert: input '{input_path}' has no extension — cannot pick a 3D decoder"
))
})?
.to_ascii_lowercase();
let out_ext = ext_of(output_path)
.ok_or_else(|| {
Error::invalid(format!(
"convert: output '{output_path}' has no extension — cannot pick a 3D encoder"
))
})?
.to_ascii_lowercase();
let mut registry = Mesh3DRegistry::new();
populate_registry(&mut registry);
validate_options_against_output(&out_ext, options)?;
let mut decoder = registry.decoder_for_extension(&in_ext).ok_or_else(|| {
let hint = crate::suggest::format_hint(&in_ext, MESH3D_INPUT_EXTS);
Error::unsupported(format!(
"convert: no 3D decoder registered for input extension '.{in_ext}'{hint} (known: {})",
joined_known_inputs()
))
})?;
let mut encoder = pick_encoder(®istry, &out_ext, options)?;
let bytes = fs::read(input_path)
.map_err(|e| Error::invalid(format!("convert: failed to read {input_path}: {e}")))?;
let scene = decoder.decode(&bytes)?;
let out_bytes = encoder.encode(&scene)?;
fs::write(output_path, out_bytes)
.map_err(|e| Error::invalid(format!("convert: failed to write {output_path}: {e}")))?;
Ok(())
}
fn validate_options_against_output(out_ext: &str, options: &Mesh3DOptions) -> Result<()> {
if options.stl_format.is_some() && out_ext != "stl" {
return Err(Error::invalid(format!(
"convert: -stl-format set but output extension is '.{out_ext}', not '.stl'"
)));
}
if options.gltf_format.is_some() && out_ext != "gltf" && out_ext != "glb" {
return Err(Error::invalid(format!(
"convert: -gltf-format set but output extension is '.{out_ext}', not '.gltf' or '.glb'"
)));
}
Ok(())
}
fn pick_encoder(
registry: &Mesh3DRegistry,
out_ext: &str,
options: &Mesh3DOptions,
) -> Result<Box<dyn Mesh3DEncoder>> {
match out_ext {
"stl" => {
if let Some(choice) = options.stl_format {
return Ok(build_stl_encoder(choice));
}
}
"gltf" | "glb" => {
if let Some(choice) = options.gltf_format {
return build_gltf_encoder(choice);
}
}
_ => {}
}
registry.encoder_for_extension(out_ext).ok_or_else(|| {
let hint = crate::suggest::format_hint(out_ext, MESH3D_OUTPUT_EXTS);
Error::unsupported(format!(
"convert: no 3D encoder registered for output extension '.{out_ext}'{hint} (known: {})",
joined_known_outputs()
))
})
}
fn build_stl_encoder(choice: StlFormatChoice) -> Box<dyn Mesh3DEncoder> {
use oxideav_stl::encoder::{StlEncoder, StlFormat};
let format = match choice {
StlFormatChoice::Binary => StlFormat::Binary,
StlFormatChoice::Ascii => StlFormat::Ascii,
};
Box::new(StlEncoder::new(format))
}
fn build_gltf_encoder(choice: GltfFormatChoice) -> Result<Box<dyn Mesh3DEncoder>> {
use oxideav_gltf::{GltfEncoder, OutputFlavour};
let flavour = match choice {
GltfFormatChoice::Glb => OutputFlavour::Glb,
GltfFormatChoice::JsonEmbedded => OutputFlavour::JsonEmbedded,
GltfFormatChoice::JsonExternal => {
return Err(Error::unsupported(
"convert: -gltf-format external (JSON + sidecar .bin) is not yet wired (gltf-rN follow-up: OutputFlavour::JsonExternal not in published oxideav-gltf)"
.to_string(),
));
}
};
Ok(Box::new(GltfEncoder::with_output(flavour)))
}
pub(crate) fn populate_registry(registry: &mut Mesh3DRegistry) {
oxideav_meta::populate_mesh3d_registry(registry);
oxideav_fbx::register(registry);
}
fn joined_known_inputs() -> String {
MESH3D_INPUT_EXTS
.iter()
.map(|e| format!(".{e}"))
.collect::<Vec<_>>()
.join(", ")
}
fn joined_known_outputs() -> String {
MESH3D_OUTPUT_EXTS
.iter()
.map(|e| format!(".{e}"))
.collect::<Vec<_>>()
.join(", ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_mesh3d_extensions_case_insensitively() {
assert!(is_mesh3d_input("foo.stl"));
assert!(is_mesh3d_input("FOO.STL"));
assert!(is_mesh3d_input("path/to/cube.obj"));
assert!(is_mesh3d_input("scene.gltf"));
assert!(is_mesh3d_input("scene.GLB"));
assert!(is_mesh3d_input("archive.usdz"));
assert!(is_mesh3d_input("materials.mtl"));
assert!(!is_mesh3d_input("foo.png"));
assert!(!is_mesh3d_input("foo.pdf"));
assert!(!is_mesh3d_input("noext"));
}
#[test]
fn detects_mesh3d_output_extensions() {
assert!(is_mesh3d_output("out.stl"));
assert!(is_mesh3d_output("out.obj"));
assert!(is_mesh3d_output("out.gltf"));
assert!(is_mesh3d_output("out.glb"));
assert!(is_mesh3d_output("out.mtl"));
assert!(is_mesh3d_output("out.usdz"));
assert!(is_mesh3d_output("out.USDZ"));
assert!(!is_mesh3d_output("out.png"));
}
#[test]
fn unknown_input_extension_errors_with_known_set() {
let err = run(
"/tmp/does-not-exist.xyz",
"/tmp/out.stl",
&Mesh3DOptions::default(),
)
.unwrap_err();
let msg = format!("{err:?}");
assert!(
msg.contains("no 3D decoder registered for input extension '.xyz'"),
"message was: {msg}"
);
}
#[test]
fn unknown_output_extension_errors_with_known_set() {
let err = run(
"/tmp/does-not-exist.stl",
"/tmp/out.xyz",
&Mesh3DOptions::default(),
)
.unwrap_err();
let msg = format!("{err:?}");
assert!(
msg.contains("no 3D encoder registered for output extension '.xyz'"),
"message was: {msg}"
);
}
#[test]
fn unknown_input_extension_suggests_close_match() {
let err = run(
"/tmp/does-not-exist.gtlf",
"/tmp/out.stl",
&Mesh3DOptions::default(),
)
.unwrap_err();
let msg = format!("{err:?}");
assert!(
msg.contains("did you mean '.gltf'?"),
"expected did-you-mean hint, message was: {msg}"
);
}
#[test]
fn unknown_output_extension_suggests_close_match() {
let err = run(
"/tmp/does-not-exist.stl",
"/tmp/out.glft",
&Mesh3DOptions::default(),
)
.unwrap_err();
let msg = format!("{err:?}");
assert!(
msg.contains("did you mean '.gltf'?"),
"expected did-you-mean hint, message was: {msg}"
);
}
#[test]
fn unrelated_unknown_extension_no_hint() {
let err = run(
"/tmp/does-not-exist.png",
"/tmp/out.stl",
&Mesh3DOptions::default(),
)
.unwrap_err();
let msg = format!("{err:?}");
assert!(
!msg.contains("did you mean"),
"expected no hint, message was: {msg}"
);
}
}