1use std::path::Path;
7
8#[allow(dead_code)]
12#[derive(Debug, Clone, PartialEq)]
13pub struct MorphWeightKeyframe {
14 pub time: f32,
16 pub weights: Vec<f32>,
18}
19
20#[allow(dead_code)]
22#[derive(Debug, Clone, PartialEq)]
23pub enum AnimPath {
24 Translation,
25 Rotation,
26 Scale,
27 MorphWeights,
28}
29
30#[allow(dead_code)]
32#[derive(Debug, Clone, PartialEq)]
33pub struct GltfAnimChannel {
34 pub target_node: u32,
36 pub path: AnimPath,
38 pub times: Vec<f32>,
40 pub values: Vec<f32>,
42}
43
44#[allow(dead_code)]
46#[derive(Debug, Clone)]
47pub struct GltfAnimClip {
48 pub name: String,
49 pub channels: Vec<GltfAnimChannel>,
50 pub duration: f32,
52}
53
54#[allow(dead_code)]
56#[derive(Debug, Clone)]
57pub struct GltfAnimExportResult {
58 pub json: String,
60 pub accessor_count: usize,
62 pub total_keyframes: usize,
64}
65
66#[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#[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#[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#[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#[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 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 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#[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#[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#[allow(dead_code)]
234pub fn validate_morph_weights(weights: &[f32]) -> bool {
235 weights.iter().all(|&w| (0.0..=1.0).contains(&w))
236}
237
238fn 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
255fn 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 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#[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 #[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 #[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 #[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 #[test]
356 fn validate_weights_valid() {
357 assert!(validate_morph_weights(&[0.0, 0.5, 1.0]));
358 }
359
360 #[test]
362 fn validate_weights_invalid_above() {
363 assert!(!validate_morph_weights(&[0.0, 1.1]));
364 }
365
366 #[test]
368 fn validate_weights_invalid_below() {
369 assert!(!validate_morph_weights(&[-0.1, 0.5]));
370 }
371
372 #[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 #[test]
388 fn clip_duration_correct() {
389 let clip = make_clip();
390 assert!((clip_duration(&clip) - 1.0).abs() < 1e-6);
391 }
392
393 #[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 let wrapped = format!(r#"{{"animations":[{}]}}"#, json);
400 assert!(wrapped.contains("animations"));
401 }
402
403 #[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 #[test]
413 fn resample_animation_frame_count() {
414 let clip = make_clip(); let resampled = resample_animation(&clip, 10.0); assert_eq!(resampled.channels[0].times.len(), 11);
417 }
418
419 #[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 #[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 #[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 #[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}