Skip to main content

oxiphysics_softbody/
shape_matching.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Rigid shape matching constraint (Müller et al., 2005).
5//!
6//! Shape matching projects a set of particles toward the best-fit rigid
7//! transformation (rotation + translation) of a rest shape.  The stiffness
8//! parameter controls how strongly particles are pulled toward the goal
9//! positions.
10//!
11//! This module provides two implementations:
12//!
13//! 1. **`ShapeMatching`** – the original nalgebra-based constraint, kept for
14//!    backward compatibility.
15//!
16//! 2. **`ShapeMatchingBody`** – a standalone, pure-`f64`-array body that
17//!    supports plasticity and direct spring-force application.
18
19use oxiphysics_core::math::{Mat3, Real, Vec3};
20
21// ============================================================================
22// Pure-f64 helper math
23// ============================================================================
24
25#[inline]
26fn v3_add(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
27    [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
28}
29
30#[inline]
31fn v3_sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
32    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
33}
34
35#[inline]
36fn v3_scale(a: [f64; 3], s: f64) -> [f64; 3] {
37    [a[0] * s, a[1] * s, a[2] * s]
38}
39
40#[inline]
41fn v3_dot(a: [f64; 3], b: [f64; 3]) -> f64 {
42    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
43}
44
45#[inline]
46fn v3_norm(a: [f64; 3]) -> f64 {
47    v3_dot(a, a).sqrt()
48}
49
50/// 3×3 matrix stored in row-major order: m\[row\]\[col\].
51type Mat3x3 = [[f64; 3]; 3];
52
53fn mat3_zero() -> Mat3x3 {
54    [[0.0; 3]; 3]
55}
56
57fn mat3_identity() -> Mat3x3 {
58    [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]
59}
60
61fn mat3_mul(a: Mat3x3, b: Mat3x3) -> Mat3x3 {
62    let mut c = mat3_zero();
63    for i in 0..3 {
64        for j in 0..3 {
65            for k in 0..3 {
66                c[i][j] += a[i][k] * b[k][j];
67            }
68        }
69    }
70    c
71}
72
73#[cfg(test)]
74fn mat3_transpose(m: Mat3x3) -> Mat3x3 {
75    [
76        [m[0][0], m[1][0], m[2][0]],
77        [m[0][1], m[1][1], m[2][1]],
78        [m[0][2], m[1][2], m[2][2]],
79    ]
80}
81
82fn mat3_det(m: Mat3x3) -> f64 {
83    m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
84        - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
85        + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])
86}
87
88/// Apply a 3×3 matrix to a vector.
89fn mat3_mul_vec(m: Mat3x3, v: [f64; 3]) -> [f64; 3] {
90    [v3_dot(m[0], v), v3_dot(m[1], v), v3_dot(m[2], v)]
91}
92
93/// Outer product: returns `a * bᵀ` as a row-major 3×3 matrix.
94fn outer_product_f64(a: [f64; 3], b: [f64; 3]) -> Mat3x3 {
95    [
96        [a[0] * b[0], a[0] * b[1], a[0] * b[2]],
97        [a[1] * b[0], a[1] * b[1], a[1] * b[2]],
98        [a[2] * b[0], a[2] * b[1], a[2] * b[2]],
99    ]
100}
101
102fn mat3_add_scaled(a: Mat3x3, b: Mat3x3, s: f64) -> Mat3x3 {
103    let mut c = a;
104    for i in 0..3 {
105        for j in 0..3 {
106            c[i][j] += b[i][j] * s;
107        }
108    }
109    c
110}
111
112/// Polar decomposition of `m` via Jacobi SVD, returning the rotation factor R
113/// such that `m ≈ R * S`.
114///
115/// Implemented using nalgebra's SVD for robustness.
116fn polar_rotation_f64(m: Mat3x3) -> Mat3x3 {
117    // Convert to nalgebra matrix.
118    let nm = Mat3::new(
119        m[0][0], m[0][1], m[0][2], m[1][0], m[1][1], m[1][2], m[2][0], m[2][1], m[2][2],
120    );
121    let svd = nm.svd(true, true);
122    let u = svd.u.unwrap_or_else(Mat3::identity);
123    let vt = svd.v_t.unwrap_or_else(Mat3::identity);
124    let mut r_na = u * vt;
125    if r_na.determinant() < 0.0 {
126        let mut u_fixed = u;
127        u_fixed.set_column(2, &(-u.column(2)));
128        r_na = u_fixed * vt;
129    }
130    // Convert back to row-major array.
131    [
132        [r_na[(0, 0)], r_na[(0, 1)], r_na[(0, 2)]],
133        [r_na[(1, 0)], r_na[(1, 1)], r_na[(1, 2)]],
134        [r_na[(2, 0)], r_na[(2, 1)], r_na[(2, 2)]],
135    ]
136}
137
138// ============================================================================
139// ShapeMatchingBody  (pure-f64 standalone)
140// ============================================================================
141
142/// A soft body that uses the shape-matching algorithm to maintain a rigid
143/// rest shape with optional plasticity.
144///
145/// # Algorithm (per timestep)
146///
147/// 1. Compute the current centre of mass `xcm`.
148/// 2. Build the deformation matrix `A = Σ mᵢ (xᵢ − xcm) qᵢᵀ` where `qᵢ`
149///    are the (possibly plastic) rest positions relative to the rest COM.
150/// 3. Extract the optimal rotation `R` via polar decomposition of `A`.
151/// 4. Compute goal positions `gᵢ = R qᵢ + xcm`.
152/// 5. Apply spring forces towards `gᵢ` (or teleport with `stiffness = 1`).
153/// 6. Optionally accumulate plastic deformation.
154#[derive(Debug, Clone)]
155pub struct ShapeMatchingBody {
156    /// Current positions of the particles.
157    pub positions: Vec<[f64; 3]>,
158    /// Current velocities of the particles.
159    pub velocities: Vec<[f64; 3]>,
160    /// Per-particle masses.
161    pub masses: Vec<f64>,
162    /// Rest positions relative to the rest centre of mass.
163    ///
164    /// These are updated when plasticity is applied.
165    rest_positions: Vec<[f64; 3]>,
166    /// The last computed optimal rotation matrix.
167    pub rotation: Mat3x3,
168    /// Stiffness α ∈ \[0, 1\].  0 = no shape matching, 1 = full rigid match.
169    pub stiffness: f64,
170    /// Plastic deformation threshold.  When the positional error exceeds this
171    /// value the rest shape is updated to partially absorb the deformation.
172    pub plasticity_threshold: f64,
173    /// How much of the excess deformation is absorbed plastically per step.
174    /// 0 = no plasticity, 1 = instant plastic yield.
175    pub plasticity_rate: f64,
176    /// Total accumulated plastic deformation magnitude (diagnostic).
177    pub accumulated_plastic_deformation: f64,
178}
179
180impl ShapeMatchingBody {
181    /// Create a new body from rest positions and masses.
182    pub fn new(positions: Vec<[f64; 3]>, masses: Vec<f64>, stiffness: f64) -> Self {
183        assert_eq!(
184            positions.len(),
185            masses.len(),
186            "positions and masses must have the same length"
187        );
188        let n = positions.len();
189
190        // Compute rest centre of mass.
191        let total_mass: f64 = masses.iter().sum();
192        assert!(total_mass > 0.0, "total mass must be positive");
193        let mut cm = [0.0_f64; 3];
194        for (p, &m) in positions.iter().zip(masses.iter()) {
195            cm[0] += p[0] * m;
196            cm[1] += p[1] * m;
197            cm[2] += p[2] * m;
198        }
199        cm = v3_scale(cm, 1.0 / total_mass);
200
201        // Rest positions relative to CM.
202        let rest_positions: Vec<[f64; 3]> = positions.iter().map(|p| v3_sub(*p, cm)).collect();
203
204        Self {
205            positions,
206            velocities: vec![[0.0; 3]; n],
207            masses,
208            rest_positions,
209            rotation: mat3_identity(),
210            stiffness,
211            plasticity_threshold: f64::INFINITY,
212            plasticity_rate: 0.0,
213            accumulated_plastic_deformation: 0.0,
214        }
215    }
216
217    /// Total mass of the body.
218    pub fn total_mass(&self) -> f64 {
219        self.masses.iter().sum()
220    }
221
222    /// Current centre of mass.
223    pub fn centre_of_mass(&self) -> [f64; 3] {
224        let total = self.total_mass();
225        let mut cm = [0.0_f64; 3];
226        for (p, &m) in self.positions.iter().zip(self.masses.iter()) {
227            cm[0] += p[0] * m;
228            cm[1] += p[1] * m;
229            cm[2] += p[2] * m;
230        }
231        v3_scale(cm, 1.0 / total)
232    }
233
234    /// Compute the optimal rotation matrix from current positions.
235    ///
236    /// Updates `self.rotation` and returns it.
237    pub fn compute_optimal_rotation(&mut self) -> Mat3x3 {
238        let xcm = self.centre_of_mass();
239
240        // Deformation matrix A = Σ mᵢ (xᵢ - xcm) qᵢᵀ.
241        let mut a = mat3_zero();
242        for ((pos, q), &mi) in self
243            .positions
244            .iter()
245            .zip(self.rest_positions.iter())
246            .zip(self.masses.iter())
247        {
248            let p = v3_sub(*pos, xcm);
249            let op = outer_product_f64(p, *q);
250            a = mat3_add_scaled(a, op, mi);
251        }
252
253        self.rotation = polar_rotation_f64(a);
254        self.rotation
255    }
256
257    /// Compute goal positions using the current optimal rotation.
258    ///
259    /// `goalᵢ = R * qᵢ + xcm`
260    pub fn goal_positions(&mut self) -> Vec<[f64; 3]> {
261        let r = self.compute_optimal_rotation();
262        let xcm = self.centre_of_mass();
263        self.rest_positions
264            .iter()
265            .map(|q| v3_add(mat3_mul_vec(r, *q), xcm))
266            .collect()
267    }
268
269    /// Apply shape-matching spring forces towards the goal positions.
270    ///
271    /// For each dynamic particle:
272    ///   `v += stiffness * (goal - pos) / dt`
273    ///   `pos += stiffness * (goal - pos)`
274    ///
275    /// After updating, optionally accumulates plastic deformation.
276    pub fn apply_shape_matching_force(&mut self, stiffness: f64, dt: f64) {
277        let goals = self.goal_positions();
278        let n = self.positions.len();
279        for (i, goal) in goals.iter().enumerate().take(n) {
280            let delta = v3_sub(*goal, self.positions[i]);
281            let disp = v3_scale(delta, stiffness);
282            self.positions[i] = v3_add(self.positions[i], disp);
283            // Velocity from positional correction.
284            if dt > 0.0 {
285                self.velocities[i] = v3_add(self.velocities[i], v3_scale(disp, 1.0 / dt));
286            }
287
288            // Plasticity: if positional error exceeds threshold, absorb it.
289            let err = v3_norm(delta);
290            if err > self.plasticity_threshold {
291                let excess = err - self.plasticity_threshold;
292                let plastic_delta = v3_scale(
293                    v3_scale(delta, 1.0 / err.max(1e-12)),
294                    excess * self.plasticity_rate,
295                );
296                // Update rest position towards current to absorb plastic strain.
297                self.rest_positions[i] = v3_add(self.rest_positions[i], plastic_delta);
298                self.accumulated_plastic_deformation += v3_norm(plastic_delta);
299            }
300        }
301    }
302
303    /// Reset the rest shape to the current positions (erase all deformation).
304    pub fn reset_rest_shape(&mut self) {
305        let cm = self.centre_of_mass();
306        self.rest_positions = self.positions.iter().map(|p| v3_sub(*p, cm)).collect();
307        self.accumulated_plastic_deformation = 0.0;
308    }
309}
310
311// ============================================================================
312// Original nalgebra-based ShapeMatching (kept for backward compat)
313// ============================================================================
314
315/// Rigid shape-matching constraint.
316#[derive(Debug, Clone)]
317pub struct ShapeMatching {
318    /// Rest shape positions relative to the rest centre of mass.
319    rest_positions: Vec<Vec3>,
320    /// Per-particle masses.
321    masses: Vec<Real>,
322    /// Stiffness α ∈ \[0, 1\].  0 = no constraint, 1 = full rigid matching.
323    pub stiffness: Real,
324}
325
326impl ShapeMatching {
327    /// Create a new shape-matching constraint from the current rest positions
328    /// and masses.
329    ///
330    /// The rest positions are stored relative to their (mass-weighted) centre
331    /// of mass so that the deformation matrix `A` can be computed cheaply at
332    /// runtime.
333    pub fn new(positions: &[Vec3], masses: &[Real], stiffness: Real) -> Self {
334        assert_eq!(
335            positions.len(),
336            masses.len(),
337            "positions and masses must have the same length"
338        );
339
340        // Compute rest centre of mass.
341        let total_mass: Real = masses.iter().sum();
342        let rest_cm: Vec3 = positions
343            .iter()
344            .zip(masses.iter())
345            .map(|(p, &m)| p * m)
346            .fold(Vec3::zeros(), |a, b| a + b)
347            / total_mass;
348
349        // Store positions relative to rest centre of mass.
350        let rest_positions: Vec<Vec3> = positions.iter().map(|p| p - rest_cm).collect();
351
352        Self {
353            rest_positions,
354            masses: masses.to_vec(),
355            stiffness,
356        }
357    }
358
359    /// Compute the goal positions for each particle by:
360    ///
361    /// 1. Computing the current centre of mass `xcm`.
362    /// 2. Building the deformation matrix
363    ///    `A = Σ mᵢ (xᵢ − xcm) qᵢᵀ`
364    ///    where `qᵢ` is the rest position of particle *i* relative to the rest
365    ///    centre of mass.
366    /// 3. Extracting the rotation `R` via polar decomposition of `A`.
367    /// 4. Computing goal positions as `goalᵢ = stiffness * (R qᵢ + xcm) + (1 − stiffness) * xᵢ`.
368    pub fn goal_positions(&self, positions: &[Vec3]) -> Vec<Vec3> {
369        let n = self.rest_positions.len();
370        assert_eq!(positions.len(), n, "positions length mismatch");
371
372        // --- (1) Current centre of mass ----------------------------------
373        let total_mass: Real = self.masses.iter().sum();
374        let xcm: Vec3 = positions
375            .iter()
376            .zip(self.masses.iter())
377            .map(|(p, &m)| p * m)
378            .fold(Vec3::zeros(), |a, b| a + b)
379            / total_mass;
380
381        // --- (2) Deformation matrix A = Σ mᵢ pᵢ qᵢᵀ ---------------------
382        // where pᵢ = xᵢ - xcm  (current relative position)
383        //       qᵢ = rest_positions[i]  (rest relative position)
384        let mut a = Mat3::zeros();
385        for ((pos, q), &mi) in positions
386            .iter()
387            .zip(self.rest_positions.iter())
388            .zip(self.masses.iter())
389        {
390            let p = pos - xcm;
391            // Outer product p * qᵀ, scaled by mass.
392            a += outer_product(&p, q) * mi;
393        }
394
395        // --- (3) Polar decomposition: extract R from A -------------------
396        let r = polar_rotation(&a);
397
398        // --- (4) Goal positions ------------------------------------------
399        self.rest_positions
400            .iter()
401            .zip(positions.iter())
402            .map(|(q, cur)| {
403                let goal_rigid = r * q + xcm;
404                // Blend between rigid goal and current position by stiffness.
405                goal_rigid * self.stiffness + cur * (1.0 - self.stiffness)
406            })
407            .collect()
408    }
409}
410
411// ---------------------------------------------------------------------------
412// Helpers (nalgebra-based)
413// ---------------------------------------------------------------------------
414
415/// Outer (dyadic) product: returns `a * bᵀ` as a 3×3 matrix.
416fn outer_product(a: &Vec3, b: &Vec3) -> Mat3 {
417    Mat3::new(
418        a.x * b.x,
419        a.x * b.y,
420        a.x * b.z,
421        a.y * b.x,
422        a.y * b.y,
423        a.y * b.z,
424        a.z * b.x,
425        a.z * b.y,
426        a.z * b.z,
427    )
428}
429
430/// Polar decomposition of matrix `m` via SVD, returning the rotation factor
431/// `R` such that `m ≈ R * S` (S symmetric positive semi-definite).
432///
433/// Given the thin SVD `m = U Σ Vᵀ`, the closest rotation is `R = U Vᵀ`.
434/// This is numerically robust even when `m` is rank-deficient (e.g. planar
435/// configurations).
436fn polar_rotation(m: &Mat3) -> Mat3 {
437    let svd = m.svd(true, true);
438    // svd.u and svd.v_t are `Option` – they are `Some` when we pass `true`.
439    let u = svd.u.unwrap_or_else(Mat3::identity);
440    let vt = svd.v_t.unwrap_or_else(Mat3::identity);
441    let mut r = u * vt;
442    // Ensure a proper rotation (det = +1, not a reflection).
443    if r.determinant() < 0.0 {
444        // Flip the column of U corresponding to the smallest singular value.
445        let mut u_fixed = u;
446        u_fixed.set_column(2, &(-u.column(2)));
447        r = u_fixed * vt;
448    }
449    r
450}
451
452// ============================================================================
453// LinearDeformationBody – linear deformation modes (affine shape matching)
454// ============================================================================
455
456/// Shape matching with linear deformation: A itself is used as the goal
457/// deformation matrix instead of just its rotation factor.
458///
459/// This allows volume-preserving stretch in addition to rotation.
460#[derive(Debug, Clone)]
461pub struct LinearDeformationBody {
462    /// Current positions.
463    pub positions: Vec<[f64; 3]>,
464    /// Current velocities.
465    pub velocities: Vec<[f64; 3]>,
466    /// Per-particle masses.
467    pub masses: Vec<f64>,
468    /// Rest positions relative to rest centre of mass.
469    rest_positions: Vec<[f64; 3]>,
470    /// Precomputed inverse of the rest shape matrix Q = Σ mᵢ qᵢ qᵢᵀ.
471    q_inv: Mat3x3,
472    /// Stiffness α ∈ \[0, 1\].
473    pub stiffness: f64,
474    /// Volume preservation blend factor (0 = pure linear, 1 = volume-preserving).
475    pub volume_preservation: f64,
476}
477
478impl LinearDeformationBody {
479    /// Create a new linear deformation body.
480    pub fn new(positions: Vec<[f64; 3]>, masses: Vec<f64>, stiffness: f64) -> Self {
481        assert_eq!(positions.len(), masses.len());
482        let n = positions.len();
483
484        let total_mass: f64 = masses.iter().sum();
485        assert!(total_mass > 0.0);
486
487        let mut cm = [0.0_f64; 3];
488        for (p, &m) in positions.iter().zip(masses.iter()) {
489            cm[0] += p[0] * m;
490            cm[1] += p[1] * m;
491            cm[2] += p[2] * m;
492        }
493        cm = v3_scale(cm, 1.0 / total_mass);
494
495        let rest_positions: Vec<[f64; 3]> = positions.iter().map(|p| v3_sub(*p, cm)).collect();
496
497        // Precompute Q = Σ mᵢ qᵢ qᵢᵀ
498        let mut q_mat = mat3_zero();
499        for (&q, &m) in rest_positions.iter().zip(masses.iter()) {
500            let op = outer_product_f64(q, q);
501            q_mat = mat3_add_scaled(q_mat, op, m);
502        }
503        let q_inv = mat3_inverse_safe(q_mat);
504
505        Self {
506            positions,
507            velocities: vec![[0.0; 3]; n],
508            masses,
509            rest_positions,
510            q_inv,
511            stiffness,
512            volume_preservation: 0.5,
513        }
514    }
515
516    /// Compute current centre of mass.
517    pub fn centre_of_mass(&self) -> [f64; 3] {
518        let total: f64 = self.masses.iter().sum();
519        let mut cm = [0.0_f64; 3];
520        for (p, &m) in self.positions.iter().zip(self.masses.iter()) {
521            cm[0] += p[0] * m;
522            cm[1] += p[1] * m;
523            cm[2] += p[2] * m;
524        }
525        v3_scale(cm, 1.0 / total)
526    }
527
528    /// Compute the linear deformation matrix A_lin = Σ mᵢ pᵢ qᵢᵀ * Q⁻¹.
529    pub fn deformation_matrix(&self) -> Mat3x3 {
530        let xcm = self.centre_of_mass();
531        let mut a = mat3_zero();
532        for ((pos, q), &mi) in self
533            .positions
534            .iter()
535            .zip(self.rest_positions.iter())
536            .zip(self.masses.iter())
537        {
538            let p = v3_sub(*pos, xcm);
539            let op = outer_product_f64(p, *q);
540            a = mat3_add_scaled(a, op, mi);
541        }
542        mat3_mul(a, self.q_inv)
543    }
544
545    /// Compute goal positions using the linear deformation matrix.
546    ///
547    /// Blends between pure rotation (shape matching) and full affine deformation.
548    pub fn goal_positions(&self) -> Vec<[f64; 3]> {
549        let a_lin = self.deformation_matrix();
550        let r = polar_rotation_f64(a_lin);
551        let xcm = self.centre_of_mass();
552
553        // Volume-preserving: blend A_lin with R.
554        let det = mat3_det(a_lin).abs();
555        let vp = self.volume_preservation;
556        let scale_factor = if det > 1e-12 {
557            det.powf(1.0 / 3.0)
558        } else {
559            1.0
560        };
561
562        self.rest_positions
563            .iter()
564            .map(|q| {
565                let goal_affine = mat3_mul_vec(a_lin, *q);
566                let goal_rigid = mat3_mul_vec(r, *q);
567                // Volume-preserving blend: scale affine toward unit-det
568                let goal_vp = v3_scale(mat3_mul_vec(a_lin, *q), 1.0 / scale_factor.max(1e-12));
569                let blended = [
570                    goal_affine[0] * (1.0 - vp) + goal_vp[0] * vp,
571                    goal_affine[1] * (1.0 - vp) + goal_vp[1] * vp,
572                    goal_affine[2] * (1.0 - vp) + goal_vp[2] * vp,
573                ];
574                let final_goal = [
575                    blended[0] * (1.0 - (1.0 - self.stiffness))
576                        + goal_rigid[0] * (1.0 - self.stiffness),
577                    blended[1] * (1.0 - (1.0 - self.stiffness))
578                        + goal_rigid[1] * (1.0 - self.stiffness),
579                    blended[2] * (1.0 - (1.0 - self.stiffness))
580                        + goal_rigid[2] * (1.0 - self.stiffness),
581                ];
582                v3_add(final_goal, xcm)
583            })
584            .collect()
585    }
586
587    /// Apply goal-position correction.
588    pub fn apply_correction(&mut self, stiffness: f64, dt: f64) {
589        let goals = self.goal_positions();
590        for (i, goal) in goals.iter().enumerate() {
591            let delta = v3_sub(*goal, self.positions[i]);
592            let disp = v3_scale(delta, stiffness);
593            self.positions[i] = v3_add(self.positions[i], disp);
594            if dt > 0.0 {
595                self.velocities[i] = v3_add(self.velocities[i], v3_scale(disp, 1.0 / dt));
596            }
597        }
598    }
599}
600
601/// Compute a safe pseudo-inverse of a 3×3 matrix via Cramer's rule.
602/// Falls back to identity when the matrix is near-singular.
603fn mat3_inverse_safe(m: Mat3x3) -> Mat3x3 {
604    let det = mat3_det(m);
605    if det.abs() < 1e-12 {
606        return mat3_identity();
607    }
608    let inv_det = 1.0 / det;
609    [
610        [
611            (m[1][1] * m[2][2] - m[1][2] * m[2][1]) * inv_det,
612            (m[0][2] * m[2][1] - m[0][1] * m[2][2]) * inv_det,
613            (m[0][1] * m[1][2] - m[0][2] * m[1][1]) * inv_det,
614        ],
615        [
616            (m[1][2] * m[2][0] - m[1][0] * m[2][2]) * inv_det,
617            (m[0][0] * m[2][2] - m[0][2] * m[2][0]) * inv_det,
618            (m[0][2] * m[1][0] - m[0][0] * m[1][2]) * inv_det,
619        ],
620        [
621            (m[1][0] * m[2][1] - m[1][1] * m[2][0]) * inv_det,
622            (m[0][1] * m[2][0] - m[0][0] * m[2][1]) * inv_det,
623            (m[0][0] * m[1][1] - m[0][1] * m[1][0]) * inv_det,
624        ],
625    ]
626}
627
628// ============================================================================
629// QuadraticDeformationBody – quadratic deformation modes
630// ============================================================================
631
632/// Extended shape matching with quadratic deformation modes.
633///
634/// The rest-position feature vector is extended to 9 components by appending
635/// quadratic terms: `[qx, qy, qz, qx*qx, qy*qy, qz*qz, qx*qy, qy*qz, qx*qz]`.
636///
637/// This allows bending modes beyond pure affine deformation.
638#[derive(Debug, Clone)]
639pub struct QuadraticDeformationBody {
640    /// Current positions.
641    pub positions: Vec<[f64; 3]>,
642    /// Current velocities.
643    pub velocities: Vec<[f64; 3]>,
644    /// Per-particle masses.
645    pub masses: Vec<f64>,
646    /// 9-component extended rest-position feature vectors.
647    rest_features: Vec<[f64; 9]>,
648    /// Stiffness α ∈ \[0, 1\].
649    pub stiffness: f64,
650}
651
652impl QuadraticDeformationBody {
653    /// Compute the 9-component feature vector from a 3D position.
654    fn feature(q: [f64; 3]) -> [f64; 9] {
655        [
656            q[0],
657            q[1],
658            q[2],
659            q[0] * q[0],
660            q[1] * q[1],
661            q[2] * q[2],
662            q[0] * q[1],
663            q[1] * q[2],
664            q[0] * q[2],
665        ]
666    }
667
668    /// Create a new quadratic deformation body from rest positions and masses.
669    pub fn new(positions: Vec<[f64; 3]>, masses: Vec<f64>, stiffness: f64) -> Self {
670        assert_eq!(positions.len(), masses.len());
671        let total: f64 = masses.iter().sum();
672        assert!(total > 0.0);
673        let mut cm = [0.0_f64; 3];
674        for (p, &m) in positions.iter().zip(masses.iter()) {
675            cm[0] += p[0] * m;
676            cm[1] += p[1] * m;
677            cm[2] += p[2] * m;
678        }
679        cm = v3_scale(cm, 1.0 / total);
680
681        let n = positions.len();
682        let rest_features: Vec<[f64; 9]> = positions
683            .iter()
684            .map(|p| Self::feature(v3_sub(*p, cm)))
685            .collect();
686
687        Self {
688            positions,
689            velocities: vec![[0.0; 3]; n],
690            masses,
691            rest_features,
692            stiffness,
693        }
694    }
695
696    /// Compute current centre of mass.
697    pub fn centre_of_mass(&self) -> [f64; 3] {
698        let total: f64 = self.masses.iter().sum();
699        let mut cm = [0.0_f64; 3];
700        for (p, &m) in self.positions.iter().zip(self.masses.iter()) {
701            cm[0] += p[0] * m;
702            cm[1] += p[1] * m;
703            cm[2] += p[2] * m;
704        }
705        v3_scale(cm, 1.0 / total)
706    }
707
708    /// Compute the 3×9 deformation matrix mapping feature space to 3D.
709    pub fn compute_a39(&self) -> [[f64; 9]; 3] {
710        let xcm = self.centre_of_mass();
711        let mut a = [[0.0_f64; 9]; 3];
712        for ((pos, feat), &mi) in self
713            .positions
714            .iter()
715            .zip(self.rest_features.iter())
716            .zip(self.masses.iter())
717        {
718            let p = v3_sub(*pos, xcm);
719            for row in 0..3 {
720                for col in 0..9 {
721                    a[row][col] += mi * p[row] * feat[col];
722                }
723            }
724        }
725        a
726    }
727
728    /// Compute goal positions using the quadratic deformation matrix.
729    ///
730    /// Goal = A₃₉ * feat(q) + xcm (with stiffness blend).
731    pub fn goal_positions(&self) -> Vec<[f64; 3]> {
732        let a39 = self.compute_a39();
733        let xcm = self.centre_of_mass();
734        self.rest_features
735            .iter()
736            .zip(self.positions.iter())
737            .map(|(feat, cur)| {
738                let mut goal = xcm;
739                for row in 0..3 {
740                    let v: f64 = a39[row].iter().zip(feat.iter()).map(|(a, f)| a * f).sum();
741                    goal[row] += v;
742                }
743                // Stiffness blend
744                [
745                    goal[0] * self.stiffness + cur[0] * (1.0 - self.stiffness),
746                    goal[1] * self.stiffness + cur[1] * (1.0 - self.stiffness),
747                    goal[2] * self.stiffness + cur[2] * (1.0 - self.stiffness),
748                ]
749            })
750            .collect()
751    }
752
753    /// Apply quadratic goal correction.
754    pub fn apply_correction(&mut self, stiffness: f64, dt: f64) {
755        let goals = self.goal_positions();
756        for (i, goal) in goals.iter().enumerate() {
757            let delta = v3_sub(*goal, self.positions[i]);
758            let disp = v3_scale(delta, stiffness);
759            self.positions[i] = v3_add(self.positions[i], disp);
760            if dt > 0.0 {
761                self.velocities[i] = v3_add(self.velocities[i], v3_scale(disp, 1.0 / dt));
762            }
763        }
764    }
765}
766
767// ============================================================================
768// ClusterShapeMatching – cluster-based shape matching
769// ============================================================================
770
771/// A cluster used in cluster-based shape matching.
772///
773/// Each particle may belong to multiple clusters; corrections are averaged.
774#[derive(Debug, Clone)]
775pub struct ShapeMatchingCluster {
776    /// Particle indices belonging to this cluster.
777    pub particle_indices: Vec<usize>,
778    /// Per-cluster rest positions relative to cluster centre of mass.
779    cluster_rest: Vec<[f64; 3]>,
780    /// Cluster stiffness.
781    pub stiffness: f64,
782}
783
784impl ShapeMatchingCluster {
785    /// Create a cluster from a subset of a particle system.
786    ///
787    /// `positions` and `masses` are the full arrays; `indices` selects
788    /// which particles form this cluster.
789    pub fn new(
790        positions: &[[f64; 3]],
791        masses: &[f64],
792        indices: Vec<usize>,
793        stiffness: f64,
794    ) -> Self {
795        let total: f64 = indices.iter().map(|&i| masses[i]).sum();
796        assert!(total > 0.0);
797        let mut cm = [0.0_f64; 3];
798        for &i in &indices {
799            cm[0] += positions[i][0] * masses[i];
800            cm[1] += positions[i][1] * masses[i];
801            cm[2] += positions[i][2] * masses[i];
802        }
803        cm = v3_scale(cm, 1.0 / total);
804        let cluster_rest: Vec<[f64; 3]> =
805            indices.iter().map(|&i| v3_sub(positions[i], cm)).collect();
806        Self {
807            particle_indices: indices,
808            cluster_rest,
809            stiffness,
810        }
811    }
812
813    /// Compute goal corrections for this cluster's particles, returning
814    /// a Vec of `(particle_index, displacement)` pairs.
815    pub fn compute_corrections(
816        &self,
817        positions: &[[f64; 3]],
818        masses: &[f64],
819    ) -> Vec<(usize, [f64; 3])> {
820        // Compute cluster current centre of mass.
821        let total: f64 = self.particle_indices.iter().map(|&i| masses[i]).sum();
822        if total < 1e-30 {
823            return vec![];
824        }
825        let mut xcm = [0.0_f64; 3];
826        for &i in &self.particle_indices {
827            xcm[0] += positions[i][0] * masses[i];
828            xcm[1] += positions[i][1] * masses[i];
829            xcm[2] += positions[i][2] * masses[i];
830        }
831        xcm = v3_scale(xcm, 1.0 / total);
832
833        // Deformation matrix.
834        let mut a = mat3_zero();
835        for (&i, q) in self.particle_indices.iter().zip(self.cluster_rest.iter()) {
836            let p = v3_sub(positions[i], xcm);
837            let op = outer_product_f64(p, *q);
838            a = mat3_add_scaled(a, op, masses[i]);
839        }
840        let r = polar_rotation_f64(a);
841
842        // Goal positions.
843        self.particle_indices
844            .iter()
845            .zip(self.cluster_rest.iter())
846            .map(|(&i, q)| {
847                let goal = v3_add(mat3_mul_vec(r, *q), xcm);
848                let delta = v3_sub(goal, positions[i]);
849                (i, v3_scale(delta, self.stiffness))
850            })
851            .collect()
852    }
853}
854
855/// Manager for cluster-based shape matching over a shared particle set.
856///
857/// Each particle may belong to multiple clusters; per-particle corrections
858/// are averaged across clusters.
859pub struct ClusterShapeMatchingSystem {
860    /// Particle positions.
861    pub positions: Vec<[f64; 3]>,
862    /// Particle velocities.
863    pub velocities: Vec<[f64; 3]>,
864    /// Per-particle masses.
865    pub masses: Vec<f64>,
866    /// All clusters.
867    pub clusters: Vec<ShapeMatchingCluster>,
868}
869
870impl ClusterShapeMatchingSystem {
871    /// Create an empty system with `n` particles.
872    pub fn new(positions: Vec<[f64; 3]>, masses: Vec<f64>) -> Self {
873        let n = positions.len();
874        Self {
875            positions,
876            velocities: vec![[0.0; 3]; n],
877            masses,
878            clusters: Vec::new(),
879        }
880    }
881
882    /// Add a cluster.
883    pub fn add_cluster(&mut self, indices: Vec<usize>, stiffness: f64) {
884        let c = ShapeMatchingCluster::new(&self.positions, &self.masses, indices, stiffness);
885        self.clusters.push(c);
886    }
887
888    /// Apply one step of cluster shape matching.
889    ///
890    /// Corrections from all clusters are averaged per particle.
891    pub fn step(&mut self, dt: f64) {
892        let n = self.positions.len();
893        let mut total_correction = vec![[0.0_f64; 3]; n];
894        let mut correction_count = vec![0usize; n];
895
896        for cluster in &self.clusters {
897            let corrections = cluster.compute_corrections(&self.positions, &self.masses);
898            for (i, delta) in corrections {
899                total_correction[i] = v3_add(total_correction[i], delta);
900                correction_count[i] += 1;
901            }
902        }
903
904        for i in 0..n {
905            if correction_count[i] > 0 {
906                let avg = v3_scale(total_correction[i], 1.0 / correction_count[i] as f64);
907                self.positions[i] = v3_add(self.positions[i], avg);
908                if dt > 0.0 {
909                    self.velocities[i] = v3_add(self.velocities[i], v3_scale(avg, 1.0 / dt));
910                }
911            }
912        }
913    }
914}
915
916// ============================================================================
917// PlasticityImprovedBody – improved plasticity with yield surface
918// ============================================================================
919
920/// Improved plasticity model with von-Mises-like yield criterion.
921///
922/// Tracks the cumulative plastic strain and applies a hardening response
923/// once yield stress is exceeded.
924#[derive(Debug, Clone)]
925pub struct PlasticityImprovedBody {
926    /// Current positions.
927    pub positions: Vec<[f64; 3]>,
928    /// Current velocities.
929    pub velocities: Vec<[f64; 3]>,
930    /// Per-particle masses.
931    pub masses: Vec<f64>,
932    /// Plastic rest positions.
933    plastic_rest: Vec<[f64; 3]>,
934    /// Cumulative plastic strain per particle.
935    pub plastic_strain: Vec<f64>,
936    /// Yield stress σ_y: deformation below this is elastic.
937    pub yield_stress: f64,
938    /// Hardening modulus H: reduces yield stress over time (softening if < 0).
939    pub hardening_modulus: f64,
940    /// Plasticity rate (0..1).
941    pub plasticity_rate: f64,
942    /// Shape matching stiffness.
943    pub stiffness: f64,
944    /// Rotation matrix.
945    pub rotation: Mat3x3,
946}
947
948impl PlasticityImprovedBody {
949    /// Create a new body with improved plasticity.
950    pub fn new(positions: Vec<[f64; 3]>, masses: Vec<f64>, stiffness: f64) -> Self {
951        assert_eq!(positions.len(), masses.len());
952        let n = positions.len();
953        let total: f64 = masses.iter().sum();
954        assert!(total > 0.0);
955        let mut cm = [0.0_f64; 3];
956        for (p, &m) in positions.iter().zip(masses.iter()) {
957            cm[0] += p[0] * m;
958            cm[1] += p[1] * m;
959            cm[2] += p[2] * m;
960        }
961        cm = v3_scale(cm, 1.0 / total);
962        let plastic_rest: Vec<[f64; 3]> = positions.iter().map(|p| v3_sub(*p, cm)).collect();
963        Self {
964            positions,
965            velocities: vec![[0.0; 3]; n],
966            masses,
967            plastic_rest,
968            plastic_strain: vec![0.0; n],
969            yield_stress: 0.1,
970            hardening_modulus: 0.0,
971            plasticity_rate: 0.3,
972            stiffness,
973            rotation: mat3_identity(),
974        }
975    }
976
977    /// Current centre of mass.
978    pub fn centre_of_mass(&self) -> [f64; 3] {
979        let total: f64 = self.masses.iter().sum();
980        let mut cm = [0.0_f64; 3];
981        for (p, &m) in self.positions.iter().zip(self.masses.iter()) {
982            cm[0] += p[0] * m;
983            cm[1] += p[1] * m;
984            cm[2] += p[2] * m;
985        }
986        v3_scale(cm, 1.0 / total)
987    }
988
989    /// Apply one shape-matching step with improved plasticity.
990    ///
991    /// The effective yield stress varies with accumulated plastic strain (hardening).
992    pub fn step(&mut self, dt: f64) {
993        let xcm = self.centre_of_mass();
994
995        let mut a = mat3_zero();
996        for ((pos, q), &mi) in self
997            .positions
998            .iter()
999            .zip(self.plastic_rest.iter())
1000            .zip(self.masses.iter())
1001        {
1002            let p = v3_sub(*pos, xcm);
1003            let op = outer_product_f64(p, *q);
1004            a = mat3_add_scaled(a, op, mi);
1005        }
1006        self.rotation = polar_rotation_f64(a);
1007        let r = self.rotation;
1008
1009        let n = self.positions.len();
1010        for i in 0..n {
1011            let goal = v3_add(mat3_mul_vec(r, self.plastic_rest[i]), xcm);
1012            let delta = v3_sub(goal, self.positions[i]);
1013            let disp = v3_scale(delta, self.stiffness);
1014            self.positions[i] = v3_add(self.positions[i], disp);
1015            if dt > 0.0 {
1016                self.velocities[i] = v3_add(self.velocities[i], v3_scale(disp, 1.0 / dt));
1017            }
1018
1019            // Improved plasticity with hardening
1020            let err = v3_norm(delta);
1021            let effective_yield =
1022                (self.yield_stress + self.hardening_modulus * self.plastic_strain[i]).max(1e-12);
1023            if err > effective_yield {
1024                let excess = err - effective_yield;
1025                let dir = v3_scale(delta, 1.0 / err.max(1e-12));
1026                let plastic_delta = v3_scale(dir, excess * self.plasticity_rate);
1027                self.plastic_rest[i] = v3_add(self.plastic_rest[i], plastic_delta);
1028                self.plastic_strain[i] += v3_norm(plastic_delta);
1029            }
1030        }
1031    }
1032
1033    /// Total accumulated plastic strain across all particles.
1034    pub fn total_plastic_strain(&self) -> f64 {
1035        self.plastic_strain.iter().sum()
1036    }
1037
1038    /// Reset plastic rest shape to current positions.
1039    pub fn reset_plasticity(&mut self) {
1040        let cm = self.centre_of_mass();
1041        self.plastic_rest = self.positions.iter().map(|p| v3_sub(*p, cm)).collect();
1042        for s in &mut self.plastic_strain {
1043            *s = 0.0;
1044        }
1045    }
1046}
1047
1048// ============================================================================
1049// Tests
1050// ============================================================================
1051
1052#[cfg(test)]
1053mod tests {
1054    use super::*;
1055
1056    const EPS: f64 = 1e-6;
1057
1058    // Helper: apply a 3×3 rotation to a position.
1059    fn rotate(r: Mat3x3, v: [f64; 3]) -> [f64; 3] {
1060        mat3_mul_vec(r, v)
1061    }
1062
1063    // Helper: Euclidean distance between two [f64;3] points.
1064    fn dist3(a: [f64; 3], b: [f64; 3]) -> f64 {
1065        v3_norm(v3_sub(a, b))
1066    }
1067
1068    // SM1. Rigid body: pure rotation → no deformation → goals equal current.
1069    //
1070    // If the current positions are a pure rotation R of the rest positions
1071    // (with stiffness=1), then goal_positions() == current positions.
1072    #[test]
1073    fn test_rigid_body_no_deformation() {
1074        // Tetrahedron rest positions.
1075        let rest = vec![
1076            [1.0, 0.0, 0.0],
1077            [-1.0, 0.0, 0.0],
1078            [0.0, 1.0, 0.0],
1079            [0.0, -1.0, 0.0],
1080        ];
1081        let masses = vec![1.0_f64; 4];
1082        let mut body = ShapeMatchingBody::new(rest.clone(), masses, 1.0);
1083
1084        // Apply a 90° rotation about Z to current positions.
1085        let rz90: Mat3x3 = [[0.0, -1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]];
1086        body.positions = rest.iter().map(|p| rotate(rz90, *p)).collect();
1087
1088        let goals = body.goal_positions();
1089        for (i, (goal, cur)) in goals.iter().zip(body.positions.iter()).enumerate() {
1090            assert!(
1091                dist3(*goal, *cur) < EPS,
1092                "Particle {i}: goal {:?} should equal current {:?}",
1093                goal,
1094                cur
1095            );
1096        }
1097    }
1098
1099    // SM2. Pure translation: shape matching should follow the centre of mass.
1100    //
1101    // Translating all particles by a constant vector should not introduce any
1102    // rotational error – goals should equal current positions.
1103    #[test]
1104    fn test_pure_translation() {
1105        let rest = vec![
1106            [0.0, 0.0, 0.0],
1107            [1.0, 0.0, 0.0],
1108            [0.0, 1.0, 0.0],
1109            [0.0, 0.0, 1.0],
1110        ];
1111        let masses = vec![1.0_f64; 4];
1112        let mut body = ShapeMatchingBody::new(rest.clone(), masses, 1.0);
1113
1114        // Translate all particles.
1115        let offset = [5.0, -3.0, 2.0];
1116        body.positions = rest.iter().map(|p| v3_add(*p, offset)).collect();
1117
1118        let goals = body.goal_positions();
1119        for (i, (goal, cur)) in goals.iter().zip(body.positions.iter()).enumerate() {
1120            assert!(
1121                dist3(*goal, *cur) < EPS,
1122                "Particle {i}: goal {:?} should equal current {:?} after pure translation",
1123                goal,
1124                cur
1125            );
1126        }
1127    }
1128
1129    // SM3. Rotation invariance: rotating rest AND current by the same R
1130    //      should not change the positional errors.
1131    #[test]
1132    fn test_rotation_invariance() {
1133        let rest = vec![[1.0, 0.0, 0.0], [-1.0, 0.0, 0.0], [0.0, 1.5, 0.0]];
1134        let masses = vec![1.0_f64; 3];
1135
1136        // Deformed positions (a simple stretch).
1137        let deformed: Vec<[f64; 3]> = vec![[2.0, 0.0, 0.0], [-1.0, 0.0, 0.0], [0.0, 1.5, 0.0]];
1138
1139        // Errors without rotation.
1140        let mut body_a = ShapeMatchingBody::new(rest.clone(), masses.clone(), 1.0);
1141        body_a.positions = deformed.clone();
1142        let goals_a = body_a.goal_positions();
1143        let errors_a: Vec<f64> = goals_a
1144            .iter()
1145            .zip(deformed.iter())
1146            .map(|(g, c)| dist3(*g, *c))
1147            .collect();
1148
1149        // Apply a 45° rotation about Z to both rest and deformed.
1150        let c45 = std::f64::consts::FRAC_PI_4.cos();
1151        let s45 = std::f64::consts::FRAC_PI_4.sin();
1152        let rz45: Mat3x3 = [[c45, -s45, 0.0], [s45, c45, 0.0], [0.0, 0.0, 1.0]];
1153
1154        let rotated_rest: Vec<[f64; 3]> = rest.iter().map(|p| rotate(rz45, *p)).collect();
1155        let rotated_deformed: Vec<[f64; 3]> = deformed.iter().map(|p| rotate(rz45, *p)).collect();
1156
1157        let mut body_b = ShapeMatchingBody::new(rotated_rest, masses.clone(), 1.0);
1158        body_b.positions = rotated_deformed.clone();
1159        let goals_b = body_b.goal_positions();
1160        let errors_b: Vec<f64> = goals_b
1161            .iter()
1162            .zip(rotated_deformed.iter())
1163            .map(|(g, c)| dist3(*g, *c))
1164            .collect();
1165
1166        for i in 0..3 {
1167            assert!(
1168                (errors_a[i] - errors_b[i]).abs() < 1e-5,
1169                "Rotation invariance violated at particle {i}: {:.6} vs {:.6}",
1170                errors_a[i],
1171                errors_b[i]
1172            );
1173        }
1174    }
1175
1176    // SM4. apply_shape_matching_force with stiffness=1 teleports particles to goals.
1177    #[test]
1178    fn test_apply_force_stiffness_one() {
1179        let rest = vec![[1.0, 0.0, 0.0], [-1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
1180        let masses = vec![1.0_f64; 3];
1181        let mut body = ShapeMatchingBody::new(rest.clone(), masses, 1.0);
1182
1183        // Perturb one particle.
1184        body.positions[2] = [0.5, 1.5, 0.3];
1185
1186        let goals_before = body.goal_positions();
1187        body.positions = goals_before.clone(); // manually set for the next check
1188        // After applying with stiffness=1, positions should equal goals.
1189        let mut body2 = ShapeMatchingBody::new(rest, vec![1.0_f64; 3], 1.0);
1190        body2.positions[2] = [0.5, 1.5, 0.3];
1191        body2.apply_shape_matching_force(1.0, 0.016);
1192
1193        // Goals after application should be very close to new positions.
1194        let goals_after = body2.goal_positions();
1195        for (i, (goal, pos)) in goals_after.iter().zip(body2.positions.iter()).enumerate() {
1196            assert!(
1197                dist3(*goal, *pos) < 1e-4,
1198                "Particle {i}: position {:?} should be close to goal {:?}",
1199                pos,
1200                goal
1201            );
1202        }
1203    }
1204
1205    // SM5. Plasticity accumulates when deformation exceeds threshold.
1206    #[test]
1207    fn test_plasticity_accumulates() {
1208        let rest = vec![[1.0, 0.0, 0.0], [-1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
1209        let masses = vec![1.0_f64; 3];
1210        let mut body = ShapeMatchingBody::new(rest, masses, 0.5);
1211        body.plasticity_threshold = 0.01;
1212        body.plasticity_rate = 0.5;
1213
1214        // Stretch particle 0 significantly.
1215        body.positions[0] = [3.0, 0.0, 0.0];
1216
1217        let plastic_before = body.accumulated_plastic_deformation;
1218        body.apply_shape_matching_force(0.5, 0.016);
1219        let plastic_after = body.accumulated_plastic_deformation;
1220
1221        assert!(
1222            plastic_after > plastic_before,
1223            "Plastic deformation should have accumulated: before={plastic_before}, after={plastic_after}"
1224        );
1225    }
1226
1227    // SM6. reset_rest_shape zeroes accumulated plasticity.
1228    #[test]
1229    fn test_reset_rest_shape() {
1230        let rest = vec![[1.0, 0.0, 0.0], [-1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
1231        let masses = vec![1.0_f64; 3];
1232        let mut body = ShapeMatchingBody::new(rest, masses, 0.5);
1233        body.accumulated_plastic_deformation = 99.0;
1234
1235        body.reset_rest_shape();
1236
1237        assert_eq!(
1238            body.accumulated_plastic_deformation, 0.0,
1239            "Accumulated plasticity should be zero after reset"
1240        );
1241
1242        // After reset, current positions should be the new rest (goals = current).
1243        let goals = body.goal_positions();
1244        for (i, (goal, pos)) in goals.iter().zip(body.positions.iter()).enumerate() {
1245            assert!(
1246                dist3(*goal, *pos) < EPS,
1247                "Particle {i}: after reset goals {:?} should equal positions {:?}",
1248                goal,
1249                pos
1250            );
1251        }
1252    }
1253
1254    // SM7. mat3_mul of two identity matrices is identity.
1255    #[test]
1256    fn test_mat3_mul_identity() {
1257        let i = mat3_identity();
1258        let r = mat3_mul(i, i);
1259        for (row, r_row) in r.iter().enumerate() {
1260            for (col, &v) in r_row.iter().enumerate() {
1261                let expected = if row == col { 1.0 } else { 0.0 };
1262                assert!(
1263                    (v - expected).abs() < 1e-12,
1264                    "mat3_mul(I,I)[{row}][{col}] = {} (expected {expected})",
1265                    v
1266                );
1267            }
1268        }
1269    }
1270
1271    // SM8. mat3_transpose inverts correctly.
1272    #[test]
1273    fn test_mat3_transpose() {
1274        let m: Mat3x3 = [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]];
1275        let mt = mat3_transpose(m);
1276        for i in 0..3 {
1277            for j in 0..3 {
1278                assert!(
1279                    (mt[i][j] - m[j][i]).abs() < 1e-12,
1280                    "Transpose mismatch at [{i}][{j}]"
1281                );
1282            }
1283        }
1284    }
1285
1286    // SM9. mat3_inverse_safe of identity should be identity.
1287    #[test]
1288    fn test_mat3_inverse_safe_identity() {
1289        let i = mat3_identity();
1290        let inv = mat3_inverse_safe(i);
1291        for (r, inv_row) in inv.iter().enumerate() {
1292            for (c, &v) in inv_row.iter().enumerate() {
1293                let expected = if r == c { 1.0 } else { 0.0 };
1294                assert!(
1295                    (v - expected).abs() < 1e-10,
1296                    "Inverse of identity should be identity at [{r}][{c}]"
1297                );
1298            }
1299        }
1300    }
1301
1302    // SM10. mat3_inverse_safe of near-singular returns identity (safe fallback).
1303    #[test]
1304    fn test_mat3_inverse_safe_singular() {
1305        let singular: Mat3x3 = [[1.0, 2.0, 3.0], [2.0, 4.0, 6.0], [1.0, 2.0, 3.0]];
1306        let inv = mat3_inverse_safe(singular);
1307        // Should not panic; result should be identity (fallback).
1308        let _ = inv;
1309    }
1310
1311    // SM11. LinearDeformationBody: pure translation → goals ≈ current.
1312    #[test]
1313    fn test_linear_deformation_pure_translation() {
1314        let rest = vec![
1315            [0.0, 0.0, 0.0],
1316            [1.0, 0.0, 0.0],
1317            [0.0, 1.0, 0.0],
1318            [0.0, 0.0, 1.0],
1319        ];
1320        let masses = vec![1.0_f64; 4];
1321        let mut body = LinearDeformationBody::new(rest.clone(), masses, 1.0);
1322        let offset = [3.0, 2.0, 1.0];
1323        body.positions = rest.iter().map(|p| v3_add(*p, offset)).collect();
1324        let goals = body.goal_positions();
1325        for (i, (goal, cur)) in goals.iter().zip(body.positions.iter()).enumerate() {
1326            let d = v3_norm(v3_sub(*goal, *cur));
1327            assert!(
1328                d < 0.2,
1329                "LinearDeformation pure translation particle {i}: d={d}"
1330            );
1331        }
1332    }
1333
1334    // SM12. LinearDeformationBody: apply_correction moves positions toward goals.
1335    #[test]
1336    fn test_linear_deformation_apply_correction() {
1337        let rest = vec![
1338            [1.0, 0.0, 0.0],
1339            [-1.0, 0.0, 0.0],
1340            [0.0, 1.0, 0.0],
1341            [0.0, -1.0, 0.0],
1342        ];
1343        let masses = vec![1.0_f64; 4];
1344        let mut body = LinearDeformationBody::new(rest, masses, 1.0);
1345        // Perturb.
1346        body.positions[0] = [2.0, 0.5, 0.3];
1347        body.apply_correction(0.5, 0.016);
1348        // Positions should have changed.
1349        assert!(
1350            (body.positions[0][0] - 2.0).abs() > 0.01 || (body.positions[0][1] - 0.5).abs() > 0.01,
1351            "LinearDeformation: correction should move particles"
1352        );
1353    }
1354
1355    // SM13. QuadraticDeformationBody: feature vector has 9 components.
1356    #[test]
1357    fn test_quadratic_feature_vector_length() {
1358        let q = [1.0, 2.0, 3.0];
1359        let feat = QuadraticDeformationBody::feature(q);
1360        assert_eq!(feat.len(), 9);
1361        assert!((feat[0] - 1.0).abs() < 1e-12);
1362        assert!((feat[3] - 1.0).abs() < 1e-12); // qx*qx = 1
1363        assert!((feat[4] - 4.0).abs() < 1e-12); // qy*qy = 4
1364        assert!((feat[6] - 2.0).abs() < 1e-12); // qx*qy = 2
1365    }
1366
1367    // SM14. QuadraticDeformationBody: goal positions exist for a small system.
1368    #[test]
1369    fn test_quadratic_deformation_goals_exist() {
1370        let rest = vec![
1371            [1.0, 0.0, 0.0],
1372            [-1.0, 0.0, 0.0],
1373            [0.0, 1.0, 0.0],
1374            [0.0, -1.0, 0.0],
1375            [0.0, 0.0, 1.0],
1376        ];
1377        let masses = vec![1.0_f64; 5];
1378        let body = QuadraticDeformationBody::new(rest.clone(), masses, 1.0);
1379        let goals = body.goal_positions();
1380        assert_eq!(goals.len(), rest.len());
1381        // Goals should be finite.
1382        for g in &goals {
1383            for &v in g {
1384                assert!(v.is_finite(), "goal component should be finite");
1385            }
1386        }
1387    }
1388
1389    // SM15. ClusterShapeMatching: single cluster covers all particles → same as basic SM.
1390    #[test]
1391    fn test_cluster_single_cluster() {
1392        let positions = vec![
1393            [1.0, 0.0, 0.0],
1394            [-1.0, 0.0, 0.0],
1395            [0.0, 1.0, 0.0],
1396            [0.0, -1.0, 0.0],
1397        ];
1398        let masses = vec![1.0_f64; 4];
1399        let mut sys = ClusterShapeMatchingSystem::new(positions.clone(), masses.clone());
1400        sys.add_cluster(vec![0, 1, 2, 3], 1.0);
1401
1402        // Perturb one particle.
1403        sys.positions[0] = [3.0, 0.0, 0.0];
1404        let before = sys.positions[0];
1405        sys.step(0.016);
1406        let after = sys.positions[0];
1407        // Position should have moved toward the goal.
1408        let d_before = v3_norm(v3_sub(before, [1.0, 0.0, 0.0]));
1409        let d_after = v3_norm(v3_sub(after, [1.0, 0.0, 0.0]));
1410        assert!(
1411            d_after < d_before + 0.01,
1412            "Cluster SM should move particle closer to goal"
1413        );
1414    }
1415
1416    // SM16. ClusterShapeMatching: two overlapping clusters both apply corrections.
1417    #[test]
1418    fn test_cluster_two_overlapping_clusters() {
1419        let positions = vec![
1420            [0.0, 0.0, 0.0],
1421            [1.0, 0.0, 0.0],
1422            [2.0, 0.0, 0.0],
1423            [3.0, 0.0, 0.0],
1424        ];
1425        let masses = vec![1.0_f64; 4];
1426        let mut sys = ClusterShapeMatchingSystem::new(positions, masses);
1427        sys.add_cluster(vec![0, 1, 2], 0.5);
1428        sys.add_cluster(vec![1, 2, 3], 0.5);
1429        // Should not panic with overlapping clusters.
1430        sys.step(0.016);
1431        assert_eq!(sys.positions.len(), 4);
1432    }
1433
1434    // SM17. PlasticityImprovedBody: plastic strain increases above yield.
1435    #[test]
1436    fn test_improved_plasticity_accumulates() {
1437        let rest = vec![[1.0, 0.0, 0.0], [-1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
1438        let masses = vec![1.0_f64; 3];
1439        let mut body = PlasticityImprovedBody::new(rest, masses, 0.5);
1440        body.yield_stress = 0.01;
1441        body.plasticity_rate = 0.5;
1442        // Large deformation.
1443        body.positions[0] = [5.0, 0.0, 0.0];
1444        let before = body.total_plastic_strain();
1445        body.step(0.016);
1446        let after = body.total_plastic_strain();
1447        assert!(
1448            after > before,
1449            "Plastic strain should accumulate: before={before}, after={after}"
1450        );
1451    }
1452
1453    // SM18. PlasticityImprovedBody: reset zeroes plastic strain.
1454    #[test]
1455    fn test_improved_plasticity_reset() {
1456        let rest = vec![[1.0, 0.0, 0.0], [-1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
1457        let masses = vec![1.0_f64; 3];
1458        let mut body = PlasticityImprovedBody::new(rest, masses, 0.5);
1459        body.plastic_strain[0] = 5.0;
1460        body.reset_plasticity();
1461        assert_eq!(body.total_plastic_strain(), 0.0);
1462    }
1463
1464    // SM19. mat3_det of rotation matrix should be ≈ 1.
1465    #[test]
1466    fn test_mat3_det_rotation() {
1467        let c = std::f64::consts::FRAC_PI_4.cos();
1468        let s = std::f64::consts::FRAC_PI_4.sin();
1469        let rz: Mat3x3 = [[c, -s, 0.0], [s, c, 0.0], [0.0, 0.0, 1.0]];
1470        let det = mat3_det(rz);
1471        assert!(
1472            (det - 1.0).abs() < 1e-12,
1473            "Rotation matrix det should be 1, got {det}"
1474        );
1475    }
1476
1477    // SM20. ShapeMatchingBody: centre_of_mass is mass-weighted.
1478    #[test]
1479    fn test_centre_of_mass_mass_weighted() {
1480        let rest = vec![[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]];
1481        let masses = vec![1.0_f64, 3.0]; // Total = 4; CM_x = (0*1 + 2*3)/4 = 1.5
1482        let body = ShapeMatchingBody::new(rest, masses, 1.0);
1483        let cm = body.centre_of_mass();
1484        assert!(
1485            (cm[0] - 1.5).abs() < 1e-12,
1486            "CM_x should be 1.5, got {}",
1487            cm[0]
1488        );
1489        assert!(cm[1].abs() < 1e-12);
1490        assert!(cm[2].abs() < 1e-12);
1491    }
1492}