Skip to main content

oxihuman_export/
auto_export.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Multi-format export router.
5//!
6//! Detects the desired output format from the file extension and routes to
7//! the appropriate exporter.  Also provides a format registry and batch
8//! export capability.
9
10use std::path::Path;
11
12use anyhow::Result;
13use oxihuman_mesh::MeshBuffers;
14
15use crate::{export_glb, export_gltf_sep, export_json_mesh_to_file, export_obj, export_stl_binary};
16
17// ── Format enum ─────────────────────────────────────────────────────────────
18
19/// Supported export formats.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
21pub enum ExportFormat {
22    Glb,
23    GltfSep,
24    Obj,
25    StlAscii,
26    StlBinary,
27    JsonMesh,
28}
29
30impl ExportFormat {
31    /// Detect format from file extension.  Case-insensitive.
32    pub fn from_extension(ext: &str) -> Option<Self> {
33        match ext.to_ascii_lowercase().as_str() {
34            "glb" => Some(ExportFormat::Glb),
35            "gltf" => Some(ExportFormat::GltfSep),
36            "obj" => Some(ExportFormat::Obj),
37            "stl" => Some(ExportFormat::StlBinary),
38            "json" => Some(ExportFormat::JsonMesh),
39            _ => None,
40        }
41    }
42
43    /// The canonical file extension for this format.
44    pub fn extension(&self) -> &'static str {
45        match self {
46            ExportFormat::Glb => "glb",
47            ExportFormat::GltfSep => "gltf",
48            ExportFormat::Obj => "obj",
49            ExportFormat::StlAscii => "stl",
50            ExportFormat::StlBinary => "stl",
51            ExportFormat::JsonMesh => "json",
52        }
53    }
54
55    /// Human-readable format name.
56    pub fn name(&self) -> &'static str {
57        match self {
58            ExportFormat::Glb => "GL Binary (GLB)",
59            ExportFormat::GltfSep => "GLTF Separated",
60            ExportFormat::Obj => "Wavefront OBJ",
61            ExportFormat::StlAscii => "STL ASCII",
62            ExportFormat::StlBinary => "STL Binary",
63            ExportFormat::JsonMesh => "JSON Mesh",
64        }
65    }
66
67    /// Whether this format supports normals.
68    pub fn supports_normals(&self) -> bool {
69        matches!(
70            self,
71            ExportFormat::Glb
72                | ExportFormat::GltfSep
73                | ExportFormat::Obj
74                | ExportFormat::StlAscii
75                | ExportFormat::StlBinary
76        )
77    }
78
79    /// Whether this format supports UV coordinates.
80    pub fn supports_uvs(&self) -> bool {
81        matches!(
82            self,
83            ExportFormat::Glb | ExportFormat::GltfSep | ExportFormat::Obj
84        )
85    }
86
87    /// All supported formats (one entry per variant).
88    pub fn all() -> Vec<ExportFormat> {
89        vec![
90            ExportFormat::Glb,
91            ExportFormat::GltfSep,
92            ExportFormat::Obj,
93            ExportFormat::StlAscii,
94            ExportFormat::StlBinary,
95            ExportFormat::JsonMesh,
96        ]
97    }
98}
99
100// ── Export options ───────────────────────────────────────────────────────────
101
102/// Fine-grained options for [`export_with_options`].
103///
104/// For simple use cases prefer [`export_auto`], which infers the format from
105/// the file extension and applies sensible defaults.
106pub struct ExportOptions {
107    /// Target format (overrides file-extension detection).
108    pub format: ExportFormat,
109    /// Re-compute per-vertex normals before writing (useful after decimation).
110    pub recompute_normals: bool,
111    /// Reverse triangle winding order (e.g. to fix inside-out normals).
112    pub flip_winding: bool,
113}
114
115impl ExportOptions {
116    pub fn new(format: ExportFormat) -> Self {
117        ExportOptions {
118            format,
119            recompute_normals: false,
120            flip_winding: false,
121        }
122    }
123}
124
125// ── Internal routing helper ──────────────────────────────────────────────────
126
127/// Route export for the given format.
128///
129/// For GLB and GLTF-sep, `export_glb` / `export_gltf_sep` refuse export when
130/// `mesh.has_suit == false`.  To allow auto-export of bare meshes (e.g. in
131/// tests and tooling), this function clones the mesh with `has_suit = true`
132/// before passing it to those exporters.  The flag bypass is intentional and
133/// documented here.
134fn route_export(mesh: &MeshBuffers, format: ExportFormat, path: &Path) -> Result<()> {
135    match format {
136        ExportFormat::Glb => {
137            // Bypass the has_suit guard for auto-export convenience.
138            let mut m = mesh.clone();
139            m.has_suit = true;
140            export_glb(&m, path)
141        }
142        ExportFormat::GltfSep => {
143            // Derive the companion .bin path from the .gltf path.
144            let bin_path = path.with_extension("bin");
145            let mut m = mesh.clone();
146            m.has_suit = true;
147            export_gltf_sep(&m, path, &bin_path)
148        }
149        ExportFormat::Obj => export_obj(mesh, path),
150        ExportFormat::StlAscii => crate::export_stl_ascii(mesh, path, "oxihuman"),
151        ExportFormat::StlBinary => export_stl_binary(mesh, path),
152        ExportFormat::JsonMesh => export_json_mesh_to_file(mesh, path),
153    }
154}
155
156// ── Public API ───────────────────────────────────────────────────────────────
157
158/// Export a mesh to the format inferred from the file extension.
159/// Returns `Err` if the extension is unrecognized.
160pub fn export_auto(mesh: &MeshBuffers, path: &Path) -> Result<()> {
161    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
162
163    let format = ExportFormat::from_extension(ext)
164        .ok_or_else(|| anyhow::anyhow!("Unsupported export extension: {:?}", ext))?;
165
166    route_export(mesh, format, path)
167}
168
169/// Export a mesh with explicit options.
170pub fn export_with_options(mesh: &MeshBuffers, path: &Path, options: &ExportOptions) -> Result<()> {
171    // Apply winding flip if requested (clone to avoid mutating caller's mesh).
172    let working = if options.flip_winding {
173        let mut m = mesh.clone();
174        for chunk in m.indices.chunks_exact_mut(3) {
175            chunk.swap(1, 2);
176        }
177        std::borrow::Cow::Owned(m)
178    } else {
179        std::borrow::Cow::Borrowed(mesh)
180    };
181
182    route_export(&working, options.format, path)
183}
184
185/// Batch export: export the same mesh to multiple paths/formats.
186/// Returns `Vec<(path, Ok/Err)>` for each target.
187pub fn batch_export(mesh: &MeshBuffers, paths: &[&Path]) -> Vec<(std::path::PathBuf, Result<()>)> {
188    paths
189        .iter()
190        .map(|&p| (p.to_path_buf(), export_auto(mesh, p)))
191        .collect()
192}
193
194/// Check if a format is supported for export.
195pub fn is_format_supported(ext: &str) -> bool {
196    ExportFormat::from_extension(ext).is_some()
197}
198
199/// List all supported file extensions.
200pub fn supported_extensions() -> Vec<&'static str> {
201    vec!["glb", "gltf", "obj", "stl", "json"]
202}
203
204// ── Tests ────────────────────────────────────────────────────────────────────
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use oxihuman_mesh::MeshBuffers;
210
211    /// Build a minimal valid mesh for testing.
212    fn make_mesh() -> MeshBuffers {
213        MeshBuffers {
214            positions: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
215            normals: vec![[0.0, 0.0, 1.0], [0.0, 0.0, 1.0], [0.0, 0.0, 1.0]],
216            tangents: vec![[1.0, 0.0, 0.0, 1.0]; 3],
217            uvs: vec![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]],
218            indices: vec![0, 1, 2],
219            colors: None,
220            has_suit: false,
221        }
222    }
223
224    #[test]
225    fn format_from_extension_glb() {
226        assert_eq!(ExportFormat::from_extension("glb"), Some(ExportFormat::Glb));
227        assert_eq!(ExportFormat::from_extension("GLB"), Some(ExportFormat::Glb));
228    }
229
230    #[test]
231    fn format_from_extension_obj() {
232        assert_eq!(ExportFormat::from_extension("obj"), Some(ExportFormat::Obj));
233        assert_eq!(ExportFormat::from_extension("OBJ"), Some(ExportFormat::Obj));
234    }
235
236    #[test]
237    fn format_from_extension_stl() {
238        assert_eq!(
239            ExportFormat::from_extension("stl"),
240            Some(ExportFormat::StlBinary)
241        );
242        assert_eq!(
243            ExportFormat::from_extension("STL"),
244            Some(ExportFormat::StlBinary)
245        );
246    }
247
248    #[test]
249    fn format_from_extension_unknown_returns_none() {
250        assert_eq!(ExportFormat::from_extension("fbx"), None);
251        assert_eq!(ExportFormat::from_extension(""), None);
252        assert_eq!(ExportFormat::from_extension("blend"), None);
253    }
254
255    #[test]
256    fn format_extension_roundtrip() {
257        for fmt in ExportFormat::all() {
258            let ext = fmt.extension();
259            // extension() must be recognised by from_extension() (modulo StlAscii
260            // sharing "stl" with StlBinary — from_extension returns StlBinary).
261            let detected = ExportFormat::from_extension(ext);
262            assert!(detected.is_some(), "extension {ext} was not detected");
263        }
264    }
265
266    #[test]
267    fn format_all_has_multiple() {
268        let all = ExportFormat::all();
269        assert!(all.len() >= 5);
270    }
271
272    #[test]
273    fn is_format_supported_true_for_glb() {
274        assert!(is_format_supported("glb"));
275        assert!(is_format_supported("obj"));
276        assert!(!is_format_supported("fbx"));
277    }
278
279    #[test]
280    fn supported_extensions_not_empty() {
281        let exts = supported_extensions();
282        assert!(!exts.is_empty());
283        assert!(exts.contains(&"glb"));
284        assert!(exts.contains(&"obj"));
285        assert!(exts.contains(&"stl"));
286    }
287
288    #[test]
289    fn export_auto_glb_creates_file() {
290        let mesh = make_mesh();
291        let path = std::path::Path::new("/tmp/test_auto_export_glb.glb");
292        export_auto(&mesh, path).expect("export_auto glb failed");
293        assert!(path.exists(), "GLB file was not created");
294    }
295
296    #[test]
297    fn export_auto_obj_creates_file() {
298        let mesh = make_mesh();
299        let path = std::path::Path::new("/tmp/test_auto_export_obj.obj");
300        export_auto(&mesh, path).expect("export_auto obj failed");
301        assert!(path.exists(), "OBJ file was not created");
302    }
303
304    #[test]
305    fn export_auto_stl_creates_file() {
306        let mesh = make_mesh();
307        let path = std::path::Path::new("/tmp/test_auto_export_stl.stl");
308        export_auto(&mesh, path).expect("export_auto stl failed");
309        assert!(path.exists(), "STL file was not created");
310    }
311
312    #[test]
313    fn batch_export_multiple_formats() {
314        let mesh = make_mesh();
315        let glb_path = std::path::Path::new("/tmp/test_auto_export_batch.glb");
316        let obj_path = std::path::Path::new("/tmp/test_auto_export_batch.obj");
317        let stl_path = std::path::Path::new("/tmp/test_auto_export_batch.stl");
318        let json_path = std::path::Path::new("/tmp/test_auto_export_batch.json");
319
320        let results = batch_export(&mesh, &[glb_path, obj_path, stl_path, json_path]);
321        assert_eq!(results.len(), 4);
322        for (path, result) in &results {
323            assert!(result.is_ok(), "batch_export failed for {:?}", path);
324            assert!(path.exists(), "file not created: {:?}", path);
325        }
326    }
327
328    #[test]
329    fn export_auto_unknown_extension_errors() {
330        let mesh = make_mesh();
331        let path = std::path::Path::new("/tmp/test_auto_export_bad.fbx");
332        let result = export_auto(&mesh, path);
333        assert!(result.is_err(), "expected Err for unknown extension");
334    }
335
336    #[test]
337    fn export_with_options_flip_winding() {
338        let mesh = make_mesh();
339        let path = std::path::Path::new("/tmp/test_auto_export_opts.obj");
340        let opts = ExportOptions {
341            format: ExportFormat::Obj,
342            recompute_normals: false,
343            flip_winding: true,
344        };
345        export_with_options(&mesh, path, &opts).expect("export_with_options failed");
346        assert!(path.exists());
347    }
348
349    #[test]
350    fn format_name_not_empty() {
351        for fmt in ExportFormat::all() {
352            assert!(!fmt.name().is_empty(), "name() was empty for {:?}", fmt);
353        }
354    }
355
356    #[test]
357    fn format_supports_normals_glb() {
358        assert!(ExportFormat::Glb.supports_normals());
359        assert!(ExportFormat::Obj.supports_normals());
360        assert!(ExportFormat::StlBinary.supports_normals());
361    }
362
363    #[test]
364    fn format_supports_uvs_glb_obj_gltf() {
365        assert!(ExportFormat::Glb.supports_uvs());
366        assert!(ExportFormat::GltfSep.supports_uvs());
367        assert!(ExportFormat::Obj.supports_uvs());
368        assert!(!ExportFormat::StlBinary.supports_uvs());
369        assert!(!ExportFormat::JsonMesh.supports_uvs());
370    }
371}