Skip to main content

oxihuman_morph/
target_tools.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Target validation, inspection, merging, and mirroring utilities.
5//!
6//! This module provides quality-assurance and manipulation tools for morph
7//! target delta arrays — checking symmetry, bounding boxes, displacement
8//! magnitudes, weighted merging, and axis mirroring.
9
10use serde::{Deserialize, Serialize};
11
12use crate::delta_painter::MirrorAxis;
13
14// ── Data types ───────────────────────────────────────────────────────────────
15
16/// Summary information about a morph target.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct TargetInfo {
19    /// Name of the target (empty if not set).
20    pub name: String,
21    /// Total number of vertices in the delta array.
22    pub vertex_count: usize,
23    /// Number of vertices with non-zero displacement.
24    pub affected_count: usize,
25    /// Maximum displacement magnitude among all vertices.
26    pub max_displacement: f64,
27    /// Mean displacement magnitude (over all vertices, including zero).
28    pub average_displacement: f64,
29    /// Fraction of zero-displacement vertices: `1 - affected / total`.
30    pub sparsity: f64,
31}
32
33/// Report from a symmetry check.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct SymmetryReport {
36    /// Whether the target is considered symmetric within tolerance.
37    pub symmetric: bool,
38    /// Maximum asymmetry measured across any vertex pair.
39    pub max_asymmetry: f64,
40    /// Indices of vertices exceeding the symmetry tolerance.
41    pub asymmetric_vertices: Vec<usize>,
42}
43
44/// A single validation warning.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct ValidationWarning {
47    /// Classification.
48    pub kind: WarningKind,
49    /// Human-readable description.
50    pub message: String,
51}
52
53/// Categories of validation warnings.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55pub enum WarningKind {
56    /// One or more vertices have displacement exceeding a safe limit.
57    ExcessiveDisplacement,
58    /// Deltas risk causing mesh self-intersection (heuristic).
59    SelfIntersectionRisk,
60    /// The target is significantly asymmetric.
61    Asymmetry,
62    /// The target has no effective deltas at all.
63    EmptyTarget,
64}
65
66// ── Helpers ──────────────────────────────────────────────────────────────────
67
68#[inline]
69fn magnitude(v: [f64; 3]) -> f64 {
70    (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt()
71}
72
73#[inline]
74fn is_zero(v: &[f64; 3], threshold: f64) -> bool {
75    magnitude(*v) < threshold
76}
77
78/// Default threshold for considering a delta as "zero".
79const DEFAULT_ZERO_THRESHOLD: f64 = 1e-10;
80
81// ── TargetValidator ──────────────────────────────────────────────────────────
82
83/// Validates morph target data for common issues.
84pub struct TargetValidator;
85
86impl TargetValidator {
87    /// Run a suite of validation checks on `deltas`.
88    ///
89    /// Returns a list of warnings (empty = no issues found).
90    pub fn validate(
91        deltas: &[[f64; 3]],
92        vertex_count: usize,
93    ) -> anyhow::Result<Vec<ValidationWarning>> {
94        if deltas.len() != vertex_count {
95            anyhow::bail!(
96                "deltas length {} != vertex_count {}",
97                deltas.len(),
98                vertex_count
99            );
100        }
101
102        let mut warnings = Vec::new();
103
104        // Check for empty target
105        let affected = deltas
106            .iter()
107            .filter(|d| !is_zero(d, DEFAULT_ZERO_THRESHOLD))
108            .count();
109        if affected == 0 {
110            warnings.push(ValidationWarning {
111                kind: WarningKind::EmptyTarget,
112                message: "Target has no non-zero deltas".to_owned(),
113            });
114        }
115
116        // Check for excessive displacement (heuristic: > 1.0 world unit)
117        let max_disp = deltas.iter().map(|d| magnitude(*d)).fold(0.0_f64, f64::max);
118        if max_disp > 1.0 {
119            warnings.push(ValidationWarning {
120                kind: WarningKind::ExcessiveDisplacement,
121                message: format!(
122                    "Maximum displacement {:.4} exceeds safe limit 1.0",
123                    max_disp
124                ),
125            });
126        }
127
128        // Self-intersection risk heuristic: if any delta is > 0.5 and there
129        // are neighbouring deltas pointing in opposite directions.
130        // Simplified: check if the delta range in any axis exceeds 1.0
131        let mut axis_min = [f64::MAX; 3];
132        let mut axis_max = [f64::MIN; 3];
133        for d in deltas {
134            for i in 0..3 {
135                if d[i] < axis_min[i] {
136                    axis_min[i] = d[i];
137                }
138                if d[i] > axis_max[i] {
139                    axis_max[i] = d[i];
140                }
141            }
142        }
143        for i in 0..3 {
144            let range = axis_max[i] - axis_min[i];
145            if range > 1.0 {
146                let axis_name = match i {
147                    0 => "X",
148                    1 => "Y",
149                    _ => "Z",
150                };
151                warnings.push(ValidationWarning {
152                    kind: WarningKind::SelfIntersectionRisk,
153                    message: format!(
154                        "Delta range on {} axis is {:.4}, which may cause self-intersection",
155                        axis_name, range
156                    ),
157                });
158            }
159        }
160
161        Ok(warnings)
162    }
163
164    /// Check symmetry of deltas relative to vertex positions across the X axis.
165    ///
166    /// For each vertex on the positive X side, finds the closest vertex on the
167    /// negative side and compares their deltas (with X component negated).
168    pub fn check_symmetry(
169        deltas: &[[f64; 3]],
170        positions: &[[f64; 3]],
171        tolerance: f64,
172    ) -> SymmetryReport {
173        let n = deltas.len().min(positions.len());
174        let tol = tolerance.max(1e-12);
175        let mut max_asym = 0.0_f64;
176        let mut asym_verts = Vec::new();
177
178        for i in 0..n {
179            let pos = positions[i];
180            // Only check vertices on the positive X side
181            if pos[0] < 0.0 {
182                continue;
183            }
184
185            // Find mirror vertex
186            let mirror_pos = [-pos[0], pos[1], pos[2]];
187            let mut best_j: Option<usize> = None;
188            let mut best_dsq = f64::MAX;
189            for (j, jpos) in positions[..n].iter().enumerate() {
190                let dp0 = jpos[0] - mirror_pos[0];
191                let dp1 = jpos[1] - mirror_pos[1];
192                let dp2 = jpos[2] - mirror_pos[2];
193                let dsq = dp0 * dp0 + dp1 * dp1 + dp2 * dp2;
194                if dsq < best_dsq {
195                    best_dsq = dsq;
196                    best_j = Some(j);
197                }
198            }
199
200            if let Some(j) = best_j {
201                if best_dsq.sqrt() > tol * 10.0 {
202                    // No mirror vertex found within reasonable range — skip
203                    continue;
204                }
205                // Expected mirror delta: negate X component
206                let expected = [-deltas[i][0], deltas[i][1], deltas[i][2]];
207                let diff = [
208                    deltas[j][0] - expected[0],
209                    deltas[j][1] - expected[1],
210                    deltas[j][2] - expected[2],
211                ];
212                let asym = magnitude(diff);
213                if asym > max_asym {
214                    max_asym = asym;
215                }
216                if asym > tol {
217                    asym_verts.push(i);
218                }
219            }
220        }
221
222        SymmetryReport {
223            symmetric: asym_verts.is_empty(),
224            max_asymmetry: max_asym,
225            asymmetric_vertices: asym_verts,
226        }
227    }
228
229    /// Return indices of vertices whose displacement exceeds `max_displacement`.
230    pub fn check_magnitude(deltas: &[[f64; 3]], max_displacement: f64) -> Vec<usize> {
231        deltas
232            .iter()
233            .enumerate()
234            .filter_map(|(i, d)| {
235                if magnitude(*d) > max_displacement {
236                    Some(i)
237                } else {
238                    None
239                }
240            })
241            .collect()
242    }
243}
244
245// ── TargetInspector ──────────────────────────────────────────────────────────
246
247/// Read-only inspection utilities for morph target delta arrays.
248pub struct TargetInspector;
249
250impl TargetInspector {
251    /// Produce a [`TargetInfo`] summary of the given deltas.
252    pub fn inspect(deltas: &[[f64; 3]]) -> TargetInfo {
253        Self::inspect_named(deltas, "")
254    }
255
256    /// Produce a [`TargetInfo`] summary with a name.
257    pub fn inspect_named(deltas: &[[f64; 3]], name: &str) -> TargetInfo {
258        let vertex_count = deltas.len();
259        let mut affected_count = 0usize;
260        let mut max_disp = 0.0_f64;
261        let mut sum_disp = 0.0_f64;
262
263        for d in deltas {
264            let m = magnitude(*d);
265            if m > DEFAULT_ZERO_THRESHOLD {
266                affected_count += 1;
267            }
268            if m > max_disp {
269                max_disp = m;
270            }
271            sum_disp += m;
272        }
273
274        let average_displacement = if vertex_count > 0 {
275            sum_disp / vertex_count as f64
276        } else {
277            0.0
278        };
279
280        let sparsity = if vertex_count > 0 {
281            1.0 - (affected_count as f64 / vertex_count as f64)
282        } else {
283            1.0
284        };
285
286        TargetInfo {
287            name: name.to_owned(),
288            vertex_count,
289            affected_count,
290            max_displacement: max_disp,
291            average_displacement,
292            sparsity,
293        }
294    }
295
296    /// Return the indices of vertices displaced above `threshold`.
297    pub fn affected_vertices(deltas: &[[f64; 3]], threshold: f64) -> Vec<usize> {
298        deltas
299            .iter()
300            .enumerate()
301            .filter_map(|(i, d)| {
302                if magnitude(*d) > threshold {
303                    Some(i)
304                } else {
305                    None
306                }
307            })
308            .collect()
309    }
310
311    /// Axis-aligned bounding box of the delta field: `(min, max)`.
312    pub fn bounding_box(deltas: &[[f64; 3]]) -> ([f64; 3], [f64; 3]) {
313        if deltas.is_empty() {
314            return ([0.0; 3], [0.0; 3]);
315        }
316        let mut mn = [f64::MAX; 3];
317        let mut mx = [f64::MIN; 3];
318        for d in deltas {
319            for i in 0..3 {
320                if d[i] < mn[i] {
321                    mn[i] = d[i];
322                }
323                if d[i] > mx[i] {
324                    mx[i] = d[i];
325                }
326            }
327        }
328        (mn, mx)
329    }
330
331    /// Maximum displacement magnitude in the delta array.
332    pub fn max_displacement(deltas: &[[f64; 3]]) -> f64 {
333        deltas.iter().map(|d| magnitude(*d)).fold(0.0_f64, f64::max)
334    }
335
336    /// Root-mean-square displacement.
337    pub fn rms_displacement(deltas: &[[f64; 3]]) -> f64 {
338        if deltas.is_empty() {
339            return 0.0;
340        }
341        let sum_sq: f64 = deltas
342            .iter()
343            .map(|d| d[0] * d[0] + d[1] * d[1] + d[2] * d[2])
344            .sum();
345        (sum_sq / deltas.len() as f64).sqrt()
346    }
347}
348
349// ── Free functions: merge and mirror ─────────────────────────────────────────
350
351/// Weighted merge of multiple targets into a single delta array.
352///
353/// Each entry is `(name, deltas, weight)`. All delta arrays must have the same
354/// length. The result is the weighted sum of all inputs.
355pub fn merge_targets(targets: &[(&str, &[[f64; 3]], f64)]) -> anyhow::Result<Vec<[f64; 3]>> {
356    if targets.is_empty() {
357        anyhow::bail!("no targets to merge");
358    }
359
360    let vertex_count = targets[0].1.len();
361    for (name, deltas, _) in targets.iter().skip(1) {
362        if deltas.len() != vertex_count {
363            anyhow::bail!(
364                "target '{}' has {} vertices, expected {}",
365                name,
366                deltas.len(),
367                vertex_count
368            );
369        }
370    }
371
372    let mut result = vec![[0.0_f64; 3]; vertex_count];
373    for (_name, deltas, weight) in targets {
374        let w = *weight;
375        for (i, d) in deltas.iter().enumerate() {
376            result[i][0] += d[0] * w;
377            result[i][1] += d[1] * w;
378            result[i][2] += d[2] * w;
379        }
380    }
381    Ok(result)
382}
383
384/// Mirror a target's deltas across the specified axis.
385///
386/// For each vertex at position P, finds the closest vertex at the
387/// mirror-reflected position (within `tolerance`) and writes the delta
388/// with the axis component negated.
389pub fn mirror_target(
390    deltas: &[[f64; 3]],
391    positions: &[[f64; 3]],
392    axis: MirrorAxis,
393    tolerance: f64,
394) -> anyhow::Result<Vec<[f64; 3]>> {
395    let n = deltas.len();
396    if positions.len() != n {
397        anyhow::bail!(
398            "deltas length {} != positions length {}",
399            n,
400            positions.len()
401        );
402    }
403    if tolerance <= 0.0 {
404        anyhow::bail!("tolerance must be positive, got {}", tolerance);
405    }
406
407    let ax = axis.idx();
408    let tol_sq = tolerance * tolerance;
409    let mut result = deltas.to_vec();
410
411    for i in 0..n {
412        let pos = positions[i];
413        // Process vertices on positive side only
414        if pos[ax] < 0.0 {
415            continue;
416        }
417
418        let mut mirror_pos = pos;
419        mirror_pos[ax] = -mirror_pos[ax];
420
421        let mut best_j: Option<usize> = None;
422        let mut best_dsq = f64::MAX;
423        for (j, jpos) in positions[..n].iter().enumerate() {
424            let dp0 = jpos[0] - mirror_pos[0];
425            let dp1 = jpos[1] - mirror_pos[1];
426            let dp2 = jpos[2] - mirror_pos[2];
427            let dsq = dp0 * dp0 + dp1 * dp1 + dp2 * dp2;
428            if dsq < best_dsq {
429                best_dsq = dsq;
430                best_j = Some(j);
431            }
432        }
433
434        if let Some(j) = best_j {
435            if best_dsq <= tol_sq {
436                let mut d = deltas[i];
437                d[ax] = -d[ax];
438                result[j] = d;
439            }
440        }
441    }
442    Ok(result)
443}
444
445/// Subtract one target from another (element-wise).
446pub fn subtract_targets(a: &[[f64; 3]], b: &[[f64; 3]]) -> anyhow::Result<Vec<[f64; 3]>> {
447    if a.len() != b.len() {
448        anyhow::bail!("target lengths differ: {} vs {}", a.len(), b.len());
449    }
450    Ok(a.iter()
451        .zip(b.iter())
452        .map(|(va, vb)| [va[0] - vb[0], va[1] - vb[1], va[2] - vb[2]])
453        .collect())
454}
455
456/// Add two targets element-wise.
457pub fn add_targets(a: &[[f64; 3]], b: &[[f64; 3]]) -> anyhow::Result<Vec<[f64; 3]>> {
458    if a.len() != b.len() {
459        anyhow::bail!("target lengths differ: {} vs {}", a.len(), b.len());
460    }
461    Ok(a.iter()
462        .zip(b.iter())
463        .map(|(va, vb)| [va[0] + vb[0], va[1] + vb[1], va[2] + vb[2]])
464        .collect())
465}
466
467/// Scale a target uniformly.
468pub fn scale_target(deltas: &[[f64; 3]], factor: f64) -> Vec<[f64; 3]> {
469    deltas
470        .iter()
471        .map(|d| [d[0] * factor, d[1] * factor, d[2] * factor])
472        .collect()
473}
474
475/// Clamp all deltas to a maximum displacement magnitude.
476pub fn clamp_target(deltas: &[[f64; 3]], max_magnitude: f64) -> Vec<[f64; 3]> {
477    deltas
478        .iter()
479        .map(|d| {
480            let m = magnitude(*d);
481            if m > max_magnitude && m > 1e-15 {
482                let scale = max_magnitude / m;
483                [d[0] * scale, d[1] * scale, d[2] * scale]
484            } else {
485                *d
486            }
487        })
488        .collect()
489}
490
491/// Sparsify a delta array: set any delta below `threshold` to zero.
492pub fn sparsify_target(deltas: &[[f64; 3]], threshold: f64) -> Vec<[f64; 3]> {
493    deltas
494        .iter()
495        .map(|d| {
496            if magnitude(*d) < threshold {
497                [0.0; 3]
498            } else {
499                *d
500            }
501        })
502        .collect()
503}
504
505// MirrorAxis::idx() is defined in delta_painter.rs and used here via the public method.
506
507// ── Unit tests ───────────────────────────────────────────────────────────────
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512
513    #[test]
514    fn test_validate_empty_target() {
515        let deltas = vec![[0.0; 3]; 5];
516        let warnings = TargetValidator::validate(&deltas, 5).expect("validate ok");
517        assert!(warnings.iter().any(|w| w.kind == WarningKind::EmptyTarget));
518    }
519
520    #[test]
521    fn test_validate_excessive_displacement() {
522        let mut deltas = vec![[0.0; 3]; 5];
523        deltas[0] = [2.0, 0.0, 0.0]; // > 1.0
524        let warnings = TargetValidator::validate(&deltas, 5).expect("ok");
525        assert!(warnings
526            .iter()
527            .any(|w| w.kind == WarningKind::ExcessiveDisplacement));
528    }
529
530    #[test]
531    fn test_validate_length_mismatch() {
532        let deltas = vec![[0.0; 3]; 5];
533        assert!(TargetValidator::validate(&deltas, 10).is_err());
534    }
535
536    #[test]
537    fn test_validate_clean_target() {
538        let mut deltas = vec![[0.0; 3]; 5];
539        deltas[0] = [0.1, 0.0, 0.0];
540        let warnings = TargetValidator::validate(&deltas, 5).expect("ok");
541        // Should have no warnings (displacement < 1.0, not empty, not self-intersecting)
542        assert!(
543            warnings.is_empty(),
544            "expected no warnings, got {:?}",
545            warnings
546        );
547    }
548
549    #[test]
550    fn test_check_symmetry_symmetric() {
551        // Two vertices symmetric about X=0, with symmetric deltas
552        let positions = vec![[1.0, 0.0, 0.0], [-1.0, 0.0, 0.0]];
553        let deltas = vec![[0.1, 0.2, 0.3], [-0.1, 0.2, 0.3]]; // mirrored X
554        let report = TargetValidator::check_symmetry(&deltas, &positions, 0.01);
555        assert!(report.symmetric, "should be symmetric");
556        assert!(report.max_asymmetry < 0.01);
557    }
558
559    #[test]
560    fn test_check_symmetry_asymmetric() {
561        let positions = vec![[1.0, 0.0, 0.0], [-1.0, 0.0, 0.0]];
562        let deltas = vec![[0.1, 0.2, 0.3], [0.5, 0.0, 0.0]]; // NOT mirrored
563        let report = TargetValidator::check_symmetry(&deltas, &positions, 0.01);
564        assert!(!report.symmetric, "should be asymmetric");
565    }
566
567    #[test]
568    fn test_check_magnitude() {
569        let deltas = vec![[0.1, 0.0, 0.0], [2.0, 0.0, 0.0], [0.5, 0.0, 0.0]];
570        let exceeding = TargetValidator::check_magnitude(&deltas, 1.0);
571        assert_eq!(exceeding, vec![1]);
572    }
573
574    #[test]
575    fn test_inspect_basic() {
576        let deltas = vec![[1.0, 0.0, 0.0], [0.0, 0.0, 0.0], [0.0, 0.5, 0.0]];
577        let info = TargetInspector::inspect(&deltas);
578        assert_eq!(info.vertex_count, 3);
579        assert_eq!(info.affected_count, 2);
580        assert!((info.max_displacement - 1.0).abs() < 1e-10);
581        assert!(info.sparsity > 0.0 && info.sparsity < 1.0);
582    }
583
584    #[test]
585    fn test_inspect_empty() {
586        let deltas: Vec<[f64; 3]> = vec![];
587        let info = TargetInspector::inspect(&deltas);
588        assert_eq!(info.vertex_count, 0);
589        assert_eq!(info.affected_count, 0);
590        assert!((info.max_displacement).abs() < 1e-15);
591    }
592
593    #[test]
594    fn test_affected_vertices() {
595        let deltas = vec![[1.0, 0.0, 0.0], [0.0, 0.0, 0.0], [0.0, 0.5, 0.0]];
596        let affected = TargetInspector::affected_vertices(&deltas, 0.01);
597        assert_eq!(affected, vec![0, 2]);
598    }
599
600    #[test]
601    fn test_bounding_box() {
602        let deltas = vec![[1.0, -2.0, 3.0], [-1.0, 4.0, 0.5]];
603        let (mn, mx) = TargetInspector::bounding_box(&deltas);
604        assert!((mn[0] - (-1.0)).abs() < 1e-15);
605        assert!((mx[0] - 1.0).abs() < 1e-15);
606        assert!((mn[1] - (-2.0)).abs() < 1e-15);
607        assert!((mx[1] - 4.0).abs() < 1e-15);
608        assert!((mn[2] - 0.5).abs() < 1e-15);
609        assert!((mx[2] - 3.0).abs() < 1e-15);
610    }
611
612    #[test]
613    fn test_bounding_box_empty() {
614        let (mn, mx) = TargetInspector::bounding_box(&[]);
615        assert_eq!(mn, [0.0; 3]);
616        assert_eq!(mx, [0.0; 3]);
617    }
618
619    #[test]
620    fn test_max_displacement() {
621        let deltas = vec![[1.0, 0.0, 0.0], [0.0, 3.0, 4.0]]; // magnitudes: 1.0, 5.0
622        let max_d = TargetInspector::max_displacement(&deltas);
623        assert!((max_d - 5.0).abs() < 1e-10);
624    }
625
626    #[test]
627    fn test_rms_displacement() {
628        let deltas = vec![[1.0, 0.0, 0.0], [0.0, 0.0, 0.0]];
629        let rms = TargetInspector::rms_displacement(&deltas);
630        // sqrt((1 + 0) / 2) = sqrt(0.5) ≈ 0.7071
631        assert!((rms - (0.5_f64).sqrt()).abs() < 1e-10);
632    }
633
634    #[test]
635    fn test_merge_targets_single() {
636        let d = vec![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]];
637        let result = merge_targets(&[("a", &d, 1.0)]).expect("merge ok");
638        assert_eq!(result.len(), 2);
639        assert!((result[0][0] - 1.0).abs() < 1e-15);
640    }
641
642    #[test]
643    fn test_merge_targets_weighted() {
644        let a = [[1.0, 0.0, 0.0]];
645        let b = [[0.0, 2.0, 0.0]];
646        let result = merge_targets(&[("a", &a[..], 0.5), ("b", &b[..], 0.5)]).expect("ok");
647        assert!((result[0][0] - 0.5).abs() < 1e-15);
648        assert!((result[0][1] - 1.0).abs() < 1e-15);
649    }
650
651    #[test]
652    fn test_merge_targets_empty() {
653        assert!(merge_targets(&[]).is_err());
654    }
655
656    #[test]
657    fn test_merge_targets_length_mismatch() {
658        let a = [[1.0, 0.0, 0.0]];
659        let b = [[0.0, 0.0, 0.0], [1.0, 1.0, 1.0]];
660        assert!(merge_targets(&[("a", &a[..], 1.0), ("b", &b[..], 1.0)]).is_err());
661    }
662
663    #[test]
664    fn test_mirror_target_x() {
665        let positions = vec![[1.0, 0.0, 0.0], [-1.0, 0.0, 0.0]];
666        let deltas = vec![[0.5, 0.3, 0.1], [0.0, 0.0, 0.0]];
667        let mirrored = mirror_target(&deltas, &positions, MirrorAxis::X, 0.1).expect("mirror ok");
668        // Vertex 1 (mirror of 0) should get negated X component
669        assert!((mirrored[1][0] - (-0.5)).abs() < 1e-10);
670        assert!((mirrored[1][1] - 0.3).abs() < 1e-10);
671        assert!((mirrored[1][2] - 0.1).abs() < 1e-10);
672    }
673
674    #[test]
675    fn test_mirror_target_length_mismatch() {
676        let d = vec![[0.0; 3]; 3];
677        let p = vec![[0.0; 3]; 2];
678        assert!(mirror_target(&d, &p, MirrorAxis::X, 0.1).is_err());
679    }
680
681    #[test]
682    fn test_subtract_targets() {
683        let a = vec![[1.0, 2.0, 3.0]];
684        let b = vec![[0.5, 0.5, 0.5]];
685        let result = subtract_targets(&a, &b).expect("ok");
686        assert!((result[0][0] - 0.5).abs() < 1e-15);
687        assert!((result[0][1] - 1.5).abs() < 1e-15);
688        assert!((result[0][2] - 2.5).abs() < 1e-15);
689    }
690
691    #[test]
692    fn test_add_targets() {
693        let a = vec![[1.0, 2.0, 3.0]];
694        let b = vec![[0.5, 0.5, 0.5]];
695        let result = add_targets(&a, &b).expect("ok");
696        assert!((result[0][0] - 1.5).abs() < 1e-15);
697    }
698
699    #[test]
700    fn test_scale_target() {
701        let d = vec![[1.0, 2.0, 3.0]];
702        let result = scale_target(&d, 2.0);
703        assert!((result[0][0] - 2.0).abs() < 1e-15);
704        assert!((result[0][1] - 4.0).abs() < 1e-15);
705    }
706
707    #[test]
708    fn test_clamp_target() {
709        let d = vec![[10.0, 0.0, 0.0], [0.1, 0.0, 0.0]];
710        let clamped = clamp_target(&d, 1.0);
711        assert!((magnitude(clamped[0]) - 1.0).abs() < 1e-10);
712        assert!((clamped[1][0] - 0.1).abs() < 1e-15); // untouched
713    }
714
715    #[test]
716    fn test_sparsify_target() {
717        let d = vec![[1.0, 0.0, 0.0], [0.001, 0.0, 0.0], [0.0, 0.5, 0.0]];
718        let sparse = sparsify_target(&d, 0.01);
719        assert!((sparse[0][0] - 1.0).abs() < 1e-15);
720        assert_eq!(sparse[1], [0.0; 3]); // below threshold
721        assert!((sparse[2][1] - 0.5).abs() < 1e-15);
722    }
723
724    #[test]
725    fn test_inspect_named() {
726        let deltas = vec![[1.0, 0.0, 0.0]];
727        let info = TargetInspector::inspect_named(&deltas, "my_target");
728        assert_eq!(info.name, "my_target");
729        assert_eq!(info.vertex_count, 1);
730        assert_eq!(info.affected_count, 1);
731    }
732
733    #[test]
734    fn test_self_intersection_warning() {
735        let mut deltas = vec![[0.0; 3]; 5];
736        deltas[0] = [0.8, 0.0, 0.0];
737        deltas[1] = [-0.8, 0.0, 0.0]; // range = 1.6 on X > 1.0
738        let warnings = TargetValidator::validate(&deltas, 5).expect("ok");
739        assert!(warnings
740            .iter()
741            .any(|w| w.kind == WarningKind::SelfIntersectionRisk));
742    }
743}