1#![allow(dead_code)]
9
10use std::path::Path;
11
12use anyhow::Context;
13use oxihuman_mesh::MeshBuffers;
14
15pub struct ColladaExportOptions {
19 pub asset_name: String,
21 pub author: String,
23 pub unit_name: String,
25 pub unit_meter: f32,
27 pub up_axis: String,
29 pub include_normals: bool,
31 pub include_uvs: bool,
33 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
52pub 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
63pub fn format_float_array(values: &[f32]) -> String {
67 values
68 .iter()
69 .map(|v| format!("{}", v))
70 .collect::<Vec<_>>()
71 .join(" ")
72}
73
74pub 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
83fn flatten3(data: &[[f32; 3]]) -> Vec<f32> {
85 data.iter().flat_map(|v| [v[0], v[1], v[2]]).collect()
86}
87
88fn flatten2(data: &[[f32; 2]]) -> Vec<f32> {
90 data.iter().flat_map(|v| [v[0], v[1]]).collect()
91}
92
93fn 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 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 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 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 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 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; 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 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 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
241fn 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
265pub 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 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 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
318pub 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
330pub fn build_collada_scene(
334 meshes: &[(&MeshBuffers, &str)],
335 options: &ColladaExportOptions,
336) -> String {
337 let mut out = collada_header(options);
338
339 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 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
366pub 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
378pub 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#[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]
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]
446 fn test_format_float_array_empty() {
447 let s = format_float_array(&[]);
448 assert_eq!(s, "");
449 }
450
451 #[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]
461 fn test_format_int_array_collada_empty() {
462 let s = format_int_array_collada(&[]);
463 assert_eq!(s, "");
464 }
465
466 #[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]
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]
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]
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]
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]
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]
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]
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]
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]
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]
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]
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]
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]
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]
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]
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}