Skip to main content

oxihuman_morph/
blend_shape_io.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Import and export blend shapes in JSON, OBJ-delta, and CSV formats.
5
6use anyhow::{anyhow, bail, Context};
7
8/// A single named blend shape with per-vertex deltas.
9#[allow(dead_code)]
10#[derive(Debug, Clone)]
11pub struct BlendShapeEntry {
12    /// Shape name.
13    pub name: String,
14    /// Per-vertex `[dx, dy, dz]`.
15    pub deltas: Vec<[f32; 3]>,
16    /// Vertex count (must equal `deltas.len()`).
17    pub vertex_count: usize,
18}
19
20/// A library of blend shapes sharing the same base mesh vertex count.
21#[allow(dead_code)]
22#[derive(Debug, Clone)]
23pub struct BlendShapeLibraryFile {
24    /// Format version (use 1).
25    pub version: u32,
26    /// Vertex count of the base mesh.
27    pub base_vertex_count: usize,
28    /// All shapes in the library.
29    pub shapes: Vec<BlendShapeEntry>,
30}
31
32// ── JSON ───────────────────────────────────────────────────────────────────
33
34/// Serialize a blend shape library to compact JSON.
35///
36/// Format: `{"version":1,"vertex_count":N,"shapes":[{"name":"...","deltas":[[dx,dy,dz],...]}]}`
37#[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/// Parse a blend shape library from compact JSON.
65#[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// ── OBJ delta ─────────────────────────────────────────────────────────────
115
116/// Export one blend shape as an OBJ file where `v = base + delta`.
117#[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/// Parse an OBJ morph target and compute `delta = parsed_v - base`.
135#[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// ── CSV ────────────────────────────────────────────────────────────────────
176
177/// Export all blend shapes as CSV: `shape_name,vertex_idx,dx,dy,dz`.
178#[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/// Parse blend shapes from CSV.
193///
194/// Expects header `shape_name,vertex_idx,dx,dy,dz`.
195#[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    // Skip header
204    let header = lines.next().unwrap_or("").trim();
205    if !header.starts_with("shape_name") {
206        bail!("missing CSV header, got: {}", header);
207    }
208
209    // name → (vertex_idx → delta)
210    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// ── Utilities ──────────────────────────────────────────────────────────────
260
261/// Return min/max/mean delta magnitude statistics as a formatted string.
262#[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/// Zero out deltas whose magnitude is below `threshold`.
279#[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/// Merge two blend shape libraries (must have the same `base_vertex_count`).
302#[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
323// ── Private helpers ────────────────────────────────────────────────────────
324
325fn json_str(s: &str) -> String {
326    // Minimal JSON string escaping
327    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// ── Tests ──────────────────────────────────────────────────────────────────
343
344#[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    // 1. export_blend_shapes_json round-trip
361    #[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    // 2. JSON contains version field
373    #[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    // 3. import_blend_shapes_json parses name and deltas
381    #[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    // 4. export_blend_shape_obj_delta has v lines
391    #[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    // 5. import_blend_shape_obj_delta recovers deltas
404    #[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    // 6. export_csv has correct columns header
422    #[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    // 7. import_blend_shapes_csv round-trip
430    #[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    // 8. blend_shape_stats non-empty returns stats string
441    #[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    // 9. blend_shape_stats empty returns "empty"
455    #[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    // 10. filter_zero_deltas removes near-zero
466    #[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    // 11. merge_blend_shape_libraries success
483    #[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    // 12. merge_blend_shape_libraries fails on vertex count mismatch
500    #[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    // 13. empty library JSON export is valid and importable
512    #[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    // 14. single shape round-trip through JSON with accurate delta values
525    #[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    // 15. vertex_count field in JSON parsed correctly
545    #[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}