1#![allow(dead_code)]
5
6use oxihuman_mesh::MeshBuffers;
7
8pub struct SkeletonJoint {
12 pub name: String,
13 pub parent: Option<usize>,
14 pub bind_translation: [f32; 3],
15 pub bind_rotation: [f32; 4], pub bind_scale: [f32; 3],
17}
18
19pub struct JointKeyframes {
21 pub joint_idx: usize,
22 pub times: Vec<f32>,
23 pub translations: Option<Vec<[f32; 3]>>,
24 pub rotations: Option<Vec<[f32; 4]>>,
25 pub scales: Option<Vec<[f32; 3]>>,
26}
27
28pub struct AnimatedGlbOptions {
30 pub include_skeleton: bool,
31 pub include_morph_weights: bool,
32 pub fps: f32,
33 pub duration: f32,
34 pub morph_target_names: Vec<String>,
35}
36
37pub struct AnimatedGlbResult {
39 pub json_size: usize,
40 pub bin_size: usize,
41 pub joint_count: usize,
42 pub morph_target_count: usize,
43 pub keyframe_count: usize,
44}
45
46#[allow(clippy::too_many_arguments)]
50pub fn build_animated_glb_json(
51 mesh: &MeshBuffers,
52 skeleton: &[SkeletonJoint],
53 joint_anims: &[JointKeyframes],
54 morph_times: &[f32],
55 morph_weight_frames: &[Vec<f32>],
56 opts: &AnimatedGlbOptions,
57) -> String {
58 let vertex_count = mesh.positions.len();
59 let face_count = mesh.indices.len() / 3;
60
61 let mut sections: Vec<String> = Vec::new();
62
63 sections.push(r#""meshes": [{ "name": "Body", "primitives": [{ "attributes": { "POSITION": 0 }, "indices": 1 }] }]"#.to_string());
65
66 sections
68 .push(r#""asset": { "version": "2.0", "generator": "OxiHuman animated_glb" }"#.to_string());
69
70 sections.push(format!(
72 r#""extras": {{ "vertexCount": {}, "faceCount": {} }}"#,
73 vertex_count, face_count
74 ));
75
76 if opts.include_skeleton && !skeleton.is_empty() {
78 let skin_json = build_skeleton_json(skeleton);
79 sections.push(format!(r#""skins": [{}]"#, skin_json));
80 }
81
82 let mut anim_parts: Vec<String> = Vec::new();
84
85 if !joint_anims.is_empty() {
86 let samplers = build_joint_anim_samplers_json(joint_anims, 10);
87 anim_parts.push(format!(
88 r#"{{ "name": "JointAnimation", "samplers": [{}], "channels": [] }}"#,
89 samplers
90 ));
91 }
92
93 if opts.include_morph_weights && !morph_times.is_empty() && !morph_weight_frames.is_empty() {
94 let morph_samplers = build_morph_anim_samplers_json(morph_times, morph_weight_frames, 100);
95 anim_parts.push(format!(
96 r#"{{ "name": "MorphAnimation", "samplers": [{}], "channels": [] }}"#,
97 morph_samplers
98 ));
99 }
100
101 if !anim_parts.is_empty() {
102 sections.push(format!(r#""animations": [{}]"#, anim_parts.join(", ")));
103 }
104
105 if !opts.morph_target_names.is_empty() {
107 let names: Vec<String> = opts
108 .morph_target_names
109 .iter()
110 .map(|n| format!(r#""{}""#, n))
111 .collect();
112 sections.push(format!(r#""morphTargetNames": [{}]"#, names.join(", ")));
113 }
114
115 format!("{{\n {}\n}}", sections.join(",\n "))
116}
117
118pub fn build_skeleton_json(joints: &[SkeletonJoint]) -> String {
120 let joint_indices: Vec<String> = (0..joints.len()).map(|i| i.to_string()).collect();
121
122 let joint_names: Vec<String> = joints.iter().map(|j| format!(r#""{}""#, j.name)).collect();
123
124 format!(
125 r#"{{ "name": "Armature", "joints": [{}], "jointNames": [{}], "skeleton": 0 }}"#,
126 joint_indices.join(", "),
127 joint_names.join(", ")
128 )
129}
130
131pub fn build_joint_anim_samplers_json(anims: &[JointKeyframes], base_accessor: u32) -> String {
133 let mut samplers: Vec<String> = Vec::new();
134 let mut acc = base_accessor;
135
136 for anim in anims {
137 if anim.translations.is_some() {
138 samplers.push(format!(
139 r#"{{ "input": {}, "output": {}, "interpolation": "LINEAR", "target": "translation", "joint": {} }}"#,
140 acc, acc + 1, anim.joint_idx
141 ));
142 acc += 2;
143 }
144 if anim.rotations.is_some() {
145 samplers.push(format!(
146 r#"{{ "input": {}, "output": {}, "interpolation": "LINEAR", "target": "rotation", "joint": {} }}"#,
147 acc, acc + 1, anim.joint_idx
148 ));
149 acc += 2;
150 }
151 if anim.scales.is_some() {
152 samplers.push(format!(
153 r#"{{ "input": {}, "output": {}, "interpolation": "LINEAR", "target": "scale", "joint": {} }}"#,
154 acc, acc + 1, anim.joint_idx
155 ));
156 acc += 2;
157 }
158 }
159
160 samplers.join(", ")
161}
162
163pub fn build_morph_anim_samplers_json(
165 times: &[f32],
166 weight_frames: &[Vec<f32>],
167 accessor_base: u32,
168) -> String {
169 let time_count = times.len();
170 let morph_count = weight_frames.first().map(|f| f.len()).unwrap_or(0);
171
172 let times_str: Vec<String> = times.iter().map(|t| format!("{:.4}", t)).collect();
173 let time_min = times.iter().cloned().fold(f32::INFINITY, f32::min);
174 let time_max = times.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
175
176 format!(
177 r#"{{ "input": {}, "output": {}, "interpolation": "LINEAR", "timesAccessor": {{ "count": {}, "min": [{:.4}], "max": [{:.4}], "times": [{}] }}, "morphCount": {}, "frameCount": {} }}"#,
178 accessor_base,
179 accessor_base + 1,
180 time_count,
181 time_min,
182 time_max,
183 times_str.join(", "),
184 morph_count,
185 weight_frames.len()
186 )
187}
188
189pub fn default_t_pose_skeleton() -> Vec<SkeletonJoint> {
195 vec![
196 SkeletonJoint {
198 name: "pelvis".to_string(),
199 parent: None,
200 bind_translation: [0.0, 0.98, 0.0],
201 bind_rotation: [0.0, 0.0, 0.0, 1.0],
202 bind_scale: [1.0, 1.0, 1.0],
203 },
204 SkeletonJoint {
206 name: "spine_lower".to_string(),
207 parent: Some(0),
208 bind_translation: [0.0, 0.12, 0.0],
209 bind_rotation: [0.0, 0.0, 0.0, 1.0],
210 bind_scale: [1.0, 1.0, 1.0],
211 },
212 SkeletonJoint {
214 name: "spine_mid".to_string(),
215 parent: Some(1),
216 bind_translation: [0.0, 0.12, 0.0],
217 bind_rotation: [0.0, 0.0, 0.0, 1.0],
218 bind_scale: [1.0, 1.0, 1.0],
219 },
220 SkeletonJoint {
222 name: "spine_upper".to_string(),
223 parent: Some(2),
224 bind_translation: [0.0, 0.12, 0.0],
225 bind_rotation: [0.0, 0.0, 0.0, 1.0],
226 bind_scale: [1.0, 1.0, 1.0],
227 },
228 SkeletonJoint {
230 name: "head".to_string(),
231 parent: Some(3),
232 bind_translation: [0.0, 0.25, 0.0],
233 bind_rotation: [0.0, 0.0, 0.0, 1.0],
234 bind_scale: [1.0, 1.0, 1.0],
235 },
236 SkeletonJoint {
238 name: "shoulder_l".to_string(),
239 parent: Some(3),
240 bind_translation: [0.18, 0.0, 0.0],
241 bind_rotation: [0.0, 0.0, 0.0, 1.0],
242 bind_scale: [1.0, 1.0, 1.0],
243 },
244 SkeletonJoint {
246 name: "shoulder_r".to_string(),
247 parent: Some(3),
248 bind_translation: [-0.18, 0.0, 0.0],
249 bind_rotation: [0.0, 0.0, 0.0, 1.0],
250 bind_scale: [1.0, 1.0, 1.0],
251 },
252 SkeletonJoint {
254 name: "elbow_l".to_string(),
255 parent: Some(5),
256 bind_translation: [0.28, 0.0, 0.0],
257 bind_rotation: [0.0, 0.0, 0.0, 1.0],
258 bind_scale: [1.0, 1.0, 1.0],
259 },
260 SkeletonJoint {
262 name: "elbow_r".to_string(),
263 parent: Some(6),
264 bind_translation: [-0.28, 0.0, 0.0],
265 bind_rotation: [0.0, 0.0, 0.0, 1.0],
266 bind_scale: [1.0, 1.0, 1.0],
267 },
268 SkeletonJoint {
270 name: "wrist_l".to_string(),
271 parent: Some(7),
272 bind_translation: [0.26, 0.0, 0.0],
273 bind_rotation: [0.0, 0.0, 0.0, 1.0],
274 bind_scale: [1.0, 1.0, 1.0],
275 },
276 SkeletonJoint {
278 name: "wrist_r".to_string(),
279 parent: Some(8),
280 bind_translation: [-0.26, 0.0, 0.0],
281 bind_rotation: [0.0, 0.0, 0.0, 1.0],
282 bind_scale: [1.0, 1.0, 1.0],
283 },
284 SkeletonJoint {
286 name: "hip_l".to_string(),
287 parent: Some(0),
288 bind_translation: [0.10, -0.08, 0.0],
289 bind_rotation: [0.0, 0.0, 0.0, 1.0],
290 bind_scale: [1.0, 1.0, 1.0],
291 },
292 SkeletonJoint {
294 name: "hip_r".to_string(),
295 parent: Some(0),
296 bind_translation: [-0.10, -0.08, 0.0],
297 bind_rotation: [0.0, 0.0, 0.0, 1.0],
298 bind_scale: [1.0, 1.0, 1.0],
299 },
300 SkeletonJoint {
302 name: "knee_l".to_string(),
303 parent: Some(11),
304 bind_translation: [0.0, -0.45, 0.0],
305 bind_rotation: [0.0, 0.0, 0.0, 1.0],
306 bind_scale: [1.0, 1.0, 1.0],
307 },
308 SkeletonJoint {
310 name: "knee_r".to_string(),
311 parent: Some(12),
312 bind_translation: [0.0, -0.45, 0.0],
313 bind_rotation: [0.0, 0.0, 0.0, 1.0],
314 bind_scale: [1.0, 1.0, 1.0],
315 },
316 SkeletonJoint {
318 name: "ankle_l".to_string(),
319 parent: Some(13),
320 bind_translation: [0.0, -0.45, 0.0],
321 bind_rotation: [0.0, 0.0, 0.0, 1.0],
322 bind_scale: [1.0, 1.0, 1.0],
323 },
324 SkeletonJoint {
326 name: "ankle_r".to_string(),
327 parent: Some(14),
328 bind_translation: [0.0, -0.45, 0.0],
329 bind_rotation: [0.0, 0.0, 0.0, 1.0],
330 bind_scale: [1.0, 1.0, 1.0],
331 },
332 ]
333}
334
335pub fn generate_idle_animation(
338 skeleton: &[SkeletonJoint],
339 fps: f32,
340 duration: f32,
341) -> Vec<JointKeyframes> {
342 let frame_count = ((fps * duration) as usize).max(2);
343 let times: Vec<f32> = (0..frame_count).map(|i| i as f32 / fps).collect();
344
345 let spine_indices: Vec<usize> = skeleton
347 .iter()
348 .enumerate()
349 .filter(|(_, j)| j.name.starts_with("spine"))
350 .map(|(i, _)| i)
351 .collect();
352
353 let mut result: Vec<JointKeyframes> = Vec::new();
354
355 for &joint_idx in &spine_indices {
356 let amplitude = 0.01f32; let freq = 0.25f32; let rotations: Vec<[f32; 4]> = times
361 .iter()
362 .map(|&t| {
363 let angle = amplitude * (2.0 * std::f32::consts::PI * freq * t).sin();
364 let half = angle * 0.5;
365 [half.sin(), 0.0, 0.0, half.cos()]
367 })
368 .collect();
369
370 result.push(JointKeyframes {
371 joint_idx,
372 times: times.clone(),
373 translations: None,
374 rotations: Some(rotations),
375 scales: None,
376 });
377 }
378
379 result
380}
381
382pub fn animated_glb_stats(result: &AnimatedGlbResult) -> String {
384 format!(
385 "AnimatedGlb: json={} bytes, bin={} bytes, joints={}, morphTargets={}, keyframes={}",
386 result.json_size,
387 result.bin_size,
388 result.joint_count,
389 result.morph_target_count,
390 result.keyframe_count,
391 )
392}
393
394#[cfg(test)]
397mod tests {
398 use super::*;
399
400 fn stub_mesh() -> MeshBuffers {
401 MeshBuffers {
402 positions: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
403 normals: vec![[0.0, 0.0, 1.0]; 3],
404 tangents: vec![[1.0, 0.0, 0.0, 1.0]; 3],
405 uvs: vec![[0.0, 0.0]; 3],
406 indices: vec![0, 1, 2],
407 colors: None,
408 has_suit: false,
409 }
410 }
411
412 #[test]
413 fn t_pose_has_17_joints() {
414 let skel = default_t_pose_skeleton();
415 assert_eq!(skel.len(), 17);
416 }
417
418 #[test]
419 fn t_pose_root_has_no_parent() {
420 let skel = default_t_pose_skeleton();
421 assert!(
422 skel[0].parent.is_none(),
423 "pelvis must be root with no parent"
424 );
425 }
426
427 #[test]
428 fn t_pose_all_non_root_have_parent() {
429 let skel = default_t_pose_skeleton();
430 for (i, j) in skel.iter().enumerate().skip(1) {
431 assert!(
432 j.parent.is_some(),
433 "joint {} ({}) should have a parent",
434 i,
435 j.name
436 );
437 }
438 }
439
440 #[test]
441 fn t_pose_parent_indices_in_range() {
442 let skel = default_t_pose_skeleton();
443 let n = skel.len();
444 for j in &skel {
445 if let Some(p) = j.parent {
446 assert!(p < n, "parent index {} out of range", p);
447 }
448 }
449 }
450
451 #[test]
452 fn build_skeleton_json_contains_joints_key() {
453 let skel = default_t_pose_skeleton();
454 let json = build_skeleton_json(&skel);
455 assert!(
456 json.contains("\"joints\""),
457 "skeleton JSON must contain 'joints'"
458 );
459 }
460
461 #[test]
462 fn build_skeleton_json_contains_armature() {
463 let skel = default_t_pose_skeleton();
464 let json = build_skeleton_json(&skel);
465 assert!(json.contains("Armature"));
466 }
467
468 #[test]
469 fn build_animated_glb_json_contains_animations() {
470 let mesh = stub_mesh();
471 let skel = default_t_pose_skeleton();
472 let idle = generate_idle_animation(&skel, 24.0, 2.0);
473 let opts = AnimatedGlbOptions {
474 include_skeleton: true,
475 include_morph_weights: false,
476 fps: 24.0,
477 duration: 2.0,
478 morph_target_names: vec![],
479 };
480 let json = build_animated_glb_json(&mesh, &skel, &idle, &[], &[], &opts);
481 assert!(
482 json.contains("\"animations\""),
483 "JSON must contain animations"
484 );
485 }
486
487 #[test]
488 fn build_animated_glb_json_contains_skins_when_skeleton_enabled() {
489 let mesh = stub_mesh();
490 let skel = default_t_pose_skeleton();
491 let opts = AnimatedGlbOptions {
492 include_skeleton: true,
493 include_morph_weights: false,
494 fps: 24.0,
495 duration: 1.0,
496 morph_target_names: vec![],
497 };
498 let json = build_animated_glb_json(&mesh, &skel, &[], &[], &[], &opts);
499 assert!(json.contains("\"skins\""));
500 }
501
502 #[test]
503 fn build_animated_glb_json_no_skins_when_skeleton_disabled() {
504 let mesh = stub_mesh();
505 let skel = default_t_pose_skeleton();
506 let opts = AnimatedGlbOptions {
507 include_skeleton: false,
508 include_morph_weights: false,
509 fps: 24.0,
510 duration: 1.0,
511 morph_target_names: vec![],
512 };
513 let json = build_animated_glb_json(&mesh, &skel, &[], &[], &[], &opts);
514 assert!(!json.contains("\"skins\""));
515 }
516
517 #[test]
518 fn generate_idle_animation_has_spine_keyframes() {
519 let skel = default_t_pose_skeleton();
520 let anims = generate_idle_animation(&skel, 24.0, 2.0);
521 assert!(!anims.is_empty(), "idle animation must have keyframe sets");
522 for anim in &anims {
524 assert!(
525 anim.rotations.is_some(),
526 "spine joints must have rotation keyframes"
527 );
528 }
529 }
530
531 #[test]
532 fn generate_idle_animation_keyframe_count() {
533 let skel = default_t_pose_skeleton();
534 let fps = 30.0f32;
535 let duration = 2.0f32;
536 let anims = generate_idle_animation(&skel, fps, duration);
537 let expected_frames = (fps * duration) as usize;
538 for anim in &anims {
539 assert_eq!(anim.times.len(), expected_frames);
540 }
541 }
542
543 #[test]
544 fn build_morph_anim_samplers_json_contains_structure() {
545 let times = vec![0.0f32, 0.5, 1.0];
546 let frames = vec![vec![0.0f32, 0.1], vec![0.5, 0.2], vec![1.0, 0.0]];
547 let json = build_morph_anim_samplers_json(×, &frames, 100);
548 assert!(json.contains("\"input\""));
549 assert!(json.contains("\"output\""));
550 assert!(json.contains("\"interpolation\""));
551 assert!(json.contains("frameCount"));
552 assert!(json.contains("morphCount"));
553 }
554
555 #[test]
556 fn animated_glb_stats_non_empty() {
557 let result = AnimatedGlbResult {
558 json_size: 1024,
559 bin_size: 4096,
560 joint_count: 17,
561 morph_target_count: 5,
562 keyframe_count: 120,
563 };
564 let s = animated_glb_stats(&result);
565 assert!(!s.is_empty());
566 assert!(s.contains("17"));
567 assert!(s.contains("120"));
568 }
569
570 #[test]
571 fn animated_glb_stats_contains_all_fields() {
572 let result = AnimatedGlbResult {
573 json_size: 500,
574 bin_size: 2000,
575 joint_count: 17,
576 morph_target_count: 3,
577 keyframe_count: 48,
578 };
579 let s = animated_glb_stats(&result);
580 assert!(s.contains("500"));
581 assert!(s.contains("2000"));
582 assert!(s.contains("3"));
583 assert!(s.contains("48"));
584 }
585
586 #[test]
587 fn keyframe_count_from_joint_keyframes() {
588 let kf = JointKeyframes {
589 joint_idx: 2,
590 times: vec![0.0, 0.5, 1.0],
591 translations: None,
592 rotations: Some(vec![[0.0, 0.0, 0.0, 1.0]; 3]),
593 scales: None,
594 };
595 assert_eq!(kf.times.len(), 3);
596 assert_eq!(kf.rotations.as_ref().expect("should succeed").len(), 3);
597 }
598
599 #[test]
600 fn options_morph_weights_flag() {
601 let mesh = stub_mesh();
602 let skel = default_t_pose_skeleton();
603 let times = vec![0.0f32, 1.0];
604 let frames = vec![vec![0.0f32], vec![1.0f32]];
605 let opts = AnimatedGlbOptions {
606 include_skeleton: false,
607 include_morph_weights: true,
608 fps: 24.0,
609 duration: 1.0,
610 morph_target_names: vec!["blink".to_string()],
611 };
612 let json = build_animated_glb_json(&mesh, &skel, &[], ×, &frames, &opts);
613 assert!(json.contains("MorphAnimation"));
614 }
615
616 #[test]
617 fn build_joint_anim_samplers_json_with_rotations() {
618 let anims = vec![JointKeyframes {
619 joint_idx: 1,
620 times: vec![0.0, 1.0],
621 translations: None,
622 rotations: Some(vec![[0.0, 0.0, 0.0, 1.0]; 2]),
623 scales: None,
624 }];
625 let json = build_joint_anim_samplers_json(&anims, 10);
626 assert!(json.contains("rotation"));
627 assert!(json.contains("\"joint\": 1"));
628 }
629}