Skip to main content

oxihuman_export/
obj.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Wavefront OBJ exporter for OxiHuman meshes.
5
6use anyhow::Result;
7use oxihuman_mesh::mesh::MeshBuffers;
8use std::fmt::Write as FmtWrite;
9use std::path::Path;
10
11/// Export a MeshBuffers to a Wavefront OBJ text file.
12/// Does NOT require `has_suit` (OBJ is for debugging/tools, not final export).
13pub fn export_obj(mesh: &MeshBuffers, path: &Path) -> Result<()> {
14    let content = mesh_to_obj_string(mesh)?;
15    std::fs::write(path, content)?;
16    Ok(())
17}
18
19/// Convert a MeshBuffers to OBJ format string.
20pub fn mesh_to_obj_string(mesh: &MeshBuffers) -> Result<String> {
21    let mut out = String::new();
22    writeln!(out, "# OxiHuman exported mesh")?;
23    writeln!(out, "# Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)")?;
24    writeln!(out, "# Vertices: {}", mesh.positions.len())?;
25    writeln!(out, "# Faces: {}", mesh.indices.len() / 3)?;
26    writeln!(out)?;
27
28    // Vertex positions
29    for p in &mesh.positions {
30        writeln!(out, "v {:.6} {:.6} {:.6}", p[0], p[1], p[2])?;
31    }
32    writeln!(out)?;
33
34    // UV coordinates
35    for uv in &mesh.uvs {
36        writeln!(out, "vt {:.6} {:.6}", uv[0], uv[1])?;
37    }
38    writeln!(out)?;
39
40    // Vertex normals
41    for n in &mesh.normals {
42        writeln!(out, "vn {:.6} {:.6} {:.6}", n[0], n[1], n[2])?;
43    }
44    writeln!(out)?;
45
46    // Faces (OBJ is 1-indexed; format: v/vt/vn)
47    let has_uvs = !mesh.uvs.is_empty();
48    let has_norms = !mesh.normals.is_empty();
49    for tri in mesh.indices.chunks_exact(3) {
50        let (i0, i1, i2) = (tri[0] + 1, tri[1] + 1, tri[2] + 1); // 1-indexed
51        if has_uvs && has_norms {
52            writeln!(out, "f {0}/{0}/{0} {1}/{1}/{1} {2}/{2}/{2}", i0, i1, i2)?;
53        } else if has_uvs {
54            writeln!(out, "f {0}/{0} {1}/{1} {2}/{2}", i0, i1, i2)?;
55        } else {
56            writeln!(out, "f {} {} {}", i0, i1, i2)?;
57        }
58    }
59
60    Ok(out)
61}
62
63#[cfg(test)]
64mod tests {
65    use super::*;
66    use oxihuman_mesh::mesh::MeshBuffers;
67    use oxihuman_morph::engine::MeshBuffers as MB;
68
69    fn triangle_mesh() -> MeshBuffers {
70        MeshBuffers::from_morph(MB {
71            positions: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
72            normals: vec![[0.0, 0.0, 1.0]; 3],
73            uvs: vec![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]],
74            indices: vec![0, 1, 2],
75            has_suit: false,
76        })
77    }
78
79    #[test]
80    fn obj_string_has_vertex_lines() {
81        let m = triangle_mesh();
82        let s = mesh_to_obj_string(&m).expect("should succeed");
83        assert!(s.contains("v 0.000000 0.000000 0.000000"));
84        assert!(s.contains("v 1.000000 0.000000 0.000000"));
85        let v_count = s.lines().filter(|l| l.starts_with("v ")).count();
86        assert_eq!(v_count, 3);
87    }
88
89    #[test]
90    fn obj_string_has_face_lines() {
91        let m = triangle_mesh();
92        let s = mesh_to_obj_string(&m).expect("should succeed");
93        let f_count = s.lines().filter(|l| l.starts_with("f ")).count();
94        assert_eq!(f_count, 1);
95    }
96
97    #[test]
98    fn export_obj_creates_file() {
99        let m = triangle_mesh();
100        let path = std::path::PathBuf::from("/tmp/test_oxihuman.obj");
101        export_obj(&m, &path).expect("should succeed");
102        assert!(path.exists());
103        let content = std::fs::read_to_string(&path).expect("should succeed");
104        assert!(content.contains("v "));
105        std::fs::remove_file(&path).ok();
106    }
107
108    #[test]
109    fn obj_indices_are_one_based() {
110        let m = triangle_mesh();
111        let s = mesh_to_obj_string(&m).expect("should succeed");
112        // OBJ is 1-indexed: first face should reference 1, not 0
113        assert!(s.contains("f 1/1/1 2/2/2 3/3/3"));
114    }
115}