1use anyhow::{bail, Result};
7use serde_json::{json, Value};
8
9pub struct AnimKeyframe {
11 pub time_s: f32,
13 pub weights: Vec<f32>,
15}
16
17pub struct AnimClip {
19 pub name: String,
20 pub keyframes: Vec<AnimKeyframe>,
21}
22
23fn to_base64(data: &[u8]) -> String {
26 const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
27 let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
28 for chunk in data.chunks(3) {
29 let b0 = chunk[0] as u32;
30 let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
31 let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
32 let combined = (b0 << 16) | (b1 << 8) | b2;
33 out.push(CHARS[((combined >> 18) & 0x3F) as usize] as char);
34 out.push(CHARS[((combined >> 12) & 0x3F) as usize] as char);
35 out.push(if chunk.len() > 1 {
36 CHARS[((combined >> 6) & 0x3F) as usize] as char
37 } else {
38 '='
39 });
40 out.push(if chunk.len() > 2 {
41 CHARS[(combined & 0x3F) as usize] as char
42 } else {
43 '='
44 });
45 }
46 out
47}
48
49fn push_f32_slice(buf: &mut Vec<u8>, values: &[f32]) {
53 for &v in values {
54 buf.extend_from_slice(&v.to_le_bytes());
55 }
56}
57
58fn positions_to_bytes(positions: &[[f32; 3]]) -> Vec<u8> {
60 let mut buf = Vec::with_capacity(positions.len() * 12);
61 for p in positions {
62 for &c in p {
63 buf.extend_from_slice(&c.to_le_bytes());
64 }
65 }
66 buf
67}
68
69fn compute_deltas(base: &[[f32; 3]], morph: &[[f32; 3]]) -> Vec<[f32; 3]> {
71 base.iter()
72 .zip(morph.iter())
73 .map(|(b, m)| [m[0] - b[0], m[1] - b[1], m[2] - b[2]])
74 .collect()
75}
76
77pub fn export_animation_gltf(
88 base_positions: &[[f32; 3]],
89 morph_target_positions: &[Vec<[f32; 3]>],
90 clip: &AnimClip,
91) -> Result<String> {
92 let n_verts = base_positions.len();
93 let n_targets = morph_target_positions.len();
94
95 for (i, target) in morph_target_positions.iter().enumerate() {
97 if target.len() != n_verts {
98 bail!(
99 "morph_target_positions[{}] has {} verts, expected {}",
100 i,
101 target.len(),
102 n_verts
103 );
104 }
105 }
106
107 for (ki, kf) in clip.keyframes.iter().enumerate() {
109 if kf.weights.len() != n_targets {
110 bail!(
111 "keyframe[{}].weights has {} entries, expected {} (n_targets)",
112 ki,
113 kf.weights.len(),
114 n_targets
115 );
116 }
117 }
118
119 let mut buffer: Vec<u8> = Vec::new();
128
129 let append_section = |buffer: &mut Vec<u8>, data: &[u8]| -> (usize, usize) {
132 let offset = buffer.len();
133 let length = data.len();
134 buffer.extend_from_slice(data);
135 while !buffer.len().is_multiple_of(4) {
137 buffer.push(0x00);
138 }
139 (offset, length)
140 };
141
142 let base_bytes = positions_to_bytes(base_positions);
144 let (base_pos_offset, base_pos_len) = append_section(&mut buffer, &base_bytes);
145
146 let mut delta_sections: Vec<(usize, usize)> = Vec::with_capacity(n_targets);
148 for target in morph_target_positions {
149 let deltas = compute_deltas(base_positions, target);
150 let delta_bytes = positions_to_bytes(&deltas);
151 let sec = append_section(&mut buffer, &delta_bytes);
152 delta_sections.push(sec);
153 }
154
155 let has_animation = !clip.keyframes.is_empty();
157 let n_keyframes = clip.keyframes.len();
158
159 let times_section: (usize, usize);
160 let weights_section: (usize, usize);
161
162 if has_animation {
163 let mut times_bytes: Vec<u8> = Vec::with_capacity(n_keyframes * 4);
165 for kf in &clip.keyframes {
166 push_f32_slice(&mut times_bytes, &[kf.time_s]);
167 }
168 times_section = append_section(&mut buffer, ×_bytes);
169
170 let total_weights = n_keyframes * n_targets;
172 let mut weights_bytes: Vec<u8> = Vec::with_capacity(total_weights * 4);
173 for kf in &clip.keyframes {
174 push_f32_slice(&mut weights_bytes, &kf.weights);
175 }
176 weights_section = append_section(&mut buffer, &weights_bytes);
177 } else {
178 times_section = (0, 0);
179 weights_section = (0, 0);
180 }
181
182 let times_accessor_idx = (n_targets + 1) as u64;
192 let weights_accessor_idx = (n_targets + 2) as u64;
193
194 let mut buffer_views: Vec<Value> = Vec::new();
196 let mut accessors: Vec<Value> = Vec::new();
197
198 buffer_views.push(json!({
200 "buffer": 0,
201 "byteOffset": base_pos_offset,
202 "byteLength": base_pos_len
203 }));
204 accessors.push(json!({
205 "bufferView": 0,
206 "componentType": 5126, "count": n_verts,
208 "type": "VEC3"
209 }));
210
211 for (i, &(offset, length)) in delta_sections.iter().enumerate() {
213 let bv_idx = (i + 1) as u64;
214 buffer_views.push(json!({
215 "buffer": 0,
216 "byteOffset": offset,
217 "byteLength": length
218 }));
219 accessors.push(json!({
220 "bufferView": bv_idx,
221 "componentType": 5126,
222 "count": n_verts,
223 "type": "VEC3"
224 }));
225 }
226
227 if has_animation {
229 let bv_times = (n_targets + 1) as u64;
230 let (t_offset, t_length) = times_section;
231 buffer_views.push(json!({
232 "buffer": 0,
233 "byteOffset": t_offset,
234 "byteLength": t_length
235 }));
236 accessors.push(json!({
237 "bufferView": bv_times,
238 "componentType": 5126,
239 "count": n_keyframes,
240 "type": "SCALAR"
241 }));
242
243 let bv_weights = (n_targets + 2) as u64;
245 let (w_offset, w_length) = weights_section;
246 buffer_views.push(json!({
247 "buffer": 0,
248 "byteOffset": w_offset,
249 "byteLength": w_length
250 }));
251 accessors.push(json!({
252 "bufferView": bv_weights,
253 "componentType": 5126,
254 "count": n_keyframes * n_targets,
255 "type": "SCALAR"
256 }));
257 }
258
259 let targets: Vec<Value> = (0..n_targets)
261 .map(|i| json!({ "POSITION": i + 1 }))
262 .collect();
263
264 let initial_weights: Vec<f32> = vec![0.0_f32; n_targets];
265
266 let animations: Value = if has_animation {
268 json!([{
269 "name": clip.name,
270 "samplers": [{
271 "input": times_accessor_idx,
272 "output": weights_accessor_idx,
273 "interpolation": "LINEAR"
274 }],
275 "channels": [{
276 "sampler": 0,
277 "target": { "node": 0, "path": "weights" }
278 }]
279 }])
280 } else {
281 json!([])
282 };
283
284 let b64 = to_base64(&buffer);
286 let data_uri = format!("data:application/octet-stream;base64,{}", b64);
287
288 let gltf = json!({
290 "asset": { "version": "2.0", "generator": "OxiHuman 0.1.0" },
291 "scene": 0,
292 "scenes": [{ "nodes": [0] }],
293 "nodes": [{ "mesh": 0 }],
294 "meshes": [{
295 "name": clip.name,
296 "primitives": [{
297 "attributes": { "POSITION": 0 },
298 "mode": 4,
299 "targets": targets
300 }],
301 "weights": initial_weights
302 }],
303 "accessors": accessors,
304 "bufferViews": buffer_views,
305 "buffers": [{
306 "uri": data_uri,
307 "byteLength": buffer.len()
308 }],
309 "animations": animations
310 });
311
312 Ok(serde_json::to_string_pretty(&gltf)?)
313}
314
315#[cfg(test)]
318mod tests {
319 use super::*;
320 use serde_json::Value;
321
322 fn make_base(n_verts: usize) -> Vec<[f32; 3]> {
324 (0..n_verts).map(|i| [i as f32, 0.0, 0.0]).collect()
325 }
326
327 fn make_target(base: &[[f32; 3]], offset: f32) -> Vec<[f32; 3]> {
328 base.iter().map(|&[x, y, z]| [x + offset, y, z]).collect()
329 }
330
331 fn two_keyframe_clip(n_targets: usize) -> AnimClip {
332 AnimClip {
333 name: "Test".to_string(),
334 keyframes: vec![
335 AnimKeyframe {
336 time_s: 0.0,
337 weights: vec![0.0; n_targets],
338 },
339 AnimKeyframe {
340 time_s: 1.0,
341 weights: vec![1.0; n_targets],
342 },
343 ],
344 }
345 }
346
347 #[test]
350 fn animation_json_parses() {
351 let base = make_base(4);
352 let targets = vec![make_target(&base, 0.1)];
353 let clip = two_keyframe_clip(1);
354 let json_str = export_animation_gltf(&base, &targets, &clip).expect("should succeed");
355 let val: Value = serde_json::from_str(&json_str).expect("must parse as JSON");
356 assert_eq!(val["asset"]["version"].as_str().expect("should succeed"), "2.0");
357 }
358
359 #[test]
362 fn animation_has_correct_target_count() {
363 let base = make_base(5);
364 let targets: Vec<Vec<[f32; 3]>> =
365 (0..3).map(|i| make_target(&base, i as f32 * 0.1)).collect();
366 let clip = two_keyframe_clip(3);
367 let json_str = export_animation_gltf(&base, &targets, &clip).expect("should succeed");
368 let val: Value = serde_json::from_str(&json_str).expect("should succeed");
369 let tgt_arr = val["meshes"][0]["primitives"][0]["targets"]
370 .as_array()
371 .expect("should succeed");
372 assert_eq!(tgt_arr.len(), 3);
373 }
374
375 #[test]
378 fn animation_keyframe_count_matches() {
379 let n_targets = 2usize;
380 let n_keyframes = 4usize;
381 let base = make_base(3);
382 let targets: Vec<Vec<[f32; 3]>> = (0..n_targets)
383 .map(|i| make_target(&base, i as f32 * 0.05))
384 .collect();
385 let clip = AnimClip {
386 name: "Walk".to_string(),
387 keyframes: (0..n_keyframes)
388 .map(|k| AnimKeyframe {
389 time_s: k as f32 * 0.25,
390 weights: vec![k as f32 / n_keyframes as f32; n_targets],
391 })
392 .collect(),
393 };
394 let json_str = export_animation_gltf(&base, &targets, &clip).expect("should succeed");
395 let val: Value = serde_json::from_str(&json_str).expect("should succeed");
396
397 let accessors = val["accessors"].as_array().expect("should succeed");
399 let weights_acc = accessors.last().expect("should succeed");
400 assert_eq!(
401 weights_acc["count"].as_u64().expect("should succeed"),
402 (n_keyframes * n_targets) as u64
403 );
404 }
405
406 #[test]
409 fn empty_clip_no_animation() {
410 let base = make_base(4);
411 let targets = vec![make_target(&base, 0.1)];
412 let clip = AnimClip {
413 name: "Empty".to_string(),
414 keyframes: vec![],
415 };
416 let json_str = export_animation_gltf(&base, &targets, &clip).expect("should succeed");
417 let val: Value = serde_json::from_str(&json_str).expect("should succeed");
418 let anim = val["animations"].as_array().expect("should succeed");
419 assert!(anim.is_empty(), "animations should be empty for empty clip");
420 }
421
422 #[test]
425 fn base64_roundtrip() {
426 assert_eq!(to_base64(b"Man"), "TWFu");
428 assert_eq!(to_base64(b"Ma"), "TWE=");
430 assert_eq!(to_base64(b"M"), "TQ==");
432 assert_eq!(to_base64(b""), "");
434 assert_eq!(to_base64(b"Hello"), "SGVsbG8=");
436 }
437
438 #[test]
445 fn weights_flattened_order() {
446 let base = make_base(2);
447 let targets = vec![make_target(&base, 0.1)];
448 let clip = AnimClip {
449 name: "Weights".to_string(),
450 keyframes: vec![
451 AnimKeyframe {
452 time_s: 0.0,
453 weights: vec![0.25_f32],
454 },
455 AnimKeyframe {
456 time_s: 1.0,
457 weights: vec![0.75_f32],
458 },
459 ],
460 };
461
462 let json_str = export_animation_gltf(&base, &targets, &clip).expect("should succeed");
463 let val: Value = serde_json::from_str(&json_str).expect("should succeed");
464
465 let uri = val["buffers"][0]["uri"].as_str().expect("should succeed");
467 let b64_data = uri
468 .strip_prefix("data:application/octet-stream;base64,")
469 .expect("should succeed");
470 let raw = decode_base64(b64_data);
471
472 let bvs = val["bufferViews"].as_array().expect("should succeed");
474 let weights_bv = bvs.last().expect("should succeed");
475 let offset = weights_bv["byteOffset"].as_u64().expect("should succeed") as usize;
476 let length = weights_bv["byteLength"].as_u64().expect("should succeed") as usize;
477
478 let weights_bytes = &raw[offset..offset + length];
479
480 let mut expected = Vec::new();
482 expected.extend_from_slice(&0.25_f32.to_le_bytes());
483 expected.extend_from_slice(&0.75_f32.to_le_bytes());
484
485 assert_eq!(weights_bytes, expected.as_slice());
486 }
487
488 fn decode_base64(s: &str) -> Vec<u8> {
490 const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
491 let mut out = Vec::new();
492 let bytes: Vec<u8> = s.bytes().filter(|&b| b != b'=').collect();
493 let lookup = |c: u8| {
494 CHARS
495 .iter()
496 .position(|&x| x == c)
497 .expect("invalid base64 character") as u32
498 };
499 let mut i = 0;
500 let pad = s.bytes().filter(|&b| b == b'=').count();
502 let chunks_len = bytes.len().div_ceil(4);
503 while i < chunks_len {
504 let start = i * 4;
505 let get = |j: usize| {
506 if start + j < bytes.len() {
507 lookup(bytes[start + j])
508 } else {
509 0
510 }
511 };
512 let combined = (get(0) << 18) | (get(1) << 12) | (get(2) << 6) | get(3);
513 out.push(((combined >> 16) & 0xFF) as u8);
514 if !(i == chunks_len - 1 && pad >= 2) {
515 out.push(((combined >> 8) & 0xFF) as u8);
516 }
517 if !(i == chunks_len - 1 && pad >= 1) {
518 out.push((combined & 0xFF) as u8);
519 }
520 i += 1;
521 }
522 out
523 }
524}