Skip to main content

oxihuman_export/
gltf_anim.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! GLTF animation export with morph target weight keyframes.
5
6use std::path::Path;
7
8// ── Data structures ───────────────────────────────────────────────────────────
9
10/// A single keyframe of morph-target weights at a given time.
11#[allow(dead_code)]
12#[derive(Debug, Clone, PartialEq)]
13pub struct MorphWeightKeyframe {
14    /// Time in seconds.
15    pub time: f32,
16    /// One weight per morph target, in [0.0, 1.0].
17    pub weights: Vec<f32>,
18}
19
20/// Path type for a GLTF animation channel.
21#[allow(dead_code)]
22#[derive(Debug, Clone, PartialEq)]
23pub enum AnimPath {
24    Translation,
25    Rotation,
26    Scale,
27    MorphWeights,
28}
29
30/// A single animation channel targeting one node property.
31#[allow(dead_code)]
32#[derive(Debug, Clone, PartialEq)]
33pub struct GltfAnimChannel {
34    /// Index of the target node in the GLTF node array.
35    pub target_node: u32,
36    /// Which property is animated.
37    pub path: AnimPath,
38    /// Time stamps for each keyframe (seconds).
39    pub times: Vec<f32>,
40    /// Flattened values. For `MorphWeights`, len = `times.len() * n_morphs`.
41    pub values: Vec<f32>,
42}
43
44/// A complete animation clip with one or more channels.
45#[allow(dead_code)]
46#[derive(Debug, Clone)]
47pub struct GltfAnimClip {
48    pub name: String,
49    pub channels: Vec<GltfAnimChannel>,
50    /// Total duration in seconds (max time across all channels).
51    pub duration: f32,
52}
53
54/// Result of exporting a GLTF animation.
55#[allow(dead_code)]
56#[derive(Debug, Clone)]
57pub struct GltfAnimExportResult {
58    /// GLTF animation JSON fragment.
59    pub json: String,
60    /// Number of accessors written.
61    pub accessor_count: usize,
62    /// Total keyframes across all channels.
63    pub total_keyframes: usize,
64}
65
66// ── Core functions ────────────────────────────────────────────────────────────
67
68/// Build a morph-weight animation channel from a slice of keyframes.
69///
70/// The `times` and `values` (flattened weights) are extracted from the keyframes.
71#[allow(dead_code)]
72pub fn build_morph_anim_channel(node: u32, keyframes: &[MorphWeightKeyframe]) -> GltfAnimChannel {
73    let times: Vec<f32> = keyframes.iter().map(|kf| kf.time).collect();
74    let values: Vec<f32> = keyframes
75        .iter()
76        .flat_map(|kf| kf.weights.iter().copied())
77        .collect();
78    GltfAnimChannel {
79        target_node: node,
80        path: AnimPath::MorphWeights,
81        times,
82        values,
83    }
84}
85
86/// Produce a compact GLTF animation node JSON fragment.
87///
88/// Each channel gets a sampler with `input` (time accessor) and `output` (value accessor).
89/// Accessor indices start at `first_accessor_idx` and increment by 2 per channel.
90#[allow(dead_code)]
91pub fn build_gltf_anim_json(clip: &GltfAnimClip, first_accessor_idx: u32) -> String {
92    let mut channels_json = Vec::new();
93    let mut samplers_json = Vec::new();
94
95    for (i, ch) in clip.channels.iter().enumerate() {
96        let path_str = match ch.path {
97            AnimPath::Translation => "translation",
98            AnimPath::Rotation => "rotation",
99            AnimPath::Scale => "scale",
100            AnimPath::MorphWeights => "weights",
101        };
102        let sampler_idx = i as u32;
103        let input_acc = first_accessor_idx + i as u32 * 2;
104        let output_acc = first_accessor_idx + i as u32 * 2 + 1;
105
106        channels_json.push(format!(
107            r#"{{"sampler":{},"target":{{"node":{},"path":"{}"}}}}"#,
108            sampler_idx, ch.target_node, path_str
109        ));
110        samplers_json.push(format!(
111            r#"{{"input":{},"interpolation":"LINEAR","output":{}}}"#,
112            input_acc, output_acc
113        ));
114    }
115
116    format!(
117        r#"{{"name":"{}","channels":[{}],"samplers":[{}]}}"#,
118        json_escape(&clip.name),
119        channels_json.join(","),
120        samplers_json.join(",")
121    )
122}
123
124/// Produce a GLTF accessor JSON object for a flat f32 array.
125///
126/// `accessor_type` is e.g. `"SCALAR"` or `"VEC3"`.
127/// `component_type` 5126 = `FLOAT`.
128#[allow(dead_code)]
129pub fn build_gltf_accessor_json(
130    data: &[f32],
131    accessor_type: &str,
132    component_type: u32,
133    idx: u32,
134) -> String {
135    let min_val = data.iter().cloned().fold(f32::INFINITY, f32::min);
136    let max_val = data.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
137    format!(
138        r#"{{"bufferView":{},"componentType":{},"count":{},"type":"{}","min":[{}],"max":[{}]}}"#,
139        idx,
140        component_type,
141        data.len(),
142        accessor_type,
143        min_val,
144        max_val
145    )
146}
147
148/// Write the animation JSON fragment for `clip` to `path`.
149#[allow(dead_code)]
150pub fn export_morph_animation(clip: &GltfAnimClip, path: &Path) -> anyhow::Result<()> {
151    let json = build_gltf_anim_json(clip, 0);
152    let wrapper = format!(r#"{{"animations":[{}]}}"#, json);
153    std::fs::write(path, wrapper)?;
154    Ok(())
155}
156
157/// Resample `clip` to uniform `fps` via linear interpolation.
158///
159/// Returns a new `GltfAnimClip` whose channel time stamps are uniform.
160#[allow(dead_code)]
161pub fn resample_animation(clip: &GltfAnimClip, fps: f32) -> GltfAnimClip {
162    let duration = clip_duration(clip);
163    if fps <= 0.0 || duration <= 0.0 {
164        return clip.clone();
165    }
166
167    let frame_dt = 1.0 / fps;
168    let n_frames = (duration * fps).ceil() as usize + 1;
169
170    let new_channels: Vec<GltfAnimChannel> = clip
171        .channels
172        .iter()
173        .map(|ch| {
174            if ch.times.is_empty() {
175                return ch.clone();
176            }
177            // Determine how many values per time step
178            let n_morphs = if ch.times.len() > 1 {
179                ch.values.len() / ch.times.len()
180            } else {
181                ch.values.len()
182            };
183            let n_morphs = n_morphs.max(1);
184
185            let mut new_times = Vec::with_capacity(n_frames);
186            let mut new_values = Vec::with_capacity(n_frames * n_morphs);
187
188            for frame in 0..n_frames {
189                let t = (frame as f32 * frame_dt).min(duration);
190                new_times.push(t);
191
192                // find segment
193                let weights = sample_weights_at(ch, t, n_morphs);
194                new_values.extend_from_slice(&weights);
195            }
196
197            GltfAnimChannel {
198                target_node: ch.target_node,
199                path: ch.path.clone(),
200                times: new_times,
201                values: new_values,
202            }
203        })
204        .collect();
205
206    GltfAnimClip {
207        name: clip.name.clone(),
208        channels: new_channels,
209        duration,
210    }
211}
212
213/// Linear interpolation between two weight slices, element-wise.
214///
215/// `t` is clamped to [0, 1].
216#[allow(dead_code)]
217pub fn lerp_weights(a: &[f32], b: &[f32], t: f32) -> Vec<f32> {
218    let t = t.clamp(0.0, 1.0);
219    let len = a.len().min(b.len());
220    (0..len).map(|i| a[i] + (b[i] - a[i]) * t).collect()
221}
222
223/// Return the maximum time across all channels.
224#[allow(dead_code)]
225pub fn clip_duration(clip: &GltfAnimClip) -> f32 {
226    clip.channels
227        .iter()
228        .flat_map(|ch| ch.times.iter().copied())
229        .fold(0.0f32, f32::max)
230}
231
232/// Return `true` if every weight is in the range [0.0, 1.0].
233#[allow(dead_code)]
234pub fn validate_morph_weights(weights: &[f32]) -> bool {
235    weights.iter().all(|&w| (0.0..=1.0).contains(&w))
236}
237
238// ── Private helpers ───────────────────────────────────────────────────────────
239
240fn json_escape(s: &str) -> String {
241    let mut out = String::with_capacity(s.len());
242    for ch in s.chars() {
243        match ch {
244            '"' => out.push_str("\\\""),
245            '\\' => out.push_str("\\\\"),
246            '\n' => out.push_str("\\n"),
247            '\r' => out.push_str("\\r"),
248            '\t' => out.push_str("\\t"),
249            other => out.push(other),
250        }
251    }
252    out
253}
254
255/// Sample linearly-interpolated weights from a channel at time `t`.
256fn sample_weights_at(ch: &GltfAnimChannel, t: f32, n_morphs: usize) -> Vec<f32> {
257    if ch.times.is_empty() {
258        return vec![0.0; n_morphs];
259    }
260    if t <= ch.times[0] {
261        return ch.values[..n_morphs.min(ch.values.len())].to_vec();
262    }
263    if ch.times.last().is_none_or(|last| t >= *last) {
264        let start = ch.values.len().saturating_sub(n_morphs);
265        return ch.values[start..].to_vec();
266    }
267
268    // Find the two surrounding keyframes
269    let idx = ch
270        .times
271        .windows(2)
272        .position(|w| t >= w[0] && t < w[1])
273        .unwrap_or(ch.times.len() - 2);
274
275    let t0 = ch.times[idx];
276    let t1 = ch.times[idx + 1];
277    let alpha = if (t1 - t0).abs() < 1e-9 {
278        0.0
279    } else {
280        (t - t0) / (t1 - t0)
281    };
282
283    let a_start = idx * n_morphs;
284    let b_start = (idx + 1) * n_morphs;
285    let a_end = (a_start + n_morphs).min(ch.values.len());
286    let b_end = (b_start + n_morphs).min(ch.values.len());
287
288    if a_end <= a_start || b_end <= b_start {
289        return vec![0.0; n_morphs];
290    }
291
292    lerp_weights(
293        &ch.values[a_start..a_end],
294        &ch.values[b_start..b_end],
295        alpha,
296    )
297}
298
299// ── Tests ─────────────────────────────────────────────────────────────────────
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    fn make_clip() -> GltfAnimClip {
306        let kf0 = MorphWeightKeyframe {
307            time: 0.0,
308            weights: vec![0.0, 0.5],
309        };
310        let kf1 = MorphWeightKeyframe {
311            time: 1.0,
312            weights: vec![1.0, 0.5],
313        };
314        let ch = build_morph_anim_channel(0, &[kf0, kf1]);
315        GltfAnimClip {
316            name: "test_clip".to_string(),
317            channels: vec![ch],
318            duration: 1.0,
319        }
320    }
321
322    // 1. lerp_weights at t=0 returns a
323    #[test]
324    fn lerp_t0_returns_a() {
325        let a = vec![0.2, 0.4, 0.6];
326        let b = vec![0.8, 1.0, 0.0];
327        let result = lerp_weights(&a, &b, 0.0);
328        for (r, &av) in result.iter().zip(a.iter()) {
329            assert!((r - av).abs() < 1e-6);
330        }
331    }
332
333    // 2. lerp_weights at t=1 returns b
334    #[test]
335    fn lerp_t1_returns_b() {
336        let a = vec![0.2, 0.4];
337        let b = vec![0.8, 1.0];
338        let result = lerp_weights(&a, &b, 1.0);
339        for (r, &bv) in result.iter().zip(b.iter()) {
340            assert!((r - bv).abs() < 1e-6);
341        }
342    }
343
344    // 3. lerp_weights at t=0.5 is midpoint
345    #[test]
346    fn lerp_t05_is_midpoint() {
347        let a = vec![0.0, 0.0];
348        let b = vec![1.0, 1.0];
349        let result = lerp_weights(&a, &b, 0.5);
350        assert!((result[0] - 0.5).abs() < 1e-6);
351        assert!((result[1] - 0.5).abs() < 1e-6);
352    }
353
354    // 4. validate_morph_weights — valid
355    #[test]
356    fn validate_weights_valid() {
357        assert!(validate_morph_weights(&[0.0, 0.5, 1.0]));
358    }
359
360    // 5. validate_morph_weights — invalid (>1)
361    #[test]
362    fn validate_weights_invalid_above() {
363        assert!(!validate_morph_weights(&[0.0, 1.1]));
364    }
365
366    // 6. validate_morph_weights — invalid (<0)
367    #[test]
368    fn validate_weights_invalid_below() {
369        assert!(!validate_morph_weights(&[-0.1, 0.5]));
370    }
371
372    // 7. build_morph_anim_channel time count
373    #[test]
374    fn build_channel_time_count() {
375        let keyframes: Vec<MorphWeightKeyframe> = (0..5)
376            .map(|i| MorphWeightKeyframe {
377                time: i as f32 * 0.25,
378                weights: vec![0.5],
379            })
380            .collect();
381        let ch = build_morph_anim_channel(1, &keyframes);
382        assert_eq!(ch.times.len(), 5);
383        assert_eq!(ch.target_node, 1);
384    }
385
386    // 8. clip_duration
387    #[test]
388    fn clip_duration_correct() {
389        let clip = make_clip();
390        assert!((clip_duration(&clip) - 1.0).abs() < 1e-6);
391    }
392
393    // 9. build_gltf_anim_json contains "animations" (via wrapper)
394    #[test]
395    fn build_gltf_anim_json_contains_animations_key() {
396        let clip = make_clip();
397        let json = build_gltf_anim_json(&clip, 0);
398        // wrap it the same way export does
399        let wrapped = format!(r#"{{"animations":[{}]}}"#, json);
400        assert!(wrapped.contains("animations"));
401    }
402
403    // 10. build_gltf_anim_json contains name
404    #[test]
405    fn build_gltf_anim_json_contains_name() {
406        let clip = make_clip();
407        let json = build_gltf_anim_json(&clip, 0);
408        assert!(json.contains("test_clip"));
409    }
410
411    // 11. resample_animation frame count
412    #[test]
413    fn resample_animation_frame_count() {
414        let clip = make_clip(); // duration = 1.0
415        let resampled = resample_animation(&clip, 10.0); // 10 fps → 11 frames (0..=10)
416        assert_eq!(resampled.channels[0].times.len(), 11);
417    }
418
419    // 12. GltfAnimChannel path is MorphWeights
420    #[test]
421    fn build_channel_path_is_morph_weights() {
422        let kf = MorphWeightKeyframe {
423            time: 0.0,
424            weights: vec![0.5],
425        };
426        let ch = build_morph_anim_channel(0, &[kf]);
427        assert_eq!(ch.path, AnimPath::MorphWeights);
428    }
429
430    // 13. build_gltf_accessor_json contains componentType 5126
431    #[test]
432    fn accessor_json_contains_component_type_5126() {
433        let data = vec![0.0f32, 0.5, 1.0];
434        let json = build_gltf_accessor_json(&data, "SCALAR", 5126, 0);
435        assert!(json.contains("5126"));
436        assert!(json.contains("SCALAR"));
437    }
438
439    // 14. export_morph_animation writes file
440    #[test]
441    fn export_morph_animation_writes_file() {
442        let clip = make_clip();
443        let path = std::path::Path::new("/tmp/test_morph_anim.json");
444        export_morph_animation(&clip, path).expect("export should succeed");
445        let content = std::fs::read_to_string(path).expect("file should exist");
446        assert!(content.contains("animations"));
447    }
448
449    // 15. lerp_weights handles mismatched lengths (uses min)
450    #[test]
451    fn lerp_weights_mismatched_length() {
452        let a = vec![0.0, 0.5, 1.0];
453        let b = vec![1.0, 0.5];
454        let result = lerp_weights(&a, &b, 0.5);
455        assert_eq!(result.len(), 2);
456    }
457}