Skip to main content

oxihuman_morph/
retarget_mesh.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Retarget mesh geometry between different topologies using closest-point transfer.
5
6/// Configuration for mesh retargeting.
7#[allow(dead_code)]
8#[derive(Debug, Clone)]
9pub struct RetargetMeshConfig {
10    /// Max search distance for closest point (default 0.1).
11    pub search_radius: f32,
12    /// Post-transfer smoothing passes (default 2).
13    pub smooth_iterations: u32,
14    /// Blend factor: 0 = no transfer, 1 = full transfer (default 1.0).
15    pub blend: f32,
16}
17
18impl Default for RetargetMeshConfig {
19    fn default() -> Self {
20        Self {
21            search_radius: 0.1,
22            smooth_iterations: 2,
23            blend: 1.0,
24        }
25    }
26}
27
28/// Result of a mesh retargeting operation.
29#[allow(dead_code)]
30#[derive(Debug, Clone)]
31pub struct RetargetMeshResult {
32    /// Retargeted vertex positions.
33    pub positions: Vec<[f32; 3]>,
34    /// Vertices successfully retargeted.
35    pub transferred_count: usize,
36    /// Vertices with no source within radius.
37    pub failed_count: usize,
38    /// Mean closest-point distance.
39    pub avg_error: f32,
40}
41
42/// Find the closest vertex in `positions` to `query` within `max_dist`.
43/// Returns `(index, distance)` or `None` if none found within radius.
44#[allow(dead_code)]
45pub fn closest_vertex(
46    query: [f32; 3],
47    positions: &[[f32; 3]],
48    max_dist: f32,
49) -> Option<(usize, f32)> {
50    let mut best_idx = None;
51    let mut best_dist = max_dist;
52    for (i, &p) in positions.iter().enumerate() {
53        let d = dist3(query, p);
54        if d < best_dist {
55            best_dist = d;
56            best_idx = Some(i);
57        }
58    }
59    best_idx.map(|i| (i, best_dist))
60}
61
62/// Retarget source vertex positions by transferring deformation deltas from a target mesh.
63///
64/// For each source vertex: find closest vertex in target_base; compute
65/// `delta = target_deformed[closest] - target_base[closest]`; apply `delta * blend`
66/// to source position. Vertices with no match within `search_radius` are marked failed.
67#[allow(dead_code)]
68pub fn retarget_mesh_positions(
69    source: &[[f32; 3]],
70    target_base: &[[f32; 3]],
71    target_deformed: &[[f32; 3]],
72    cfg: &RetargetMeshConfig,
73) -> RetargetMeshResult {
74    let n = source.len();
75    let mut positions = Vec::with_capacity(n);
76    let mut failed_mask = Vec::with_capacity(n);
77    let mut transferred_count = 0usize;
78    let mut failed_count = 0usize;
79    let mut error_sum = 0.0f32;
80
81    for &sv in source.iter() {
82        match closest_vertex(sv, target_base, cfg.search_radius) {
83            Some((idx, d)) => {
84                let delta = sub3(target_deformed[idx], target_base[idx]);
85                let scaled = scale3(delta, cfg.blend);
86                positions.push(add3(sv, scaled));
87                failed_mask.push(false);
88                transferred_count += 1;
89                error_sum += d;
90            }
91            None => {
92                positions.push(sv);
93                failed_mask.push(true);
94                failed_count += 1;
95            }
96        }
97    }
98
99    let avg_error = if transferred_count > 0 {
100        error_sum / transferred_count as f32
101    } else {
102        0.0
103    };
104
105    // Optional Laplacian smoothing of failed vertices (no adjacency info here, skip smoothing
106    // since no index buffer provided — callers should call smooth_transferred_positions separately)
107    let _ = cfg.smooth_iterations; // used when caller invokes smooth_transferred_positions
108
109    RetargetMeshResult {
110        positions,
111        transferred_count,
112        failed_count,
113        avg_error,
114    }
115}
116
117/// Transfer morph deltas from target topology to source topology.
118///
119/// For each source vertex: find closest vertex in target_base; apply the corresponding
120/// target delta (scaled by blend) as the source delta.
121#[allow(dead_code)]
122pub fn transfer_deltas(
123    source: &[[f32; 3]],
124    source_base: &[[f32; 3]],
125    target_base: &[[f32; 3]],
126    target_deltas: &[[f32; 3]],
127    cfg: &RetargetMeshConfig,
128) -> Vec<[f32; 3]> {
129    let _ = source_base; // retained for API symmetry / future use
130    source
131        .iter()
132        .map(
133            |&sv| match closest_vertex(sv, target_base, cfg.search_radius) {
134                Some((idx, _)) => scale3(target_deltas[idx], cfg.blend),
135                None => [0.0, 0.0, 0.0],
136            },
137        )
138        .collect()
139}
140
141/// Laplacian smooth only failed vertices using triangle index data.
142///
143/// For failed vertices, replace position with average of neighboring vertices
144/// derived from the index buffer. Performs `iterations` passes.
145#[allow(dead_code)]
146pub fn smooth_transferred_positions(
147    positions: &[[f32; 3]],
148    failed_mask: &[bool],
149    indices: &[u32],
150    iterations: u32,
151) -> Vec<[f32; 3]> {
152    let n = positions.len();
153    // Build adjacency list from triangle indices
154    let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
155    for tri in indices.chunks_exact(3) {
156        let (a, b, c) = (tri[0] as usize, tri[1] as usize, tri[2] as usize);
157        if !adj[a].contains(&b) {
158            adj[a].push(b);
159        }
160        if !adj[a].contains(&c) {
161            adj[a].push(c);
162        }
163        if !adj[b].contains(&a) {
164            adj[b].push(a);
165        }
166        if !adj[b].contains(&c) {
167            adj[b].push(c);
168        }
169        if !adj[c].contains(&a) {
170            adj[c].push(a);
171        }
172        if !adj[c].contains(&b) {
173            adj[c].push(b);
174        }
175    }
176
177    let mut current: Vec<[f32; 3]> = positions.to_vec();
178    for _ in 0..iterations {
179        let prev = current.clone();
180        for (i, cur_pos) in current.iter_mut().enumerate() {
181            if failed_mask.get(i).copied().unwrap_or(false) && !adj[i].is_empty() {
182                let mut sum = [0.0f32; 3];
183                for &nb in &adj[i] {
184                    sum = add3(sum, prev[nb]);
185                }
186                let cnt = adj[i].len() as f32;
187                *cur_pos = [sum[0] / cnt, sum[1] / cnt, sum[2] / cnt];
188            }
189        }
190    }
191    current
192}
193
194/// Format a human-readable error statistics string from a `RetargetMeshResult`.
195#[allow(dead_code)]
196pub fn retarget_error_stats(result: &RetargetMeshResult) -> String {
197    let total = result.transferred_count + result.failed_count;
198    let coverage = if total > 0 {
199        result.transferred_count as f32 / total as f32 * 100.0
200    } else {
201        0.0
202    };
203    format!(
204        "transferred={} failed={} coverage={:.1}% avg_error={:.6}",
205        result.transferred_count, result.failed_count, coverage, result.avg_error
206    )
207}
208
209// ── Inline math helpers ────────────────────────────────────────────────────
210
211#[inline]
212fn dist3(a: [f32; 3], b: [f32; 3]) -> f32 {
213    let dx = a[0] - b[0];
214    let dy = a[1] - b[1];
215    let dz = a[2] - b[2];
216    (dx * dx + dy * dy + dz * dz).sqrt()
217}
218
219#[inline]
220fn sub3(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
221    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
222}
223
224#[inline]
225fn add3(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
226    [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
227}
228
229#[inline]
230fn scale3(v: [f32; 3], s: f32) -> [f32; 3] {
231    [v[0] * s, v[1] * s, v[2] * s]
232}
233
234// ── Tests ──────────────────────────────────────────────────────────────────
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    fn cfg_default() -> RetargetMeshConfig {
241        RetargetMeshConfig::default()
242    }
243
244    // 1. closest_vertex finds nearest
245    #[test]
246    fn test_closest_vertex_finds_nearest() {
247        let pts = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.5, 0.0, 0.0]];
248        let (idx, d) = closest_vertex([0.49, 0.0, 0.0], &pts, 1.0).expect("should succeed");
249        assert_eq!(idx, 2);
250        assert!(d < 0.02);
251    }
252
253    // 2. closest_vertex returns None when max_dist exceeded
254    #[test]
255    fn test_closest_vertex_none_beyond_radius() {
256        let pts = vec![[10.0, 0.0, 0.0]];
257        assert!(closest_vertex([0.0, 0.0, 0.0], &pts, 0.5).is_none());
258    }
259
260    // 3. closest_vertex exact match (distance = 0)
261    #[test]
262    fn test_closest_vertex_exact() {
263        let pts = vec![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]];
264        let (idx, d) = closest_vertex([1.0, 2.0, 3.0], &pts, 0.001).expect("should succeed");
265        assert_eq!(idx, 0);
266        assert!(d < 1e-6);
267    }
268
269    // 4. retarget_mesh_positions identity: source == target_base → zero delta
270    #[test]
271    fn test_retarget_identity_zero_delta() {
272        let verts = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
273        let cfg = RetargetMeshConfig {
274            search_radius: 1.0,
275            ..cfg_default()
276        };
277        let result = retarget_mesh_positions(&verts, &verts, &verts, &cfg);
278        for (orig, out) in verts.iter().zip(result.positions.iter()) {
279            assert!((orig[0] - out[0]).abs() < 1e-6);
280            assert!((orig[1] - out[1]).abs() < 1e-6);
281            assert!((orig[2] - out[2]).abs() < 1e-6);
282        }
283        assert_eq!(result.failed_count, 0);
284    }
285
286    // 5. retarget applies delta correctly
287    #[test]
288    fn test_retarget_applies_delta() {
289        let source = vec![[0.0, 0.0, 0.0]];
290        let target_base = vec![[0.0, 0.0, 0.0]];
291        let target_deformed = vec![[0.0, 1.0, 0.0]];
292        let cfg = RetargetMeshConfig {
293            search_radius: 0.5,
294            blend: 1.0,
295            ..cfg_default()
296        };
297        let result = retarget_mesh_positions(&source, &target_base, &target_deformed, &cfg);
298        assert!((result.positions[0][1] - 1.0).abs() < 1e-6);
299    }
300
301    // 6. blend=0 → no change
302    #[test]
303    fn test_retarget_blend_zero_no_change() {
304        let source = vec![[0.0, 0.0, 0.0]];
305        let target_base = vec![[0.0, 0.0, 0.0]];
306        let target_deformed = vec![[0.0, 5.0, 0.0]];
307        let cfg = RetargetMeshConfig {
308            search_radius: 0.5,
309            blend: 0.0,
310            ..cfg_default()
311        };
312        let result = retarget_mesh_positions(&source, &target_base, &target_deformed, &cfg);
313        assert!((result.positions[0][1]).abs() < 1e-6);
314    }
315
316    // 7. blend=1 → full transfer
317    #[test]
318    fn test_retarget_blend_one_full() {
319        let source = vec![[0.0, 0.0, 0.0]];
320        let target_base = vec![[0.0, 0.0, 0.0]];
321        let target_deformed = vec![[3.0, 0.0, 0.0]];
322        let cfg = RetargetMeshConfig {
323            search_radius: 0.5,
324            blend: 1.0,
325            ..cfg_default()
326        };
327        let result = retarget_mesh_positions(&source, &target_base, &target_deformed, &cfg);
328        assert!((result.positions[0][0] - 3.0).abs() < 1e-6);
329    }
330
331    // 8. failed_count for out-of-radius vertex
332    #[test]
333    fn test_retarget_failed_count() {
334        let source = vec![[0.0, 0.0, 0.0], [100.0, 0.0, 0.0]];
335        let target_base = vec![[0.0, 0.0, 0.0]];
336        let target_deformed = vec![[0.0, 1.0, 0.0]];
337        let cfg = RetargetMeshConfig {
338            search_radius: 0.5,
339            ..cfg_default()
340        };
341        let result = retarget_mesh_positions(&source, &target_base, &target_deformed, &cfg);
342        assert_eq!(result.failed_count, 1);
343        assert_eq!(result.transferred_count, 1);
344    }
345
346    // 9. transfer_deltas count matches source
347    #[test]
348    fn test_transfer_deltas_count_matches_source() {
349        let source = vec![[0.0f32; 3]; 5];
350        let source_base = vec![[0.0f32; 3]; 5];
351        let target_base = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]];
352        let target_deltas = vec![[0.1, 0.0, 0.0], [0.2, 0.0, 0.0]];
353        let cfg = RetargetMeshConfig {
354            search_radius: 2.0,
355            ..cfg_default()
356        };
357        let out = transfer_deltas(&source, &source_base, &target_base, &target_deltas, &cfg);
358        assert_eq!(out.len(), 5);
359    }
360
361    // 10. transfer_deltas correct delta applied
362    #[test]
363    fn test_transfer_deltas_value() {
364        let source = vec![[0.0, 0.0, 0.0]];
365        let source_base = vec![[0.0, 0.0, 0.0]];
366        let target_base = vec![[0.0, 0.0, 0.0]];
367        let target_deltas = vec![[0.5, 0.25, 0.1]];
368        let cfg = RetargetMeshConfig {
369            search_radius: 1.0,
370            blend: 1.0,
371            ..cfg_default()
372        };
373        let out = transfer_deltas(&source, &source_base, &target_base, &target_deltas, &cfg);
374        assert!((out[0][0] - 0.5).abs() < 1e-6);
375    }
376
377    // 11. smooth_transferred_positions with no failures → no-op
378    #[test]
379    fn test_smooth_no_failures_noop() {
380        let positions = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.5, 1.0, 0.0]];
381        let failed_mask = vec![false, false, false];
382        let indices = vec![0u32, 1, 2];
383        let out = smooth_transferred_positions(&positions, &failed_mask, &indices, 3);
384        for (a, b) in positions.iter().zip(out.iter()) {
385            assert!((a[0] - b[0]).abs() < 1e-6);
386            assert!((a[1] - b[1]).abs() < 1e-6);
387            assert!((a[2] - b[2]).abs() < 1e-6);
388        }
389    }
390
391    // 12. smooth_transferred_positions with failed vertex moves toward neighbors
392    #[test]
393    fn test_smooth_failed_vertex_moves() {
394        let positions = vec![[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [99.0, 99.0, 99.0]];
395        let failed_mask = vec![false, false, true];
396        let indices = vec![0u32, 1, 2];
397        let out = smooth_transferred_positions(&positions, &failed_mask, &indices, 1);
398        // vertex 2 should move toward average of 0 and 1 = (1,0,0)
399        assert!((out[2][0] - 1.0).abs() < 1e-5);
400    }
401
402    // 13. avg_error is computed (non-negative, reasonable)
403    #[test]
404    fn test_avg_error_computed() {
405        let source = vec![[0.05, 0.0, 0.0]];
406        let target_base = vec![[0.0, 0.0, 0.0]];
407        let target_deformed = vec![[0.0, 0.1, 0.0]];
408        let cfg = RetargetMeshConfig {
409            search_radius: 1.0,
410            ..cfg_default()
411        };
412        let result = retarget_mesh_positions(&source, &target_base, &target_deformed, &cfg);
413        assert!(result.avg_error >= 0.0);
414        assert!(result.avg_error < 1.0);
415    }
416
417    // 14. retarget_error_stats format check
418    #[test]
419    fn test_retarget_error_stats_format() {
420        let result = RetargetMeshResult {
421            positions: vec![],
422            transferred_count: 8,
423            failed_count: 2,
424            avg_error: 0.01234,
425        };
426        let s = retarget_error_stats(&result);
427        assert!(s.contains("transferred=8"));
428        assert!(s.contains("failed=2"));
429        assert!(s.contains("avg_error"));
430    }
431
432    // 15. retarget_error_stats with zero total
433    #[test]
434    fn test_retarget_error_stats_zero_total() {
435        let result = RetargetMeshResult {
436            positions: vec![],
437            transferred_count: 0,
438            failed_count: 0,
439            avg_error: 0.0,
440        };
441        let s = retarget_error_stats(&result);
442        assert!(s.contains("coverage=0.0%"));
443    }
444}