Skip to main content

oxihuman_export/
animated_glb.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4#![allow(dead_code)]
5
6use oxihuman_mesh::MeshBuffers;
7
8// ── Data types ────────────────────────────────────────────────────────────────
9
10/// A single joint in a skeletal hierarchy.
11pub struct SkeletonJoint {
12    pub name: String,
13    pub parent: Option<usize>,
14    pub bind_translation: [f32; 3],
15    pub bind_rotation: [f32; 4], // xyzw quaternion
16    pub bind_scale: [f32; 3],
17}
18
19/// Per-joint animation keyframe data.
20pub 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
28/// Options controlling animated GLB output.
29pub 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
37/// Summary of a build_animated_glb_json call.
38pub 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// ── Public API ────────────────────────────────────────────────────────────────
47
48/// Produce a GLTF JSON string with skins + animations sections.
49#[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    // Mesh primitive
64    sections.push(r#""meshes": [{ "name": "Body", "primitives": [{ "attributes": { "POSITION": 0 }, "indices": 1 }] }]"#.to_string());
65
66    // Asset block
67    sections
68        .push(r#""asset": { "version": "2.0", "generator": "OxiHuman animated_glb" }"#.to_string());
69
70    // Mesh statistics comment (embedded in extras)
71    sections.push(format!(
72        r#""extras": {{ "vertexCount": {}, "faceCount": {} }}"#,
73        vertex_count, face_count
74    ));
75
76    // Skins section
77    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    // Animations section
83    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    // Morph target names
106    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
118/// Produce the "skins" array entry JSON for the given joints.
119pub 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
131/// Produce GLTF animation sampler JSON entries for joint keyframes.
132pub 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
163/// Produce GLTF morph weight animation sampler JSON.
164pub 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
189/// Return a 17-joint T-pose biped skeleton.
190///
191/// Joints: pelvis, spine_lower, spine_mid, spine_upper, head,
192/// shoulder_l, shoulder_r, elbow_l, elbow_r, wrist_l, wrist_r,
193/// hip_l, hip_r, knee_l, knee_r, ankle_l, ankle_r
194pub fn default_t_pose_skeleton() -> Vec<SkeletonJoint> {
195    vec![
196        // 0: pelvis (root)
197        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        // 1: spine_lower
205        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        // 2: spine_mid
213        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        // 3: spine_upper
221        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        // 4: head
229        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        // 5: shoulder_l
237        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        // 6: shoulder_r
245        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        // 7: elbow_l
253        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        // 8: elbow_r
261        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        // 9: wrist_l
269        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        // 10: wrist_r
277        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        // 11: hip_l
285        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        // 12: hip_r
293        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        // 13: knee_l
301        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        // 14: knee_r
309        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        // 15: ankle_l
317        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        // 16: ankle_r
325        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
335/// Generate a subtle idle (breathing) animation as sinusoidal rotation
336/// keyframes on the three spine joints (indices 1, 2, 3).
337pub 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    // Spine joints: look for joints named spine_* or with parent chain through index 0
346    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        // Breathing: gentle sinusoidal rotation around X axis
357        let amplitude = 0.01f32; // ~0.57 degrees
358        let freq = 0.25f32; // 0.25 Hz breathing cycle
359
360        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                // quaternion for rotation around X
366                [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
382/// Return a human-readable summary string for AnimatedGlbResult.
383pub 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// ── Tests ─────────────────────────────────────────────────────────────────────
395
396#[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        // All should have rotations (breathing)
523        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(&times, &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, &[], &times, &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}