Skip to main content

oxihuman_export/
usd.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4#![allow(dead_code)]
5#![allow(non_snake_case)]
6
7use std::fs;
8use std::io::{BufRead, BufReader};
9use std::path::Path;
10
11use anyhow::Context;
12use oxihuman_mesh::MeshBuffers;
13
14/// Options controlling USDA export output.
15pub struct UsdExportOptions {
16    pub prim_name: String,
17    pub root_name: String,
18    pub up_axis: String,
19    pub meters_per_unit: f32,
20    pub include_normals: bool,
21    pub include_uvs: bool,
22    pub include_displayColor: bool,
23}
24
25impl Default for UsdExportOptions {
26    fn default() -> Self {
27        Self {
28            prim_name: "Body".to_string(),
29            root_name: "Root".to_string(),
30            up_axis: "Y".to_string(),
31            meters_per_unit: 1.0,
32            include_normals: true,
33            include_uvs: true,
34            include_displayColor: false,
35        }
36    }
37}
38
39/// Statistics returned after a successful USDA export.
40pub struct UsdExportStats {
41    pub vertex_count: usize,
42    pub face_count: usize,
43    pub file_size_bytes: usize,
44    pub has_normals: bool,
45    pub has_uvs: bool,
46}
47
48// ── formatting helpers ────────────────────────────────────────────────────────
49
50/// Format a float3 slice as a USD tuple list: `[(x, y, z), ...]`
51pub fn format_float3_array(data: &[[f32; 3]]) -> String {
52    let inner: Vec<String> = data
53        .iter()
54        .map(|v| format!("({:.6}, {:.6}, {:.6})", v[0], v[1], v[2]))
55        .collect();
56    format!("[{}]", inner.join(", "))
57}
58
59/// Format a float2 slice as a USD tuple list: `[(u, v), ...]`
60pub fn format_float2_array(data: &[[f32; 2]]) -> String {
61    let inner: Vec<String> = data
62        .iter()
63        .map(|v| format!("({:.6}, {:.6})", v[0], v[1]))
64        .collect();
65    format!("[{}]", inner.join(", "))
66}
67
68/// Format a u32 slice as a USD array: `[i0, i1, i2, ...]`
69pub fn format_int_array(data: &[u32]) -> String {
70    let inner: Vec<String> = data.iter().map(|i| i.to_string()).collect();
71    format!("[{}]", inner.join(", "))
72}
73
74// ── core builder ─────────────────────────────────────────────────────────────
75
76/// Build a USDA string from a mesh and options.
77pub fn build_usda(mesh: &MeshBuffers, opts: &UsdExportOptions) -> String {
78    let face_count = mesh.indices.len() / 3;
79
80    // 1. Header
81    let mut out = format!(
82        "#usda 1.0\n(\n    defaultPrim = \"{root}\"\n    upAxis = \"{up}\"\n    metersPerUnit = {mpu}\n)\n\n",
83        root = opts.root_name,
84        up = opts.up_axis,
85        mpu = opts.meters_per_unit,
86    );
87
88    // 2. Root xform + mesh prim
89    out.push_str(&format!(
90        "def Xform \"{root}\"\n{{\n    def Mesh \"{prim}\"\n    {{\n",
91        root = opts.root_name,
92        prim = opts.prim_name,
93    ));
94
95    // 3. Points
96    out.push_str(&format!(
97        "        float3[] points = {}\n",
98        format_float3_array(&mesh.positions)
99    ));
100
101    // 4. faceVertexCounts — one 3 per triangle
102    let counts: Vec<u32> = vec![3u32; face_count];
103    out.push_str(&format!(
104        "        int[] faceVertexCounts = {}\n",
105        format_int_array(&counts)
106    ));
107
108    // 5. faceVertexIndices
109    out.push_str(&format!(
110        "        int[] faceVertexIndices = {}\n",
111        format_int_array(&mesh.indices)
112    ));
113
114    // 6. Optional normals
115    if opts.include_normals && !mesh.normals.is_empty() {
116        out.push_str(&format!(
117            "        normal3f[] normals = {}\n",
118            format_float3_array(&mesh.normals)
119        ));
120    }
121
122    // 7. Optional UVs
123    if opts.include_uvs && !mesh.uvs.is_empty() {
124        out.push_str(&format!(
125            "        texCoord2f[] primvars:st = {}\n",
126            format_float2_array(&mesh.uvs)
127        ));
128        out.push_str("        uniform token[] primvars:st:indices = None\n");
129    }
130
131    // 8. Transform
132    out.push_str("        double3 xformOp:translate = (0, 0, 0)\n");
133    out.push_str("        uniform token[] xformOpOrder = [\"xformOp:translate\"]\n");
134
135    // 9. Close braces
136    out.push_str("    }\n}\n");
137
138    out
139}
140
141// ── file-level exports ────────────────────────────────────────────────────────
142
143/// Export a single mesh to a `.usda` file.
144pub fn export_usda(
145    mesh: &MeshBuffers,
146    path: &Path,
147    opts: &UsdExportOptions,
148) -> anyhow::Result<UsdExportStats> {
149    let content = build_usda(mesh, opts);
150    let bytes = content.as_bytes();
151
152    if let Some(parent) = path.parent() {
153        if !parent.as_os_str().is_empty() {
154            fs::create_dir_all(parent).with_context(|| format!("create dir {:?}", parent))?;
155        }
156    }
157
158    fs::write(path, bytes).with_context(|| format!("write {:?}", path))?;
159
160    Ok(UsdExportStats {
161        vertex_count: mesh.positions.len(),
162        face_count: mesh.indices.len() / 3,
163        file_size_bytes: bytes.len(),
164        has_normals: opts.include_normals && !mesh.normals.is_empty(),
165        has_uvs: opts.include_uvs && !mesh.uvs.is_empty(),
166    })
167}
168
169/// Export multiple meshes as a USDA scene (each as a separate Mesh prim).
170pub fn export_usda_scene(
171    meshes: &[(&MeshBuffers, &str)],
172    path: &Path,
173    opts: &UsdExportOptions,
174) -> anyhow::Result<()> {
175    let mut out = format!(
176        "#usda 1.0\n(\n    defaultPrim = \"{root}\"\n    upAxis = \"{up}\"\n    metersPerUnit = {mpu}\n)\n\n",
177        root = opts.root_name,
178        up = opts.up_axis,
179        mpu = opts.meters_per_unit,
180    );
181
182    out.push_str(&format!(
183        "def Xform \"{root}\"\n{{\n",
184        root = opts.root_name
185    ));
186
187    for (mesh, name) in meshes {
188        let face_count = mesh.indices.len() / 3;
189        out.push_str(&format!("    def Mesh \"{name}\"\n    {{\n"));
190
191        out.push_str(&format!(
192            "        float3[] points = {}\n",
193            format_float3_array(&mesh.positions)
194        ));
195
196        let counts: Vec<u32> = vec![3u32; face_count];
197        out.push_str(&format!(
198            "        int[] faceVertexCounts = {}\n",
199            format_int_array(&counts)
200        ));
201
202        out.push_str(&format!(
203            "        int[] faceVertexIndices = {}\n",
204            format_int_array(&mesh.indices)
205        ));
206
207        if opts.include_normals && !mesh.normals.is_empty() {
208            out.push_str(&format!(
209                "        normal3f[] normals = {}\n",
210                format_float3_array(&mesh.normals)
211            ));
212        }
213
214        if opts.include_uvs && !mesh.uvs.is_empty() {
215            out.push_str(&format!(
216                "        texCoord2f[] primvars:st = {}\n",
217                format_float2_array(&mesh.uvs)
218            ));
219            out.push_str("        uniform token[] primvars:st:indices = None\n");
220        }
221
222        out.push_str("        double3 xformOp:translate = (0, 0, 0)\n");
223        out.push_str("        uniform token[] xformOpOrder = [\"xformOp:translate\"]\n");
224        out.push_str("    }\n");
225    }
226
227    out.push_str("}\n");
228
229    if let Some(parent) = path.parent() {
230        if !parent.as_os_str().is_empty() {
231            fs::create_dir_all(parent).with_context(|| format!("create dir {:?}", parent))?;
232        }
233    }
234
235    fs::write(path, out.as_bytes()).with_context(|| format!("write {:?}", path))?;
236    Ok(())
237}
238
239/// Validate that a `.usda` file has the correct `#usda 1.0` header.
240pub fn validate_usda(path: &Path) -> anyhow::Result<bool> {
241    let file = fs::File::open(path).with_context(|| format!("open {:?}", path))?;
242    let mut reader = BufReader::new(file);
243    let mut first_line = String::new();
244    reader
245        .read_line(&mut first_line)
246        .with_context(|| "read first line")?;
247    Ok(first_line.trim() == "#usda 1.0")
248}
249
250// ── tests ─────────────────────────────────────────────────────────────────────
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use oxihuman_mesh::MeshBuffers;
256
257    /// Build a minimal 2-triangle (quad) test mesh.
258    fn two_tri_mesh() -> MeshBuffers {
259        MeshBuffers {
260            positions: vec![
261                [0.0, 0.0, 0.0],
262                [1.0, 0.0, 0.0],
263                [1.0, 1.0, 0.0],
264                [0.0, 1.0, 0.0],
265            ],
266            normals: vec![
267                [0.0, 0.0, 1.0],
268                [0.0, 0.0, 1.0],
269                [0.0, 0.0, 1.0],
270                [0.0, 0.0, 1.0],
271            ],
272            tangents: vec![[1.0, 0.0, 0.0, 1.0]; 4],
273            uvs: vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]],
274            indices: vec![0, 1, 2, 0, 2, 3],
275            colors: None,
276            has_suit: true,
277        }
278    }
279
280    #[test]
281    fn test_build_usda_header() {
282        let mesh = two_tri_mesh();
283        let opts = UsdExportOptions::default();
284        let usda = build_usda(&mesh, &opts);
285        assert!(usda.starts_with("#usda 1.0"), "must start with #usda 1.0");
286        assert!(usda.contains("defaultPrim = \"Root\""));
287        assert!(usda.contains("upAxis = \"Y\""));
288        assert!(usda.contains("metersPerUnit = 1"));
289    }
290
291    #[test]
292    fn test_build_usda_has_points() {
293        let mesh = two_tri_mesh();
294        let opts = UsdExportOptions::default();
295        let usda = build_usda(&mesh, &opts);
296        assert!(
297            usda.contains("float3[] points"),
298            "must contain points array"
299        );
300        assert!(usda.contains("(0.000000, 0.000000, 0.000000)"));
301        assert!(usda.contains("(1.000000, 0.000000, 0.000000)"));
302    }
303
304    #[test]
305    fn test_build_usda_has_face_counts() {
306        let mesh = two_tri_mesh();
307        let opts = UsdExportOptions::default();
308        let usda = build_usda(&mesh, &opts);
309        assert!(
310            usda.contains("int[] faceVertexCounts"),
311            "must contain faceVertexCounts"
312        );
313        // 2 triangles → [3, 3]
314        assert!(usda.contains("[3, 3]"));
315    }
316
317    #[test]
318    fn test_build_usda_has_indices() {
319        let mesh = two_tri_mesh();
320        let opts = UsdExportOptions::default();
321        let usda = build_usda(&mesh, &opts);
322        assert!(
323            usda.contains("int[] faceVertexIndices"),
324            "must contain faceVertexIndices"
325        );
326        assert!(usda.contains("[0, 1, 2, 0, 2, 3]"));
327    }
328
329    #[test]
330    fn test_build_usda_with_normals() {
331        let mesh = two_tri_mesh();
332        let opts = UsdExportOptions {
333            include_normals: true,
334            ..Default::default()
335        };
336        let usda = build_usda(&mesh, &opts);
337        assert!(usda.contains("normal3f[] normals"), "must contain normals");
338        assert!(usda.contains("(0.000000, 0.000000, 1.000000)"));
339    }
340
341    #[test]
342    fn test_build_usda_with_uvs() {
343        let mesh = two_tri_mesh();
344        let opts = UsdExportOptions {
345            include_uvs: true,
346            ..Default::default()
347        };
348        let usda = build_usda(&mesh, &opts);
349        assert!(
350            usda.contains("texCoord2f[] primvars:st"),
351            "must contain UVs"
352        );
353        assert!(usda.contains("primvars:st:indices"));
354    }
355
356    #[test]
357    fn test_format_float3_array() {
358        let data: Vec<[f32; 3]> = vec![[1.0, 2.0, 3.0], [4.5, 5.5, 6.5]];
359        let result = format_float3_array(&data);
360        assert_eq!(
361            result,
362            "[(1.000000, 2.000000, 3.000000), (4.500000, 5.500000, 6.500000)]"
363        );
364    }
365
366    #[test]
367    fn test_format_float2_array() {
368        let data: Vec<[f32; 2]> = vec![[0.0, 1.0], [0.5, 0.5]];
369        let result = format_float2_array(&data);
370        assert_eq!(result, "[(0.000000, 1.000000), (0.500000, 0.500000)]");
371    }
372
373    #[test]
374    fn test_format_int_array() {
375        let data: Vec<u32> = vec![0, 1, 2, 3];
376        let result = format_int_array(&data);
377        assert_eq!(result, "[0, 1, 2, 3]");
378    }
379
380    #[test]
381    fn test_export_usda_to_file() {
382        let mesh = two_tri_mesh();
383        let opts = UsdExportOptions::default();
384        let path = std::path::PathBuf::from("/tmp/test_export.usda");
385        let stats = export_usda(&mesh, &path, &opts).expect("export_usda should succeed");
386        assert_eq!(stats.vertex_count, 4);
387        assert_eq!(stats.face_count, 2);
388        assert!(stats.file_size_bytes > 0);
389        assert!(stats.has_normals);
390        assert!(stats.has_uvs);
391        assert!(path.exists());
392    }
393
394    #[test]
395    fn test_validate_usda_valid() {
396        let mesh = two_tri_mesh();
397        let opts = UsdExportOptions::default();
398        let path = std::path::PathBuf::from("/tmp/test_validate_valid.usda");
399        export_usda(&mesh, &path, &opts).expect("export_usda should succeed");
400        let valid = validate_usda(&path).expect("validate_usda should succeed");
401        assert!(valid, "exported file should be valid USDA");
402    }
403
404    #[test]
405    fn test_validate_usda_invalid() {
406        let path = std::path::PathBuf::from("/tmp/test_validate_invalid.usda");
407        fs::write(&path, b"not a usda file\nsome content\n").expect("write temp file");
408        let valid = validate_usda(&path).expect("validate_usda should succeed");
409        assert!(!valid, "file without #usda 1.0 header should be invalid");
410    }
411
412    #[test]
413    fn test_export_usda_scene() {
414        let mesh1 = two_tri_mesh();
415        let mesh2 = two_tri_mesh();
416        let opts = UsdExportOptions::default();
417        let path = std::path::PathBuf::from("/tmp/test_scene.usda");
418        let meshes: Vec<(&MeshBuffers, &str)> = vec![(&mesh1, "Body"), (&mesh2, "Hair")];
419        export_usda_scene(&meshes, &path, &opts).expect("export_usda_scene should succeed");
420        let content = fs::read_to_string(&path).expect("read scene file");
421        assert!(content.starts_with("#usda 1.0"));
422        assert!(content.contains("def Mesh \"Body\""));
423        assert!(content.contains("def Mesh \"Hair\""));
424        assert!(path.exists());
425    }
426}