1use anyhow::{anyhow, bail, Context};
7
8#[allow(dead_code)]
10#[derive(Debug, Clone)]
11pub struct BlendShapeEntry {
12 pub name: String,
14 pub deltas: Vec<[f32; 3]>,
16 pub vertex_count: usize,
18}
19
20#[allow(dead_code)]
22#[derive(Debug, Clone)]
23pub struct BlendShapeLibraryFile {
24 pub version: u32,
26 pub base_vertex_count: usize,
28 pub shapes: Vec<BlendShapeEntry>,
30}
31
32#[allow(dead_code)]
38pub fn export_blend_shapes_json(lib: &BlendShapeLibraryFile) -> String {
39 let mut buf = String::new();
40 buf.push_str(&format!(
41 "{{\"version\":{},\"vertex_count\":{},\"shapes\":[",
42 lib.version, lib.base_vertex_count
43 ));
44 for (si, shape) in lib.shapes.iter().enumerate() {
45 if si > 0 {
46 buf.push(',');
47 }
48 buf.push_str(&format!(
49 "{{\"name\":{},\"deltas\":[",
50 json_str(&shape.name)
51 ));
52 for (di, d) in shape.deltas.iter().enumerate() {
53 if di > 0 {
54 buf.push(',');
55 }
56 buf.push_str(&format!("[{},{},{}]", d[0], d[1], d[2]));
57 }
58 buf.push_str("]}");
59 }
60 buf.push_str("]}");
61 buf
62}
63
64#[allow(dead_code)]
66pub fn import_blend_shapes_json(json: &str) -> anyhow::Result<BlendShapeLibraryFile> {
67 let v: serde_json::Value = serde_json::from_str(json).context("invalid JSON")?;
68 let version = v["version"]
69 .as_u64()
70 .ok_or_else(|| anyhow!("missing version"))? as u32;
71 let base_vertex_count = v["vertex_count"]
72 .as_u64()
73 .ok_or_else(|| anyhow!("missing vertex_count"))? as usize;
74 let shapes_arr = v["shapes"]
75 .as_array()
76 .ok_or_else(|| anyhow!("missing shapes"))?;
77
78 let mut shapes = Vec::new();
79 for s in shapes_arr {
80 let name = s["name"]
81 .as_str()
82 .ok_or_else(|| anyhow!("shape missing name"))?
83 .to_string();
84 let deltas_arr = s["deltas"]
85 .as_array()
86 .ok_or_else(|| anyhow!("shape missing deltas"))?;
87 let mut deltas: Vec<[f32; 3]> = Vec::with_capacity(deltas_arr.len());
88 for d in deltas_arr {
89 let arr = d.as_array().ok_or_else(|| anyhow!("delta not array"))?;
90 if arr.len() < 3 {
91 bail!("delta too short");
92 }
93 deltas.push([
94 arr[0].as_f64().ok_or_else(|| anyhow!("delta not f64"))? as f32,
95 arr[1].as_f64().ok_or_else(|| anyhow!("delta not f64"))? as f32,
96 arr[2].as_f64().ok_or_else(|| anyhow!("delta not f64"))? as f32,
97 ]);
98 }
99 let vertex_count = deltas.len();
100 shapes.push(BlendShapeEntry {
101 name,
102 deltas,
103 vertex_count,
104 });
105 }
106
107 Ok(BlendShapeLibraryFile {
108 version,
109 base_vertex_count,
110 shapes,
111 })
112}
113
114#[allow(dead_code)]
118pub fn export_blend_shape_obj_delta(
119 entry: &BlendShapeEntry,
120 base_positions: &[[f32; 3]],
121) -> String {
122 let mut buf = String::new();
123 buf.push_str("# OBJ morph target\n");
124 buf.push_str(&format!("# shape: {}\n", entry.name));
125 for (bp, d) in base_positions.iter().zip(entry.deltas.iter()) {
126 let x = bp[0] + d[0];
127 let y = bp[1] + d[1];
128 let z = bp[2] + d[2];
129 buf.push_str(&format!("v {} {} {}\n", x, y, z));
130 }
131 buf
132}
133
134#[allow(dead_code)]
136pub fn import_blend_shape_obj_delta(
137 obj_src: &str,
138 base_positions: &[[f32; 3]],
139) -> anyhow::Result<BlendShapeEntry> {
140 let mut parsed: Vec<[f32; 3]> = Vec::new();
141 for line in obj_src.lines() {
142 let line = line.trim();
143 if !line.starts_with("v ") {
144 continue;
145 }
146 let parts: Vec<&str> = line.split_whitespace().collect();
147 if parts.len() < 4 {
148 bail!("malformed v line: {}", line);
149 }
150 let x: f32 = parts[1].parse().context("x")?;
151 let y: f32 = parts[2].parse().context("y")?;
152 let z: f32 = parts[3].parse().context("z")?;
153 parsed.push([x, y, z]);
154 }
155 if parsed.len() != base_positions.len() {
156 bail!(
157 "OBJ vertex count {} != base count {}",
158 parsed.len(),
159 base_positions.len()
160 );
161 }
162 let deltas: Vec<[f32; 3]> = parsed
163 .iter()
164 .zip(base_positions.iter())
165 .map(|(&p, &b)| [p[0] - b[0], p[1] - b[1], p[2] - b[2]])
166 .collect();
167 let vertex_count = deltas.len();
168 Ok(BlendShapeEntry {
169 name: "imported".to_string(),
170 deltas,
171 vertex_count,
172 })
173}
174
175#[allow(dead_code)]
179pub fn export_blend_shapes_csv(lib: &BlendShapeLibraryFile) -> String {
180 let mut buf = String::from("shape_name,vertex_idx,dx,dy,dz\n");
181 for shape in &lib.shapes {
182 for (vi, d) in shape.deltas.iter().enumerate() {
183 buf.push_str(&format!(
184 "{},{},{},{},{}\n",
185 shape.name, vi, d[0], d[1], d[2]
186 ));
187 }
188 }
189 buf
190}
191
192#[allow(dead_code)]
196pub fn import_blend_shapes_csv(
197 csv: &str,
198 vertex_count: usize,
199) -> anyhow::Result<BlendShapeLibraryFile> {
200 use std::collections::BTreeMap;
201
202 let mut lines = csv.lines();
203 let header = lines.next().unwrap_or("").trim();
205 if !header.starts_with("shape_name") {
206 bail!("missing CSV header, got: {}", header);
207 }
208
209 let mut map: BTreeMap<String, BTreeMap<usize, [f32; 3]>> = BTreeMap::new();
211
212 for (ln, line) in lines.enumerate() {
213 let line = line.trim();
214 if line.is_empty() {
215 continue;
216 }
217 let parts: Vec<&str> = line.split(',').collect();
218 if parts.len() < 5 {
219 bail!("line {}: expected 5 columns, got {}", ln + 2, parts.len());
220 }
221 let name = parts[0].to_string();
222 let vi: usize = parts[1]
223 .parse()
224 .with_context(|| format!("vertex_idx line {}", ln + 2))?;
225 let dx: f32 = parts[2]
226 .parse()
227 .with_context(|| format!("dx line {}", ln + 2))?;
228 let dy: f32 = parts[3]
229 .parse()
230 .with_context(|| format!("dy line {}", ln + 2))?;
231 let dz: f32 = parts[4]
232 .parse()
233 .with_context(|| format!("dz line {}", ln + 2))?;
234 map.entry(name).or_default().insert(vi, [dx, dy, dz]);
235 }
236
237 let mut shapes: Vec<BlendShapeEntry> = Vec::new();
238 for (name, vmap) in map {
239 let mut deltas = vec![[0.0f32; 3]; vertex_count];
240 for (vi, d) in vmap {
241 if vi < vertex_count {
242 deltas[vi] = d;
243 }
244 }
245 shapes.push(BlendShapeEntry {
246 name,
247 vertex_count,
248 deltas,
249 });
250 }
251
252 Ok(BlendShapeLibraryFile {
253 version: 1,
254 base_vertex_count: vertex_count,
255 shapes,
256 })
257}
258
259#[allow(dead_code)]
263pub fn blend_shape_stats(entry: &BlendShapeEntry) -> String {
264 if entry.deltas.is_empty() {
265 return "empty".to_string();
266 }
267 let mags: Vec<f32> = entry
268 .deltas
269 .iter()
270 .map(|d| (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt())
271 .collect();
272 let min = mags.iter().cloned().fold(f32::INFINITY, f32::min);
273 let max = mags.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
274 let mean = mags.iter().sum::<f32>() / mags.len() as f32;
275 format!("min={:.6} max={:.6} mean={:.6}", min, max, mean)
276}
277
278#[allow(dead_code)]
280pub fn filter_zero_deltas(entry: &BlendShapeEntry, threshold: f32) -> BlendShapeEntry {
281 let deltas = entry
282 .deltas
283 .iter()
284 .map(|&d| {
285 let mag = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
286 if mag < threshold {
287 [0.0, 0.0, 0.0]
288 } else {
289 d
290 }
291 })
292 .collect::<Vec<_>>();
293 let vertex_count = deltas.len();
294 BlendShapeEntry {
295 name: entry.name.clone(),
296 deltas,
297 vertex_count,
298 }
299}
300
301#[allow(dead_code)]
303pub fn merge_blend_shape_libraries(
304 a: BlendShapeLibraryFile,
305 b: BlendShapeLibraryFile,
306) -> anyhow::Result<BlendShapeLibraryFile> {
307 if a.base_vertex_count != b.base_vertex_count {
308 bail!(
309 "vertex count mismatch: {} vs {}",
310 a.base_vertex_count,
311 b.base_vertex_count
312 );
313 }
314 let mut shapes = a.shapes;
315 shapes.extend(b.shapes);
316 Ok(BlendShapeLibraryFile {
317 version: a.version.max(b.version),
318 base_vertex_count: a.base_vertex_count,
319 shapes,
320 })
321}
322
323fn json_str(s: &str) -> String {
326 let mut out = String::from('"');
328 for ch in s.chars() {
329 match ch {
330 '"' => out.push_str("\\\""),
331 '\\' => out.push_str("\\\\"),
332 '\n' => out.push_str("\\n"),
333 '\r' => out.push_str("\\r"),
334 '\t' => out.push_str("\\t"),
335 c => out.push(c),
336 }
337 }
338 out.push('"');
339 out
340}
341
342#[cfg(test)]
345mod tests {
346 use super::*;
347
348 fn sample_lib() -> BlendShapeLibraryFile {
349 BlendShapeLibraryFile {
350 version: 1,
351 base_vertex_count: 2,
352 shapes: vec![BlendShapeEntry {
353 name: "smile".to_string(),
354 deltas: vec![[0.1, 0.2, 0.3], [-0.1, -0.2, -0.3]],
355 vertex_count: 2,
356 }],
357 }
358 }
359
360 #[test]
362 fn test_json_roundtrip() {
363 let lib = sample_lib();
364 let json = export_blend_shapes_json(&lib);
365 let imported = import_blend_shapes_json(&json).expect("should succeed");
366 assert_eq!(imported.shapes.len(), 1);
367 assert_eq!(imported.shapes[0].name, "smile");
368 assert_eq!(imported.shapes[0].deltas.len(), 2);
369 assert!((imported.shapes[0].deltas[0][0] - 0.1).abs() < 1e-5);
370 }
371
372 #[test]
374 fn test_json_contains_version() {
375 let lib = sample_lib();
376 let json = export_blend_shapes_json(&lib);
377 assert!(json.contains("\"version\":1"));
378 }
379
380 #[test]
382 fn test_json_import_name_deltas() {
383 let json =
384 r#"{"version":1,"vertex_count":1,"shapes":[{"name":"brow","deltas":[[0.5,0.0,0.0]]}]}"#;
385 let lib = import_blend_shapes_json(json).expect("should succeed");
386 assert_eq!(lib.shapes[0].name, "brow");
387 assert!((lib.shapes[0].deltas[0][0] - 0.5).abs() < 1e-5);
388 }
389
390 #[test]
392 fn test_obj_export_has_v_lines() {
393 let entry = BlendShapeEntry {
394 name: "test".to_string(),
395 deltas: vec![[0.1, 0.2, 0.3]],
396 vertex_count: 1,
397 };
398 let base = vec![[1.0f32, 2.0, 3.0]];
399 let obj = export_blend_shape_obj_delta(&entry, &base);
400 assert!(obj.contains("v "));
401 }
402
403 #[test]
405 fn test_obj_import_recovers_deltas() {
406 let base = vec![[1.0f32, 2.0, 3.0], [4.0, 5.0, 6.0]];
407 let entry = BlendShapeEntry {
408 name: "test".to_string(),
409 deltas: vec![[0.5, -0.5, 0.1], [0.0, 0.2, -0.1]],
410 vertex_count: 2,
411 };
412 let obj = export_blend_shape_obj_delta(&entry, &base);
413 let imported = import_blend_shape_obj_delta(&obj, &base).expect("should succeed");
414 for (a, b) in entry.deltas.iter().zip(imported.deltas.iter()) {
415 assert!((a[0] - b[0]).abs() < 1e-4);
416 assert!((a[1] - b[1]).abs() < 1e-4);
417 assert!((a[2] - b[2]).abs() < 1e-4);
418 }
419 }
420
421 #[test]
423 fn test_csv_header_columns() {
424 let lib = sample_lib();
425 let csv = export_blend_shapes_csv(&lib);
426 assert!(csv.starts_with("shape_name,vertex_idx,dx,dy,dz"));
427 }
428
429 #[test]
431 fn test_csv_roundtrip() {
432 let lib = sample_lib();
433 let csv = export_blend_shapes_csv(&lib);
434 let imported = import_blend_shapes_csv(&csv, 2).expect("should succeed");
435 assert_eq!(imported.shapes.len(), 1);
436 assert_eq!(imported.shapes[0].name, "smile");
437 assert!((imported.shapes[0].deltas[0][0] - 0.1).abs() < 1e-4);
438 }
439
440 #[test]
442 fn test_blend_shape_stats_nonempty() {
443 let entry = BlendShapeEntry {
444 name: "t".to_string(),
445 deltas: vec![[3.0, 4.0, 0.0]],
446 vertex_count: 1,
447 };
448 let s = blend_shape_stats(&entry);
449 assert!(s.contains("min="));
450 assert!(s.contains("max="));
451 assert!(s.contains("mean="));
452 }
453
454 #[test]
456 fn test_blend_shape_stats_empty() {
457 let entry = BlendShapeEntry {
458 name: "e".to_string(),
459 deltas: vec![],
460 vertex_count: 0,
461 };
462 assert_eq!(blend_shape_stats(&entry), "empty");
463 }
464
465 #[test]
467 fn test_filter_zero_deltas_removes() {
468 let entry = BlendShapeEntry {
469 name: "t".to_string(),
470 deltas: vec![[0.0001, 0.0, 0.0], [1.0, 0.0, 0.0]],
471 vertex_count: 2,
472 };
473 let filtered = filter_zero_deltas(&entry, 0.01);
474 let mag0 = (filtered.deltas[0][0].powi(2)
475 + filtered.deltas[0][1].powi(2)
476 + filtered.deltas[0][2].powi(2))
477 .sqrt();
478 assert!(mag0 < 1e-6);
479 assert!((filtered.deltas[1][0] - 1.0).abs() < 1e-6);
480 }
481
482 #[test]
484 fn test_merge_success() {
485 let a = sample_lib();
486 let b = BlendShapeLibraryFile {
487 version: 1,
488 base_vertex_count: 2,
489 shapes: vec![BlendShapeEntry {
490 name: "frown".to_string(),
491 deltas: vec![[0.0, -0.1, 0.0], [0.0, -0.1, 0.0]],
492 vertex_count: 2,
493 }],
494 };
495 let merged = merge_blend_shape_libraries(a, b).expect("should succeed");
496 assert_eq!(merged.shapes.len(), 2);
497 }
498
499 #[test]
501 fn test_merge_mismatch_fails() {
502 let a = sample_lib();
503 let b = BlendShapeLibraryFile {
504 version: 1,
505 base_vertex_count: 999,
506 shapes: vec![],
507 };
508 assert!(merge_blend_shape_libraries(a, b).is_err());
509 }
510
511 #[test]
513 fn test_empty_library_json_export() {
514 let lib = BlendShapeLibraryFile {
515 version: 1,
516 base_vertex_count: 0,
517 shapes: vec![],
518 };
519 let json = export_blend_shapes_json(&lib);
520 let imported = import_blend_shapes_json(&json).expect("should succeed");
521 assert_eq!(imported.shapes.len(), 0);
522 }
523
524 #[test]
526 fn test_single_shape_json_roundtrip() {
527 let lib = BlendShapeLibraryFile {
528 version: 1,
529 base_vertex_count: 1,
530 shapes: vec![BlendShapeEntry {
531 name: "single".to_string(),
532 deltas: vec![[1.5, -2.5, 3.77]],
533 vertex_count: 1,
534 }],
535 };
536 let json = export_blend_shapes_json(&lib);
537 let imported = import_blend_shapes_json(&json).expect("should succeed");
538 let d = &imported.shapes[0].deltas[0];
539 assert!((d[0] - 1.5).abs() < 1e-4);
540 assert!((d[1] - (-2.5)).abs() < 1e-4);
541 assert!((d[2] - 3.77).abs() < 1e-3);
542 }
543
544 #[test]
546 fn test_json_vertex_count_field() {
547 let lib = sample_lib();
548 let json = export_blend_shapes_json(&lib);
549 let imported = import_blend_shapes_json(&json).expect("should succeed");
550 assert_eq!(imported.base_vertex_count, 2);
551 }
552}