Skip to main content

oxihuman_export/
morph_export.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Morph target export: pack blend-shape deltas into GLB-compatible format.
5
6// ── Structs / Enums ──────────────────────────────────────────────────────────
7
8/// A single morph target with delta positions and optional delta normals.
9#[allow(dead_code)]
10#[derive(Debug, Clone)]
11pub struct MorphTargetExport {
12    /// Human-readable name of this morph target.
13    pub name: String,
14    /// Per-vertex position deltas (dx, dy, dz).
15    pub delta_positions: Vec<[f32; 3]>,
16    /// Per-vertex normal deltas (dx, dy, dz). May be empty if unused.
17    pub delta_normals: Vec<[f32; 3]>,
18    /// Default weight in [0, 1].
19    pub default_weight: f32,
20}
21
22/// Configuration for morph target export.
23#[allow(dead_code)]
24#[derive(Debug, Clone)]
25pub struct MorphExportConfig {
26    /// Minimum delta magnitude to keep (prune smaller deltas to zero).
27    pub threshold: f32,
28    /// Whether to include delta normals in the export.
29    pub include_normals: bool,
30    /// Whether to normalize deltas before export.
31    pub normalize: bool,
32}
33
34/// A bundle of morph targets ready for export.
35#[allow(dead_code)]
36#[derive(Debug, Clone)]
37pub struct MorphExportBundle {
38    /// All morph targets in export order.
39    pub targets: Vec<MorphTargetExport>,
40    /// Configuration used when building this bundle.
41    pub config: MorphExportConfig,
42}
43
44// ── Constructor functions ────────────────────────────────────────────────────
45
46/// Create a default morph export configuration.
47#[allow(dead_code)]
48pub fn default_morph_export_config() -> MorphExportConfig {
49    MorphExportConfig {
50        threshold: 1e-6,
51        include_normals: true,
52        normalize: false,
53    }
54}
55
56/// Create a new morph target export with the given name and vertex count.
57///
58/// All deltas are initialised to zero.
59#[allow(dead_code)]
60pub fn new_morph_target_export(name: &str, vertex_count: usize) -> MorphTargetExport {
61    MorphTargetExport {
62        name: name.to_string(),
63        delta_positions: vec![[0.0; 3]; vertex_count],
64        delta_normals: vec![[0.0; 3]; vertex_count],
65        default_weight: 0.0,
66    }
67}
68
69// ── Accessors ────────────────────────────────────────────────────────────────
70
71/// Return a reference to the delta positions of a morph target.
72#[allow(dead_code)]
73pub fn morph_delta_positions(target: &MorphTargetExport) -> &[[f32; 3]] {
74    &target.delta_positions
75}
76
77/// Return a reference to the delta normals of a morph target.
78#[allow(dead_code)]
79pub fn morph_delta_normals(target: &MorphTargetExport) -> &[[f32; 3]] {
80    &target.delta_normals
81}
82
83/// Return the number of morph targets in a bundle.
84#[allow(dead_code)]
85pub fn morph_target_count(bundle: &MorphExportBundle) -> usize {
86    bundle.targets.len()
87}
88
89/// Return the name of a morph target, or an empty string if the index is out of range.
90#[allow(dead_code)]
91pub fn morph_target_name(bundle: &MorphExportBundle, index: usize) -> &str {
92    bundle
93        .targets
94        .get(index)
95        .map(|t| t.name.as_str())
96        .unwrap_or("")
97}
98
99// ── Bundle building ──────────────────────────────────────────────────────────
100
101/// Pack a slice of morph targets into an export bundle using the given config.
102#[allow(dead_code)]
103pub fn pack_morph_bundle(
104    targets: &[MorphTargetExport],
105    config: &MorphExportConfig,
106) -> MorphExportBundle {
107    let mut out_targets: Vec<MorphTargetExport> = Vec::with_capacity(targets.len());
108    for t in targets {
109        let mut target = t.clone();
110        if config.normalize {
111            normalize_morph_deltas_internal(&mut target.delta_positions);
112        }
113        if !config.include_normals {
114            target.delta_normals.clear();
115        }
116        if config.threshold > 0.0 {
117            filter_deltas_by_threshold(&mut target.delta_positions, config.threshold);
118            if !target.delta_normals.is_empty() {
119                filter_deltas_by_threshold(&mut target.delta_normals, config.threshold);
120            }
121        }
122        out_targets.push(target);
123    }
124    MorphExportBundle {
125        targets: out_targets,
126        config: config.clone(),
127    }
128}
129
130/// Serialise a morph bundle to a JSON string.
131#[allow(dead_code)]
132pub fn morph_bundle_to_json(bundle: &MorphExportBundle) -> String {
133    let mut s = String::from("{\n  \"morph_targets\": [\n");
134    for (i, t) in bundle.targets.iter().enumerate() {
135        s.push_str(&format!(
136            "    {{\"name\":\"{}\",\"vertex_count\":{},\"has_normals\":{},\"default_weight\":{:.6}}}",
137            t.name,
138            t.delta_positions.len(),
139            !t.delta_normals.is_empty(),
140            t.default_weight,
141        ));
142        if i + 1 < bundle.targets.len() {
143            s.push(',');
144        }
145        s.push('\n');
146    }
147    s.push_str("  ],\n");
148    s.push_str(&format!(
149        "  \"config\":{{\"threshold\":{:.8},\"include_normals\":{},\"normalize\":{}}}\n",
150        bundle.config.threshold, bundle.config.include_normals, bundle.config.normalize,
151    ));
152    s.push('}');
153    s
154}
155
156// ── Computation utilities ────────────────────────────────────────────────────
157
158/// Compute the magnitude of a single morph delta vector.
159#[allow(dead_code)]
160pub fn morph_delta_magnitude(delta: [f32; 3]) -> f32 {
161    (delta[0] * delta[0] + delta[1] * delta[1] + delta[2] * delta[2]).sqrt()
162}
163
164/// Normalize all delta positions in a morph target in-place so the maximum
165/// magnitude is 1.0.  Does nothing if all deltas are zero.
166#[allow(dead_code)]
167pub fn normalize_morph_deltas(target: &mut MorphTargetExport) {
168    normalize_morph_deltas_internal(&mut target.delta_positions);
169}
170
171/// Filter delta positions in a morph target: set any delta with magnitude below
172/// `threshold` to zero.
173#[allow(dead_code)]
174pub fn filter_morph_by_threshold(target: &mut MorphTargetExport, threshold: f32) {
175    filter_deltas_by_threshold(&mut target.delta_positions, threshold);
176    if !target.delta_normals.is_empty() {
177        filter_deltas_by_threshold(&mut target.delta_normals, threshold);
178    }
179}
180
181/// Return the valid weight range for a morph target (always `(0.0, 1.0)`).
182#[allow(dead_code)]
183pub fn morph_weight_range() -> (f32, f32) {
184    (0.0, 1.0)
185}
186
187/// Estimate the size in bytes of a morph export bundle when serialised to
188/// a binary GLB buffer.
189///
190/// Each target contributes `vertex_count * 3 * 4` bytes for positions,
191/// plus the same for normals if present.
192#[allow(dead_code)]
193pub fn morph_export_size_bytes(bundle: &MorphExportBundle) -> usize {
194    let mut total = 0usize;
195    for t in &bundle.targets {
196        total += t.delta_positions.len() * 3 * 4;
197        if !t.delta_normals.is_empty() {
198            total += t.delta_normals.len() * 3 * 4;
199        }
200    }
201    total
202}
203
204// ── Private helpers ──────────────────────────────────────────────────────────
205
206fn normalize_morph_deltas_internal(deltas: &mut [[f32; 3]]) {
207    let max_mag = deltas
208        .iter()
209        .map(|d| morph_delta_magnitude(*d))
210        .fold(0.0_f32, f32::max);
211    if max_mag < 1e-12 {
212        return;
213    }
214    let inv = 1.0 / max_mag;
215    for d in deltas.iter_mut() {
216        d[0] *= inv;
217        d[1] *= inv;
218        d[2] *= inv;
219    }
220}
221
222fn filter_deltas_by_threshold(deltas: &mut [[f32; 3]], threshold: f32) {
223    for d in deltas.iter_mut() {
224        if morph_delta_magnitude(*d) < threshold {
225            *d = [0.0; 3];
226        }
227    }
228}
229
230// ── Tests ────────────────────────────────────────────────────────────────────
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    #[test]
237    fn default_config_values() {
238        let cfg = default_morph_export_config();
239        assert!(cfg.include_normals);
240        assert!(!cfg.normalize);
241        assert!(cfg.threshold > 0.0);
242    }
243
244    #[test]
245    fn new_morph_target_has_correct_size() {
246        let t = new_morph_target_export("smile", 100);
247        assert_eq!(t.name, "smile");
248        assert_eq!(t.delta_positions.len(), 100);
249        assert_eq!(t.delta_normals.len(), 100);
250        assert!((t.default_weight - 0.0).abs() < 1e-6);
251    }
252
253    #[test]
254    fn morph_delta_positions_accessor() {
255        let t = new_morph_target_export("test", 5);
256        let dp = morph_delta_positions(&t);
257        assert_eq!(dp.len(), 5);
258    }
259
260    #[test]
261    fn morph_delta_normals_accessor() {
262        let t = new_morph_target_export("test", 5);
263        let dn = morph_delta_normals(&t);
264        assert_eq!(dn.len(), 5);
265    }
266
267    #[test]
268    fn morph_delta_magnitude_basic() {
269        assert!((morph_delta_magnitude([3.0, 4.0, 0.0]) - 5.0).abs() < 1e-6);
270    }
271
272    #[test]
273    fn morph_delta_magnitude_zero() {
274        assert!((morph_delta_magnitude([0.0, 0.0, 0.0])).abs() < 1e-6);
275    }
276
277    #[test]
278    fn pack_morph_bundle_preserves_count() {
279        let targets = vec![
280            new_morph_target_export("a", 10),
281            new_morph_target_export("b", 10),
282        ];
283        let cfg = default_morph_export_config();
284        let bundle = pack_morph_bundle(&targets, &cfg);
285        assert_eq!(morph_target_count(&bundle), 2);
286    }
287
288    #[test]
289    fn pack_morph_bundle_strips_normals_when_disabled() {
290        let targets = vec![new_morph_target_export("a", 10)];
291        let mut cfg = default_morph_export_config();
292        cfg.include_normals = false;
293        let bundle = pack_morph_bundle(&targets, &cfg);
294        assert!(bundle.targets[0].delta_normals.is_empty());
295    }
296
297    #[test]
298    fn morph_target_name_valid_index() {
299        let targets = vec![new_morph_target_export("blink", 5)];
300        let cfg = default_morph_export_config();
301        let bundle = pack_morph_bundle(&targets, &cfg);
302        assert_eq!(morph_target_name(&bundle, 0), "blink");
303    }
304
305    #[test]
306    fn morph_target_name_invalid_index() {
307        let targets = vec![new_morph_target_export("blink", 5)];
308        let cfg = default_morph_export_config();
309        let bundle = pack_morph_bundle(&targets, &cfg);
310        assert_eq!(morph_target_name(&bundle, 99), "");
311    }
312
313    #[test]
314    fn normalize_morph_deltas_max_is_one() {
315        let mut t = new_morph_target_export("test", 3);
316        t.delta_positions[0] = [3.0, 0.0, 0.0];
317        t.delta_positions[1] = [0.0, 4.0, 0.0];
318        t.delta_positions[2] = [0.0, 0.0, 5.0];
319        normalize_morph_deltas(&mut t);
320        let max_mag = t
321            .delta_positions
322            .iter()
323            .map(|d| morph_delta_magnitude(*d))
324            .fold(0.0_f32, f32::max);
325        assert!((max_mag - 1.0).abs() < 1e-5);
326    }
327
328    #[test]
329    fn normalize_morph_deltas_all_zero_is_noop() {
330        let mut t = new_morph_target_export("test", 3);
331        normalize_morph_deltas(&mut t);
332        for d in &t.delta_positions {
333            assert_eq!(*d, [0.0; 3]);
334        }
335    }
336
337    #[test]
338    fn filter_morph_by_threshold_zeroes_small() {
339        let mut t = new_morph_target_export("test", 3);
340        t.delta_positions[0] = [0.001, 0.0, 0.0];
341        t.delta_positions[1] = [1.0, 0.0, 0.0];
342        t.delta_positions[2] = [0.0005, 0.0, 0.0];
343        filter_morph_by_threshold(&mut t, 0.01);
344        assert_eq!(t.delta_positions[0], [0.0; 3]);
345        assert!((t.delta_positions[1][0] - 1.0).abs() < 1e-6);
346        assert_eq!(t.delta_positions[2], [0.0; 3]);
347    }
348
349    #[test]
350    fn morph_weight_range_is_zero_to_one() {
351        let (lo, hi) = morph_weight_range();
352        assert!((lo - 0.0).abs() < 1e-6);
353        assert!((hi - 1.0).abs() < 1e-6);
354    }
355
356    #[test]
357    fn morph_export_size_bytes_calculation() {
358        let targets = vec![new_morph_target_export("a", 10)];
359        let cfg = default_morph_export_config();
360        let bundle = pack_morph_bundle(&targets, &cfg);
361        // 10 verts * 3 floats * 4 bytes = 120 for positions + 120 for normals
362        assert_eq!(morph_export_size_bytes(&bundle), 240);
363    }
364
365    #[test]
366    fn morph_export_size_bytes_no_normals() {
367        let targets = vec![new_morph_target_export("a", 10)];
368        let mut cfg = default_morph_export_config();
369        cfg.include_normals = false;
370        let bundle = pack_morph_bundle(&targets, &cfg);
371        assert_eq!(morph_export_size_bytes(&bundle), 120);
372    }
373
374    #[test]
375    fn morph_bundle_to_json_contains_name() {
376        let targets = vec![new_morph_target_export("jaw_open", 5)];
377        let cfg = default_morph_export_config();
378        let bundle = pack_morph_bundle(&targets, &cfg);
379        let json = morph_bundle_to_json(&bundle);
380        assert!(json.contains("jaw_open"));
381        assert!(json.contains("morph_targets"));
382    }
383
384    #[test]
385    fn morph_bundle_to_json_multiple_targets() {
386        let targets = vec![
387            new_morph_target_export("a", 5),
388            new_morph_target_export("b", 5),
389        ];
390        let cfg = default_morph_export_config();
391        let bundle = pack_morph_bundle(&targets, &cfg);
392        let json = morph_bundle_to_json(&bundle);
393        assert!(json.contains("\"a\""));
394        assert!(json.contains("\"b\""));
395    }
396}