Skip to main content

oxihuman_export/
collada.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! COLLADA (`.dae`) 3D format export — ISO/IEC 17506.
5//!
6//! Writes COLLADA 1.4.1 XML directly as strings; no external XML crate required.
7
8#![allow(dead_code)]
9
10use std::path::Path;
11
12use anyhow::Context;
13use oxihuman_mesh::MeshBuffers;
14
15// ── Options ───────────────────────────────────────────────────────────────────
16
17/// Options controlling COLLADA export output.
18pub struct ColladaExportOptions {
19    /// Name used for the asset and mesh geometry. Default: `"OxiHumanMesh"`.
20    pub asset_name: String,
21    /// Author metadata string. Default: `"OxiHuman"`.
22    pub author: String,
23    /// Unit name string. Default: `"meter"`.
24    pub unit_name: String,
25    /// Meters-per-unit scale factor. Default: `1.0`.
26    pub unit_meter: f32,
27    /// Up-axis string, e.g. `"Y_UP"` or `"Z_UP"`. Default: `"Y_UP"`.
28    pub up_axis: String,
29    /// Emit normals source and input. Default: `true`.
30    pub include_normals: bool,
31    /// Emit UV-texcoord source and input. Default: `true`.
32    pub include_uvs: bool,
33    /// Emit `<double_sided>` extra element. Default: `false`.
34    pub double_sided: bool,
35}
36
37impl Default for ColladaExportOptions {
38    fn default() -> Self {
39        Self {
40            asset_name: "OxiHumanMesh".to_string(),
41            author: "OxiHuman".to_string(),
42            unit_name: "meter".to_string(),
43            unit_meter: 1.0,
44            up_axis: "Y_UP".to_string(),
45            include_normals: true,
46            include_uvs: true,
47            double_sided: false,
48        }
49    }
50}
51
52// ── Stats ─────────────────────────────────────────────────────────────────────
53
54/// Statistics returned after a successful COLLADA export.
55pub struct ColladaExportStats {
56    pub vertex_count: usize,
57    pub face_count: usize,
58    pub has_normals: bool,
59    pub has_uvs: bool,
60    pub byte_size: usize,
61}
62
63// ── Formatting helpers ────────────────────────────────────────────────────────
64
65/// Format a flat `&[f32]` slice as a space-separated string.
66pub fn format_float_array(values: &[f32]) -> String {
67    values
68        .iter()
69        .map(|v| format!("{}", v))
70        .collect::<Vec<_>>()
71        .join(" ")
72}
73
74/// Format a flat `&[u32]` slice as a space-separated string.
75pub fn format_int_array_collada(values: &[u32]) -> String {
76    values
77        .iter()
78        .map(|v| v.to_string())
79        .collect::<Vec<_>>()
80        .join(" ")
81}
82
83/// Flatten a `[[f32; 3]]` slice into a `Vec<f32>`.
84fn flatten3(data: &[[f32; 3]]) -> Vec<f32> {
85    data.iter().flat_map(|v| [v[0], v[1], v[2]]).collect()
86}
87
88/// Flatten a `[[f32; 2]]` slice into a `Vec<f32>`.
89fn flatten2(data: &[[f32; 2]]) -> Vec<f32> {
90    data.iter().flat_map(|v| [v[0], v[1]]).collect()
91}
92
93// ── Core geometry builder ─────────────────────────────────────────────────────
94
95/// Build the `<geometry>` XML block for one mesh.
96///
97/// `geo_id` is used as the `id` attribute, e.g. `"mesh0"`.
98fn build_geometry_block(
99    mesh: &MeshBuffers,
100    geo_id: &str,
101    name: &str,
102    opts: &ColladaExportOptions,
103) -> String {
104    let v_count = mesh.positions.len();
105    let f_count = mesh.indices.len() / 3;
106
107    let has_normals = opts.include_normals && !mesh.normals.is_empty();
108    let has_uvs = opts.include_uvs && !mesh.uvs.is_empty();
109
110    let mut out = String::new();
111
112    out.push_str(&format!(
113        "    <geometry id=\"{geo_id}\" name=\"{name}\">\n      <mesh>\n"
114    ));
115
116    // ── positions source ──
117    let pos_flat = flatten3(&mesh.positions);
118    out.push_str(&format!("        <source id=\"{geo_id}-positions\">\n"));
119    out.push_str(&format!(
120        "          <float_array id=\"{geo_id}-positions-array\" count=\"{count}\">{data}</float_array>\n",
121        count = pos_flat.len(),
122        data = format_float_array(&pos_flat),
123    ));
124    out.push_str("          <technique_common>\n");
125    out.push_str(&format!(
126        "            <accessor source=\"#{geo_id}-positions-array\" count=\"{v}\" stride=\"3\">\n",
127        v = v_count
128    ));
129    out.push_str("              <param name=\"X\" type=\"float\"/>\n");
130    out.push_str("              <param name=\"Y\" type=\"float\"/>\n");
131    out.push_str("              <param name=\"Z\" type=\"float\"/>\n");
132    out.push_str("            </accessor>\n");
133    out.push_str("          </technique_common>\n");
134    out.push_str("        </source>\n");
135
136    // ── normals source ──
137    if has_normals {
138        let norm_flat = flatten3(&mesh.normals);
139        out.push_str(&format!("        <source id=\"{geo_id}-normals\">\n"));
140        out.push_str(&format!(
141            "          <float_array id=\"{geo_id}-normals-array\" count=\"{count}\">{data}</float_array>\n",
142            count = norm_flat.len(),
143            data = format_float_array(&norm_flat),
144        ));
145        out.push_str("          <technique_common>\n");
146        out.push_str(&format!(
147            "            <accessor source=\"#{geo_id}-normals-array\" count=\"{v}\" stride=\"3\">\n",
148            v = v_count
149        ));
150        out.push_str("              <param name=\"X\" type=\"float\"/>\n");
151        out.push_str("              <param name=\"Y\" type=\"float\"/>\n");
152        out.push_str("              <param name=\"Z\" type=\"float\"/>\n");
153        out.push_str("            </accessor>\n");
154        out.push_str("          </technique_common>\n");
155        out.push_str("        </source>\n");
156    }
157
158    // ── UVs source ──
159    if has_uvs {
160        let uv_flat = flatten2(&mesh.uvs);
161        out.push_str(&format!("        <source id=\"{geo_id}-uvs\">\n"));
162        out.push_str(&format!(
163            "          <float_array id=\"{geo_id}-uvs-array\" count=\"{count}\">{data}</float_array>\n",
164            count = uv_flat.len(),
165            data = format_float_array(&uv_flat),
166        ));
167        out.push_str("          <technique_common>\n");
168        out.push_str(&format!(
169            "            <accessor source=\"#{geo_id}-uvs-array\" count=\"{v}\" stride=\"2\">\n",
170            v = v_count
171        ));
172        out.push_str("              <param name=\"S\" type=\"float\"/>\n");
173        out.push_str("              <param name=\"T\" type=\"float\"/>\n");
174        out.push_str("            </accessor>\n");
175        out.push_str("          </technique_common>\n");
176        out.push_str("        </source>\n");
177    }
178
179    // ── vertices ──
180    out.push_str(&format!("        <vertices id=\"{geo_id}-vertices\">\n"));
181    out.push_str(&format!(
182        "          <input semantic=\"POSITION\" source=\"#{geo_id}-positions\"/>\n"
183    ));
184    out.push_str("        </vertices>\n");
185
186    // ── triangles ──
187    // Determine stride based on which channels are present.
188    let stride: usize = 1 + if has_normals { 1 } else { 0 } + if has_uvs { 1 } else { 0 };
189
190    let mut normal_offset = 0usize;
191    let mut uv_offset = 0usize;
192    let mut current_offset = 1usize; // VERTEX is always offset 0
193    if has_normals {
194        normal_offset = current_offset;
195        current_offset += 1;
196    }
197    if has_uvs {
198        uv_offset = current_offset;
199    }
200
201    out.push_str(&format!("        <triangles count=\"{f_count}\">\n"));
202    out.push_str(&format!(
203        "          <input semantic=\"VERTEX\" source=\"#{geo_id}-vertices\" offset=\"0\"/>\n"
204    ));
205    if has_normals {
206        out.push_str(&format!(
207            "          <input semantic=\"NORMAL\" source=\"#{geo_id}-normals\" offset=\"{normal_offset}\"/>\n"
208        ));
209    }
210    if has_uvs {
211        out.push_str(&format!(
212            "          <input semantic=\"TEXCOORD\" source=\"#{geo_id}-uvs\" offset=\"{uv_offset}\" set=\"0\"/>\n"
213        ));
214    }
215
216    // Build interleaved index list: for each triangle vertex, emit indices for
217    // all active channels. Since we use per-vertex data (not per-face-corner),
218    // all channel indices are the same as the vertex index.
219    let mut p_parts: Vec<String> = Vec::with_capacity(mesh.indices.len() * stride);
220    for &idx in &mesh.indices {
221        for _ in 0..stride {
222            p_parts.push(idx.to_string());
223        }
224    }
225    out.push_str(&format!("          <p>{}</p>\n", p_parts.join(" ")));
226    out.push_str("        </triangles>\n");
227
228    // ── double_sided extra ──
229    if opts.double_sided {
230        out.push_str("        <extra>\n");
231        out.push_str("          <technique profile=\"MAYA\">\n");
232        out.push_str("            <double_sided>1</double_sided>\n");
233        out.push_str("          </technique>\n");
234        out.push_str("        </extra>\n");
235    }
236
237    out.push_str("      </mesh>\n    </geometry>\n");
238    out
239}
240
241// ── COLLADA header / footer ───────────────────────────────────────────────────
242
243fn collada_header(opts: &ColladaExportOptions) -> String {
244    format!(
245        "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
246         <COLLADA xmlns=\"http://www.collada.org/2005/11/COLLADASchema\" version=\"1.4.1\">\n\
247           <asset>\n\
248         \x20   <contributor><author>{author}</author></contributor>\n\
249         \x20   <created>2026-01-01</created>\n\
250         \x20   <modified>2026-01-01</modified>\n\
251         \x20   <unit name=\"{unit_name}\" meter=\"{unit_meter}\"/>\n\
252         \x20   <up_axis>{up_axis}</up_axis>\n\
253           </asset>\n",
254        author = opts.author,
255        unit_name = opts.unit_name,
256        unit_meter = opts.unit_meter,
257        up_axis = opts.up_axis,
258    )
259}
260
261fn collada_footer() -> &'static str {
262    "  <scene>\n    <instance_visual_scene url=\"#Scene\"/>\n  </scene>\n</COLLADA>\n"
263}
264
265// ── Public API ────────────────────────────────────────────────────────────────
266
267/// Build a COLLADA XML string for a single mesh.
268///
269/// Returns the XML content and export statistics.
270pub fn build_collada(
271    mesh: &MeshBuffers,
272    options: &ColladaExportOptions,
273) -> (String, ColladaExportStats) {
274    let geo_id = "mesh0";
275    let has_normals = options.include_normals && !mesh.normals.is_empty();
276    let has_uvs = options.include_uvs && !mesh.uvs.is_empty();
277    let v_count = mesh.positions.len();
278    let f_count = mesh.indices.len() / 3;
279
280    let mut out = collada_header(options);
281
282    // library_geometries
283    out.push_str("  <library_geometries>\n");
284    out.push_str(&build_geometry_block(
285        mesh,
286        geo_id,
287        &options.asset_name,
288        options,
289    ));
290    out.push_str("  </library_geometries>\n");
291
292    // library_visual_scenes
293    out.push_str("  <library_visual_scenes>\n");
294    out.push_str("    <visual_scene id=\"Scene\" name=\"Scene\">\n");
295    out.push_str(&format!(
296        "      <node id=\"Mesh\" name=\"{name}\" type=\"NODE\">\n\
297         \x20       <instance_geometry url=\"#{geo_id}\"/>\n\
298               </node>\n",
299        name = options.asset_name,
300        geo_id = geo_id,
301    ));
302    out.push_str("    </visual_scene>\n");
303    out.push_str("  </library_visual_scenes>\n");
304
305    out.push_str(collada_footer());
306
307    let byte_size = out.len();
308    let stats = ColladaExportStats {
309        vertex_count: v_count,
310        face_count: f_count,
311        has_normals,
312        has_uvs,
313        byte_size,
314    };
315    (out, stats)
316}
317
318/// Export a single mesh as a `.dae` COLLADA file.
319pub fn export_collada(
320    mesh: &MeshBuffers,
321    path: &Path,
322    options: &ColladaExportOptions,
323) -> anyhow::Result<ColladaExportStats> {
324    let (content, stats) = build_collada(mesh, options);
325    std::fs::write(path, &content)
326        .with_context(|| format!("Failed to write COLLADA file: {}", path.display()))?;
327    Ok(stats)
328}
329
330/// Build a COLLADA XML string for multiple meshes (each as a separate geometry).
331///
332/// `meshes` is a slice of `(mesh_ref, name_str)` pairs.
333pub fn build_collada_scene(
334    meshes: &[(&MeshBuffers, &str)],
335    options: &ColladaExportOptions,
336) -> String {
337    let mut out = collada_header(options);
338
339    // library_geometries
340    out.push_str("  <library_geometries>\n");
341    for (i, (mesh, name)) in meshes.iter().enumerate() {
342        let geo_id = format!("mesh{i}");
343        out.push_str(&build_geometry_block(mesh, &geo_id, name, options));
344    }
345    out.push_str("  </library_geometries>\n");
346
347    // library_visual_scenes
348    out.push_str("  <library_visual_scenes>\n");
349    out.push_str("    <visual_scene id=\"Scene\" name=\"Scene\">\n");
350    for (i, (_mesh, name)) in meshes.iter().enumerate() {
351        let geo_id = format!("mesh{i}");
352        let node_id = format!("Node{i}");
353        out.push_str(&format!(
354            "      <node id=\"{node_id}\" name=\"{name}\" type=\"NODE\">\n\
355             \x20       <instance_geometry url=\"#{geo_id}\"/>\n\
356                   </node>\n"
357        ));
358    }
359    out.push_str("    </visual_scene>\n");
360    out.push_str("  </library_visual_scenes>\n");
361
362    out.push_str(collada_footer());
363    out
364}
365
366/// Export multiple meshes as a `.dae` COLLADA scene file.
367pub fn export_collada_scene(
368    meshes: &[(&MeshBuffers, &str)],
369    path: &Path,
370    options: &ColladaExportOptions,
371) -> anyhow::Result<()> {
372    let content = build_collada_scene(meshes, options);
373    std::fs::write(path, &content)
374        .with_context(|| format!("Failed to write COLLADA scene file: {}", path.display()))?;
375    Ok(())
376}
377
378/// Validate a COLLADA XML string by checking for required key elements.
379///
380/// Returns `Ok(())` if all required elements are present, or `Err(msg)` on failure.
381pub fn validate_collada(content: &str) -> Result<(), String> {
382    let required = [
383        "<?xml",
384        "<COLLADA",
385        "http://www.collada.org/2005/11/COLLADASchema",
386        "<asset>",
387        "<library_geometries>",
388        "<library_visual_scenes>",
389        "<visual_scene",
390        "<scene>",
391        "</COLLADA>",
392    ];
393    for token in &required {
394        if !content.contains(token) {
395            return Err(format!("Missing required COLLADA element: {token}"));
396        }
397    }
398    Ok(())
399}
400
401// ── Tests ─────────────────────────────────────────────────────────────────────
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406    use oxihuman_mesh::MeshBuffers;
407    use oxihuman_morph::engine::MeshBuffers as MB;
408
409    fn simple_mesh() -> MeshBuffers {
410        MeshBuffers::from_morph(MB {
411            positions: vec![
412                [0.0, 0.0, 0.0],
413                [1.0, 0.0, 0.0],
414                [0.0, 1.0, 0.0],
415                [1.0, 1.0, 0.0],
416            ],
417            normals: vec![[0.0, 0.0, 1.0]; 4],
418            uvs: vec![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0]],
419            indices: vec![0, 1, 2, 1, 3, 2],
420            has_suit: false,
421        })
422    }
423
424    fn single_tri_mesh() -> MeshBuffers {
425        MeshBuffers::from_morph(MB {
426            positions: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
427            normals: vec![[0.0, 0.0, 1.0]; 3],
428            uvs: vec![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]],
429            indices: vec![0, 1, 2],
430            has_suit: false,
431        })
432    }
433
434    // Test 1: format_float_array basic
435    #[test]
436    fn test_format_float_array_basic() {
437        let v = vec![1.0f32, 2.0, 3.0];
438        let s = format_float_array(&v);
439        assert!(s.contains("1"), "should contain 1");
440        assert!(s.contains("2"), "should contain 2");
441        assert!(s.contains("3"), "should contain 3");
442    }
443
444    // Test 2: format_float_array empty
445    #[test]
446    fn test_format_float_array_empty() {
447        let s = format_float_array(&[]);
448        assert_eq!(s, "");
449    }
450
451    // Test 3: format_int_array_collada basic
452    #[test]
453    fn test_format_int_array_collada_basic() {
454        let v = vec![0u32, 1, 2, 3];
455        let s = format_int_array_collada(&v);
456        assert_eq!(s, "0 1 2 3");
457    }
458
459    // Test 4: format_int_array_collada empty
460    #[test]
461    fn test_format_int_array_collada_empty() {
462        let s = format_int_array_collada(&[]);
463        assert_eq!(s, "");
464    }
465
466    // Test 5: build_collada returns valid XML declaration
467    #[test]
468    fn test_build_collada_xml_declaration() {
469        let mesh = simple_mesh();
470        let opts = ColladaExportOptions::default();
471        let (xml, _) = build_collada(&mesh, &opts);
472        assert!(xml.starts_with("<?xml version=\"1.0\""));
473    }
474
475    // Test 6: build_collada contains COLLADA root element
476    #[test]
477    fn test_build_collada_root_element() {
478        let mesh = simple_mesh();
479        let opts = ColladaExportOptions::default();
480        let (xml, _) = build_collada(&mesh, &opts);
481        assert!(xml.contains("<COLLADA"));
482        assert!(xml.contains("</COLLADA>"));
483    }
484
485    // Test 7: build_collada stats vertex/face count
486    #[test]
487    fn test_build_collada_stats_counts() {
488        let mesh = simple_mesh();
489        let opts = ColladaExportOptions::default();
490        let (_, stats) = build_collada(&mesh, &opts);
491        assert_eq!(stats.vertex_count, 4);
492        assert_eq!(stats.face_count, 2);
493    }
494
495    // Test 8: build_collada stats has_normals and has_uvs
496    #[test]
497    fn test_build_collada_stats_channels() {
498        let mesh = simple_mesh();
499        let opts = ColladaExportOptions::default();
500        let (_, stats) = build_collada(&mesh, &opts);
501        assert!(stats.has_normals);
502        assert!(stats.has_uvs);
503    }
504
505    // Test 9: build_collada byte_size matches string length
506    #[test]
507    fn test_build_collada_byte_size() {
508        let mesh = simple_mesh();
509        let opts = ColladaExportOptions::default();
510        let (xml, stats) = build_collada(&mesh, &opts);
511        assert_eq!(stats.byte_size, xml.len());
512    }
513
514    // Test 10: build_collada excludes normals when include_normals=false
515    #[test]
516    fn test_build_collada_no_normals() {
517        let mesh = simple_mesh();
518        let opts = ColladaExportOptions {
519            include_normals: false,
520            ..Default::default()
521        };
522        let (xml, stats) = build_collada(&mesh, &opts);
523        assert!(
524            !xml.contains("normals"),
525            "should not contain normals source"
526        );
527        assert!(!stats.has_normals);
528    }
529
530    // Test 11: build_collada excludes uvs when include_uvs=false
531    #[test]
532    fn test_build_collada_no_uvs() {
533        let mesh = simple_mesh();
534        let opts = ColladaExportOptions {
535            include_uvs: false,
536            ..Default::default()
537        };
538        let (xml, stats) = build_collada(&mesh, &opts);
539        assert!(
540            !xml.contains("TEXCOORD"),
541            "should not contain texcoord input"
542        );
543        assert!(!stats.has_uvs);
544    }
545
546    // Test 12: validate_collada passes for valid output
547    #[test]
548    fn test_validate_collada_valid() {
549        let mesh = simple_mesh();
550        let opts = ColladaExportOptions::default();
551        let (xml, _) = build_collada(&mesh, &opts);
552        assert!(validate_collada(&xml).is_ok());
553    }
554
555    // Test 13: validate_collada fails for truncated content
556    #[test]
557    fn test_validate_collada_invalid() {
558        let bad = "<?xml version=\"1.0\"?><notcollada/>";
559        assert!(validate_collada(bad).is_err());
560    }
561
562    // Test 14: export_collada writes file to /tmp/
563    #[test]
564    fn test_export_collada_writes_file() {
565        let mesh = single_tri_mesh();
566        let opts = ColladaExportOptions::default();
567        let path = std::path::Path::new("/tmp/oxihuman_test_collada.dae");
568        let stats = export_collada(&mesh, path, &opts).expect("export_collada failed");
569        assert!(path.exists(), "file should exist");
570        assert!(stats.byte_size > 0);
571        let content = std::fs::read_to_string(path).expect("should succeed");
572        assert!(validate_collada(&content).is_ok());
573    }
574
575    // Test 15: build_collada_scene with two meshes
576    #[test]
577    fn test_build_collada_scene_two_meshes() {
578        let m1 = simple_mesh();
579        let m2 = single_tri_mesh();
580        let opts = ColladaExportOptions::default();
581        let meshes: Vec<(&MeshBuffers, &str)> = vec![(&m1, "Mesh1"), (&m2, "Mesh2")];
582        let xml = build_collada_scene(&meshes, &opts);
583        assert!(xml.contains("id=\"mesh0\""), "should have mesh0 geometry");
584        assert!(xml.contains("id=\"mesh1\""), "should have mesh1 geometry");
585        assert!(validate_collada(&xml).is_ok());
586    }
587
588    // Test 16: export_collada_scene writes file to /tmp/
589    #[test]
590    fn test_export_collada_scene_writes_file() {
591        let m1 = simple_mesh();
592        let m2 = single_tri_mesh();
593        let opts = ColladaExportOptions::default();
594        let meshes: Vec<(&MeshBuffers, &str)> = vec![(&m1, "Body"), (&m2, "Head")];
595        let path = std::path::Path::new("/tmp/oxihuman_test_collada_scene.dae");
596        export_collada_scene(&meshes, path, &opts).expect("export_collada_scene failed");
597        assert!(path.exists());
598        let content = std::fs::read_to_string(path).expect("should succeed");
599        assert!(validate_collada(&content).is_ok());
600    }
601
602    // Test 17: double_sided extra element appears when enabled
603    #[test]
604    fn test_build_collada_double_sided() {
605        let mesh = single_tri_mesh();
606        let opts = ColladaExportOptions {
607            double_sided: true,
608            ..Default::default()
609        };
610        let (xml, _) = build_collada(&mesh, &opts);
611        assert!(xml.contains("<double_sided>1</double_sided>"));
612    }
613
614    // Test 18: up_axis is reflected in output
615    #[test]
616    fn test_build_collada_up_axis_z() {
617        let mesh = single_tri_mesh();
618        let opts = ColladaExportOptions {
619            up_axis: "Z_UP".to_string(),
620            ..Default::default()
621        };
622        let (xml, _) = build_collada(&mesh, &opts);
623        assert!(xml.contains("<up_axis>Z_UP</up_axis>"));
624    }
625
626    // Test 19: asset_name reflected in geometry name attribute
627    #[test]
628    fn test_build_collada_asset_name_in_geometry() {
629        let mesh = single_tri_mesh();
630        let opts = ColladaExportOptions {
631            asset_name: "TestBody".to_string(),
632            ..Default::default()
633        };
634        let (xml, _) = build_collada(&mesh, &opts);
635        assert!(xml.contains("name=\"TestBody\""));
636    }
637
638    // Test 20: build_collada_scene with empty slice
639    #[test]
640    fn test_build_collada_scene_empty() {
641        let opts = ColladaExportOptions::default();
642        let meshes: Vec<(&MeshBuffers, &str)> = vec![];
643        let xml = build_collada_scene(&meshes, &opts);
644        assert!(xml.contains("<library_geometries>"));
645        assert!(xml.contains("</library_geometries>"));
646    }
647}