Skip to main content

rapier3d/dynamics/soft_body/
soft_body_cluster.rs

1//! Soft-body clusters: weighted particle sets, each backed by a proxy rigid body
2//! ([`crate::dynamics::RigidBodyType::SoftFrame`]) standing for it in the islands and joints.
3//! Index 0 is the whole-body cluster; removing a cluster deletes the particles it alone covers.
4
5#[cfg(not(feature = "std"))]
6#[allow(unused_imports)]
7use simba::scalar::{ComplexField as _, RealField as _};
8
9use crate::alloc_prelude::*;
10use crate::dynamics::RigidBodyHandle;
11use crate::math::{AngVector, AngularInertia, DIM, Pose, Real, Rotation, Vector};
12use crate::utils::{AngularInertiaOps, CrossProduct};
13
14use super::SoftBody;
15use super::soft_body_shape_matching::extract_rotation;
16
17/// A cluster of a soft body: a set of its particles backed by a proxy rigid body
18/// ([`crate::dynamics::RigidBodyType::SoftFrame`]) carrying its frame, gathered velocity and
19/// reduced mass. A rank-deficient one (one particle, or collinear in 3D) has no angular response.
20#[derive(Clone, Debug)]
21#[cfg_attr(feature = "serde-serialize", derive(Serialize, Deserialize))]
22pub struct SoftBodyCluster {
23    /// The particles of the cluster (sorted, unique). Empty for a removed cluster slot.
24    pub(crate) particles: Vec<u32>,
25    /// The cluster's proxy rigid body (invalid for a removed cluster slot).
26    pub(crate) proxy: RigidBodyHandle,
27    /// Warm-started rotation of the cluster's frame fit.
28    // NOTE: read once the frames go live (the joint phase); stored from the start so the
29    // serialization format is stable.
30    #[cfg_attr(feature = "serde-serialize", serde(default))]
31    pub(crate) rotation: Rotation,
32    /// The cell whose vertices are exactly this cluster's particles, if any: the frame rotation
33    /// is then the cell's corotational rotation (exact and free) instead of a shape-match fit.
34    #[cfg_attr(feature = "serde-serialize", serde(default = "invalid_cell"))]
35    pub(crate) cell: u32,
36    /// Whether this cluster's particles are shape-matched toward the cluster's frame.
37    #[cfg_attr(feature = "serde-serialize", serde(default))]
38    pub(crate) shape_matching: bool,
39    pub(crate) shape_matching_target: Option<Pose>,
40    #[cfg_attr(feature = "serde-serialize", serde(skip))]
41    pub(crate) prev_shape_matching_target: Option<Pose>,
42    /// The rigid velocity gathered from the particles when the proxy was last updated: the
43    /// proxy's `rb.vels` was set to exactly this, so `rb.vels - last_gather` is the external
44    /// impulse the user applied to the proxy since (scattered by the solver's first pass).
45    #[cfg_attr(feature = "serde-serialize", serde(default))]
46    pub(crate) last_gather: (Vector, AngVector),
47    /// Warm-start impulses of this cluster's shape-matching constraints, parallel to `particles`
48    /// (empty until the cluster is shape-matched).
49    #[cfg_attr(feature = "serde-serialize", serde(default))]
50    pub(crate) shape_impulses: Vec<Vector>,
51    /// The collision meshes this cluster owns (see [`super::SoftCollisionMesh`]). Removed
52    /// meshes leave a `None` slot so ids stay stable.
53    #[cfg_attr(feature = "serde-serialize", serde(default))]
54    pub(crate) meshes: Vec<Option<super::SoftCollisionMesh>>,
55}
56
57#[cfg(feature = "serde-serialize")]
58fn invalid_cell() -> u32 {
59    u32::MAX
60}
61
62impl SoftBodyCluster {
63    /// A cluster over `particles`, backed by the proxy rigid body `proxy`.
64    pub fn new(particles: Vec<u32>, proxy: RigidBodyHandle, shape_matching: bool) -> Self {
65        SoftBodyCluster {
66            particles,
67            proxy,
68            rotation: Rotation::IDENTITY,
69            cell: u32::MAX,
70            shape_matching,
71            shape_matching_target: None,
72            prev_shape_matching_target: None,
73            last_gather: Default::default(),
74            shape_impulses: vec![],
75            meshes: Vec::new(),
76        }
77    }
78
79    /// Whether this cluster slot is live (not removed).
80    #[inline]
81    pub fn is_live(&self) -> bool {
82        !self.proxy.is_invalid()
83    }
84
85    /// Turns this slot into a dead one, returning its particles and its proxy. The meshes are
86    /// dropped: their colliders are the caller's to deal with.
87    pub(crate) fn tombstone(&mut self) -> (Vec<u32>, RigidBodyHandle) {
88        self.cell = u32::MAX;
89        self.shape_matching = false;
90        self.shape_matching_target = None;
91        self.prev_shape_matching_target = None;
92        self.shape_impulses.clear();
93        self.meshes.clear();
94        (
95            core::mem::take(&mut self.particles),
96            core::mem::replace(&mut self.proxy, RigidBodyHandle::invalid()),
97        )
98    }
99
100    /// The particles of this cluster (sorted, unique).
101    pub fn particles(&self) -> &[u32] {
102        &self.particles
103    }
104
105    /// The cluster's proxy rigid body.
106    pub fn proxy(&self) -> RigidBodyHandle {
107        self.proxy
108    }
109
110    /// The collision meshes this cluster owns mutably (dead slots skipped).
111    pub(crate) fn meshes_mut(&mut self) -> impl Iterator<Item = &mut super::SoftCollisionMesh> {
112        self.meshes.iter_mut().flatten()
113    }
114
115    /// The collision meshes this cluster owns (dead slots skipped).
116    pub fn meshes(&self) -> impl Iterator<Item = &super::SoftCollisionMesh> {
117        self.meshes.iter().flatten()
118    }
119
120    /// The `i`-th mesh of this cluster, if it is live.
121    pub fn mesh(&self, i: u32) -> Option<&super::SoftCollisionMesh> {
122        self.meshes.get(i as usize).and_then(|mesh| mesh.as_ref())
123    }
124
125    /// Whether this cluster's particles are shape-matched toward its frame.
126    pub fn shape_matching_enabled(&self) -> bool {
127        self.shape_matching
128    }
129
130    /// Sets the pose the cluster's particles are shape-matched toward (`None`: its own frame).
131    pub fn set_shape_matching_target(&mut self, target: Option<Pose>) {
132        self.shape_matching_target = target;
133    }
134}
135
136/// What [`crate::dynamics::SoftBodySet::remove_cluster`] (or removing a proxy rigid body)
137/// deleted: the particles only the removed cluster covered, and everything attached to them.
138#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
139pub struct SoftClusterRemoval {
140    /// Particles removed (they belonged to no other cluster).
141    pub particles: usize,
142    /// Distance constraints removed with them.
143    pub edges: usize,
144    /// Cells removed with them.
145    pub cells: usize,
146    /// Dihedral bending constraints removed with them (3D).
147    pub dihedrals: usize,
148    /// Surface elements removed with them.
149    pub surface_elements: usize,
150    /// Particle attachments removed with them.
151    pub attachments: usize,
152    /// Whether the soft body itself was removed (its last cluster is gone).
153    pub soft_body_removed: bool,
154}
155
156impl SoftBody {
157    /// The clusters of this soft body (index 0 is the whole-body cluster created at insertion).
158    /// Removed cluster slots stay as dead entries (`SoftBodyCluster::is_live`); indices are
159    /// stable.
160    pub fn clusters(&self) -> &[SoftBodyCluster] {
161        &self.clusters
162    }
163
164    /// The `i`-th cluster, if it exists and is live.
165    pub fn cluster(&self, i: u32) -> Option<&SoftBodyCluster> {
166        self.clusters.get(i as usize).filter(|c| c.is_live())
167    }
168
169    /// The `i`-th cluster, if it exists and is live.
170    pub fn cluster_mut(&mut self, i: u32) -> Option<&mut SoftBodyCluster> {
171        let result = self.clusters.get_mut(i as usize).filter(|c| c.is_live());
172        self.modified |= result.is_some();
173        result
174    }
175
176    /// The proxy rigid body of the `i`-th cluster, if it is live.
177    pub fn cluster_proxy(&self, i: u32) -> Option<RigidBodyHandle> {
178        self.cluster(i).map(|c| c.proxy)
179    }
180
181    /// Iterates over the live clusters as `(index, cluster)`.
182    pub fn live_clusters(&self) -> impl Iterator<Item = (u32, &SoftBodyCluster)> {
183        self.clusters
184            .iter()
185            .enumerate()
186            .filter(|(_, c)| c.is_live())
187            .map(|(i, c)| (i as u32, c))
188    }
189
190    /// Number of live clusters.
191    pub fn num_live_clusters(&self) -> usize {
192        self.clusters.iter().filter(|c| c.is_live()).count()
193    }
194
195    /// Whether two of this body's clusters share no particle (a merge walk over their sorted
196    /// particle lists; a dead or missing cluster shares nothing).
197    pub(crate) fn clusters_are_disjoint(&self, a: u32, b: u32) -> bool {
198        let (Some(a), Some(b)) = (self.cluster(a), self.cluster(b)) else {
199            return true;
200        };
201        let (mut i, mut j) = (0, 0);
202        let (a, b) = (a.particles(), b.particles());
203        while i < a.len() && j < b.len() {
204            match a[i].cmp(&b[j]) {
205                core::cmp::Ordering::Less => i += 1,
206                core::cmp::Ordering::Greater => j += 1,
207                core::cmp::Ordering::Equal => return false,
208            }
209        }
210        true
211    }
212
213    /// Enables or disables shape matching of the `i`-th cluster's particles toward the cluster's
214    /// frame (independent from the whole-body
215    /// [`crate::dynamics::SoftBodyBuilder::shape_matching`]).
216    pub fn enable_cluster_shape_matching(&mut self, i: u32, enabled: bool) {
217        if let Some(c) = self.clusters.get_mut(i as usize) {
218            if c.is_live() && c.shape_matching != enabled {
219                c.shape_matching = enabled;
220                self.modified = true;
221            }
222        }
223    }
224
225    /// Scales the material stiffness (Young modulus) of every cell fully contained in the
226    /// `i`-th cluster: regional materials without a separate body. Cells straddling the
227    /// cluster's boundary are left as they are.
228    pub fn set_cluster_stiffness_scale(&mut self, i: u32, scale: Real) {
229        let Some(cluster) = self.clusters.get(i as usize).filter(|c| c.is_live()) else {
230            return;
231        };
232        let contained: Vec<bool> = self
233            .cells
234            .iter()
235            .map(|c| {
236                c.vertices
237                    .iter()
238                    .all(|v| cluster.particles.binary_search(v).is_ok())
239            })
240            .collect();
241        for (c, inside) in self.cells.iter_mut().zip(contained) {
242            if inside {
243                c.stiffness_scale = scale.max(0.0);
244            }
245        }
246        self.modified = true;
247    }
248
249    /// Overrides the softness of every structural or bending edge fully contained in the `i`-th
250    /// cluster (`None` restores the body material's): regional stiffness for cloth and ropes.
251    pub fn set_cluster_edge_softness(
252        &mut self,
253        i: u32,
254        softness: Option<crate::dynamics::SpringCoefficients<Real>>,
255    ) {
256        let Some(cluster) = self.clusters.get(i as usize).filter(|c| c.is_live()) else {
257            return;
258        };
259        let contained: Vec<bool> = self
260            .edges
261            .iter()
262            .map(|e| {
263                e.vertices
264                    .iter()
265                    .all(|v| cluster.particles.binary_search(v).is_ok())
266            })
267            .collect();
268        for (e, inside) in self.edges.iter_mut().zip(contained) {
269            if inside {
270                e.softness = softness;
271            }
272        }
273        self.modified = true;
274    }
275
276    /// Sets the tear-threshold multiplier of every edge and cell fully contained in the `i`-th
277    /// cluster (see [`super::SoftBodyEdge::tear_resistance`]): a tough region, or a perforation
278    /// line; `1.0` restores the material's thresholds.
279    pub fn set_cluster_tear_resistance(&mut self, i: u32, resistance: Real) {
280        let Some(cluster) = self.clusters.get(i as usize).filter(|c| c.is_live()) else {
281            return;
282        };
283        let resistance = resistance.max(0.0);
284        let inside = |vertices: &[u32]| {
285            vertices
286                .iter()
287                .all(|v| cluster.particles.binary_search(v).is_ok())
288        };
289        let edges: Vec<bool> = self.edges.iter().map(|e| inside(&e.vertices)).collect();
290        let cells: Vec<bool> = self.cells.iter().map(|c| inside(&c.vertices)).collect();
291        for (e, inside) in self.edges.iter_mut().zip(edges) {
292            if inside {
293                e.tear_resistance = resistance;
294            }
295        }
296        for (c, inside) in self.cells.iter_mut().zip(cells) {
297            if inside {
298                c.tear_resistance = resistance;
299            }
300        }
301        self.modified = true;
302    }
303
304    /// Pins (or releases) every particle of the `i`-th cluster at once: the cluster becomes a
305    /// fixed (kinematic) region dragging the rest of the body, driven along a path with
306    /// [`Self::set_cluster_kinematic_target`]; particles keep their individual pinned semantics.
307    pub fn set_cluster_pinned(&mut self, i: u32, pinned: bool) {
308        let Some(cluster) = self.clusters.get(i as usize).filter(|c| c.is_live()) else {
309            return;
310        };
311        for &v in cluster.particles.clone().iter() {
312            self.set_particle_pinned(v as usize, pinned);
313        }
314    }
315
316    /// Moves the pinned particles of the `i`-th cluster rigidly toward `pose` over the next step:
317    /// each pinned particle's target is the cluster's rest shape placed at `pose`, with matching
318    /// velocities; free particles are unaffected (pin them with [`Self::set_cluster_pinned`]).
319    pub fn set_cluster_kinematic_target(&mut self, i: u32, pose: Pose) {
320        let Some(cluster) = self.clusters.get(i as usize).filter(|c| c.is_live()) else {
321            return;
322        };
323        // Rest centroid of the cluster (nominal masses): the target pose places the rest shape.
324        let mut nominal = 0.0;
325        let mut rest_com = Vector::ZERO;
326        for &v in &cluster.particles {
327            let p = &self.particles[v as usize];
328            nominal += p.mass;
329            rest_com += p.rest_position * p.mass;
330        }
331        if nominal > 0.0 {
332            rest_com /= nominal;
333        }
334        for &v in cluster.particles.clone().iter() {
335            let offset = self.particles[v as usize].rest_position - rest_com;
336            self.set_particle_kinematic_target(v as usize, pose * offset);
337        }
338    }
339
340    /// Recomputes the per-particle cluster reference counts from the cluster lists.
341    pub(crate) fn rebuild_cluster_refs(&mut self) {
342        self.cluster_refs.clear();
343        self.cluster_refs.resize(self.particles.len(), 0);
344        for c in &self.clusters {
345            if c.is_live() {
346                for &v in &c.particles {
347                    if let Some(r) = self.cluster_refs.get_mut(v as usize) {
348                        *r += 1;
349                    }
350                }
351            }
352        }
353    }
354
355    /// The cell whose vertices are exactly `particles` (sorted), if any: such a cluster's frame
356    /// rotation is the cell's own corotational rotation.
357    pub(crate) fn matching_cell(&self, particles: &[u32]) -> u32 {
358        if particles.len() != DIM + 1 {
359            return u32::MAX;
360        }
361        for (i, cell) in self.cells.iter().enumerate() {
362            let mut vs = cell.vertices;
363            vs.sort_unstable();
364            if vs == particles[..DIM + 1] {
365                return i as u32;
366            }
367        }
368        u32::MAX
369    }
370
371    /// Registers freshly duplicated particles (a tear split) into the clusters of their source
372    /// particle, and extends the reference counts. `duplicated` is `(copy, source)` pairs.
373    pub(crate) fn inherit_cluster_membership(&mut self, duplicated: &[(u32, u32)]) {
374        self.cluster_refs.resize(self.particles.len(), 0);
375        for &(copy, source) in duplicated {
376            for c in &mut self.clusters {
377                if c.is_live() && c.particles.binary_search(&source).is_ok() {
378                    if let Err(pos) = c.particles.binary_search(&copy) {
379                        c.particles.insert(pos, copy);
380                        if !c.shape_impulses.is_empty() {
381                            // Keep the warm shape impulses aligned with the particle list.
382                            c.shape_impulses
383                                .insert(pos.min(c.shape_impulses.len()), Vector::ZERO);
384                        }
385                        if let Some(r) = self.cluster_refs.get_mut(copy as usize) {
386                            *r += 1;
387                        }
388                    }
389                }
390            }
391        }
392        // Duplication changes the cell list; the element frames must follow their cells.
393        self.update_cluster_cells();
394    }
395
396    /// Re-derives every cluster's `cell` match (cells were removed, added or renumbered).
397    pub(crate) fn update_cluster_cells(&mut self) {
398        let mut matches = Vec::new();
399        for c in &self.clusters {
400            matches.push(if c.is_live() {
401                self.matching_cell(&c.particles)
402            } else {
403                u32::MAX
404            });
405        }
406        for (c, cell) in self.clusters.iter_mut().zip(matches) {
407            c.cell = cell;
408        }
409    }
410
411    /// Removes every particle whose `dead` flag is set (they must belong to no live cluster), the
412    /// elements touching them and the attachments on them, compacting the remaining indices.
413    /// Returns the removal counts and the old-to-new remap (`u32::MAX` if removed; empty if none).
414    pub(crate) fn remove_dead_particles(
415        &mut self,
416        dead: &[bool],
417    ) -> (SoftClusterRemoval, Vec<u32>) {
418        let mut counts = SoftClusterRemoval::default();
419        if !dead.contains(&true) {
420            return (counts, Vec::new());
421        }
422        debug_assert_eq!(dead.len(), self.particles.len());
423
424        // Old -> new particle index (u32::MAX for the removed ones).
425        let mut remap = vec![u32::MAX; self.particles.len()];
426        let mut next = 0u32;
427        for (i, &d) in dead.iter().enumerate() {
428            if !d {
429                remap[i] = next;
430                next += 1;
431            }
432        }
433        counts.particles = self.particles.len() - next as usize;
434
435        let alive = |vs: &[u32]| vs.iter().all(|&v| !dead[v as usize]);
436
437        // Elements: drop the ones touching a dead particle, remap the rest.
438        let before = self.edges.len();
439        self.edges.retain(|e| alive(&e.vertices));
440        counts.edges = before - self.edges.len();
441        for e in &mut self.edges {
442            for v in &mut e.vertices {
443                *v = remap[*v as usize];
444            }
445        }
446
447        // Cells need their own remap for the skin bindings.
448        let mut cell_remap = vec![u32::MAX; self.cells.len()];
449        let mut next_cell = 0u32;
450        for (i, c) in self.cells.iter().enumerate() {
451            if alive(&c.vertices) {
452                cell_remap[i] = next_cell;
453                next_cell += 1;
454            }
455        }
456        let before = self.cells.len();
457        self.cells.retain(|c| alive(&c.vertices));
458        counts.cells = before - self.cells.len();
459        for c in &mut self.cells {
460            for v in &mut c.vertices {
461                *v = remap[*v as usize];
462            }
463        }
464
465        #[cfg(feature = "dim3")]
466        {
467            let before = self.dihedrals.len();
468            self.dihedrals.retain(|d| alive(&d.vertices));
469            counts.dihedrals = before - self.dihedrals.len();
470            for d in &mut self.dihedrals {
471                for v in &mut d.vertices {
472                    *v = remap[*v as usize];
473                }
474            }
475        }
476
477        let before = self.boundary.len();
478        self.boundary.retain(|s| alive(s));
479        counts.surface_elements = before - self.boundary.len();
480        for s in &mut self.boundary {
481            for v in s.iter_mut() {
482                *v = remap[*v as usize];
483            }
484        }
485
486        // Attachments anchored to a dead particle die with it.
487        let before = self.attachments.len();
488        self.attachments.retain(|a| !dead[a.particle as usize]);
489        counts.attachments = before - self.attachments.len();
490        for a in &mut self.attachments {
491            a.particle = remap[a.particle as usize];
492        }
493        if counts.attachments > 0 {
494            self.attachments_modified = true;
495        }
496        self.attachments_modified |= counts.particles > 0;
497
498        // Live clusters: drop dead entries (there should be none) and remap; the warm shape
499        // impulses follow their particles.
500        for c in &mut self.clusters {
501            if c.is_live() {
502                if c.shape_impulses.len() == c.particles.len() {
503                    let mut keep = c.particles.iter().map(|&v| !dead[v as usize]);
504                    c.shape_impulses.retain(|_| keep.next().unwrap());
505                } else {
506                    c.shape_impulses.clear();
507                }
508                c.particles.retain(|&v| !dead[v as usize]);
509                for v in &mut c.particles {
510                    *v = remap[*v as usize];
511                }
512            }
513        }
514
515        // A split family keeps one root: the new index of its first surviving member.
516        let mut family_root = vec![u32::MAX; self.particles.len()];
517        for (i, p) in self.particles.iter_mut().enumerate() {
518            if dead[i] {
519                continue;
520            }
521            let old = p.split_root(i as u32) as usize;
522            if let Some(root) = family_root.get_mut(old) {
523                if *root == u32::MAX {
524                    *root = remap[i];
525                }
526                p.split_root = *root;
527            } else {
528                p.split_root = remap[i];
529            }
530        }
531
532        // The particles themselves, and the per-particle tables.
533        let mut keep = dead.iter().map(|d| !d);
534        self.particles.retain(|_| keep.next().unwrap());
535        self.rebuild_cluster_refs();
536
537        // The meshes follow the renumbered particles and cells; their contact caches hold stale
538        // ids.
539        let remap_tables = super::collision_mesh::SoftTopologyRemap {
540            cells: &cell_remap,
541            particles: &remap,
542            split: &[],
543            inserted: &[],
544        };
545        self.for_each_mesh_mut(|body, mesh| {
546            mesh.remap_topology(body, &remap_tables);
547            mesh.clear_contacts();
548        });
549
550        // The boundary tables (closedness, owning cells, volume pieces) and the meshes' own.
551        self.rebuild_boundary_tables();
552        self.rebuild_volume_pieces(&remap);
553        self.update_cluster_cells();
554        self.recolor();
555        self.modified = true;
556        self.topology_version = self.topology_version.wrapping_add(1);
557        (counts, remap)
558    }
559
560    /// Weighted centroid of the cluster's particles (current positions) and their total mass.
561    pub(crate) fn cluster_com_and_mass(&self, cluster: &SoftBodyCluster) -> (Vector, Real) {
562        let mut com = Vector::ZERO;
563        let mut mass = 0.0;
564        for &v in &cluster.particles {
565            let p = &self.particles[v as usize];
566            com += p.position * p.mass;
567            mass += p.mass;
568        }
569        if mass > 0.0 {
570            com /= mass;
571        }
572        (com, mass)
573    }
574
575    /// The current frame and reduced mass matrix of the `ci`-th cluster: origin at the free
576    /// particles' mass-weighted centroid (every particle's when all are pinned), rotation from the
577    /// matching cell (else the Kabsch fit onto the rest positions), mass from the free particles.
578    pub(crate) fn cluster_frame(&self, ci: usize) -> Option<ClusterFrame> {
579        let cluster = self.clusters.get(ci)?;
580        if !cluster.is_live() {
581            return None;
582        }
583
584        // Dynamics: the free particles' centroid, momentum and reduced mass matrix.
585        let mut mass = 0.0;
586        let mut com = Vector::ZERO;
587        for &v in &cluster.particles {
588            let p = &self.particles[v as usize];
589            if p.inv_mass > 0.0 {
590                mass += p.mass;
591                com += p.position * p.mass;
592            }
593        }
594        let inv_mass = crate::utils::inv(mass);
595        let origin = if mass > 0.0 {
596            com * inv_mass
597        } else {
598            // Fully pinned: the frame still needs an origin.
599            let (com_all, _) = self.cluster_com_and_mass(cluster);
600            com_all
601        };
602
603        let mut linvel = Vector::ZERO;
604        let mut angmom = AngVector::default();
605        #[cfg(feature = "dim2")]
606        let mut inertia: AngularInertia = 0.0;
607        #[cfg(feature = "dim3")]
608        let mut inertia = parry::utils::SdpMatrix3::zero();
609        for &v in &cluster.particles {
610            let p = &self.particles[v as usize];
611            if p.inv_mass > 0.0 {
612                let r = p.position - origin;
613                linvel += p.velocity * p.mass;
614                angmom += r.gcross(p.velocity * p.mass);
615                #[cfg(feature = "dim2")]
616                {
617                    inertia += p.mass * r.length_squared();
618                }
619                #[cfg(feature = "dim3")]
620                {
621                    let d = p.mass * r.length_squared();
622                    inertia = parry::utils::SdpMatrix3 {
623                        m11: inertia.m11 + d - p.mass * r.x * r.x,
624                        m12: inertia.m12 - p.mass * r.x * r.y,
625                        m13: inertia.m13 - p.mass * r.x * r.z,
626                        m22: inertia.m22 + d - p.mass * r.y * r.y,
627                        m23: inertia.m23 - p.mass * r.y * r.z,
628                        m33: inertia.m33 + d - p.mass * r.z * r.z,
629                    };
630                }
631            }
632        }
633        // Pseudo-inverse with an eigenvalue threshold: a rank-deficient cluster has a singular
634        // reduced inertia whose naive inverse explodes; those directions lose their angular
635        // response instead (a fully degenerate cluster gets its angular joint axes stripped).
636        let inv_inertia = pseudo_inverse_inertia(inertia, inertia_noise_floor(mass, origin));
637        let linvel = linvel * inv_mass;
638        let angvel = inv_inertia.transform_vector(angmom);
639
640        // Rotation: exact (the matching cell's corotational rotation), or the Kabsch fit of the
641        // particles onto their rest positions (see the plan's 2.6: for a one-cell cluster the
642        // two differ by O(strain x rest anisotropy), so the exact one is preferred).
643        let rotation = if let Some(cell) = self.cells.get(cluster.cell as usize) {
644            cell.rotation
645        } else {
646            let mut rest_com = Vector::ZERO;
647            let mut nominal_mass = 0.0;
648            for &v in &cluster.particles {
649                let p = &self.particles[v as usize];
650                rest_com += p.rest_position * p.mass;
651                nominal_mass += p.mass;
652            }
653            if nominal_mass > 0.0 {
654                rest_com /= nominal_mass;
655            }
656            let mut fit_com = Vector::ZERO;
657            for &v in &cluster.particles {
658                let p = &self.particles[v as usize];
659                fit_com += p.position * p.mass;
660            }
661            if nominal_mass > 0.0 {
662                fit_com /= nominal_mass;
663            }
664            let mut a = crate::math::Matrix::ZERO;
665            for &v in &cluster.particles {
666                let p = &self.particles[v as usize];
667                let x = (p.position - fit_com) * p.mass;
668                let q = p.rest_position - rest_com;
669                #[cfg(feature = "dim2")]
670                {
671                    a += crate::math::Matrix::from_cols(x * q.x, x * q.y);
672                }
673                #[cfg(feature = "dim3")]
674                {
675                    a += crate::math::Matrix::from_cols(x * q.x, x * q.y, x * q.z);
676                }
677            }
678            extract_rotation(a, cluster.rotation)
679        };
680
681        Some(ClusterFrame {
682            pose: Pose::from_parts(origin, rotation),
683            inv_mass,
684            inv_inertia,
685            linvel,
686            angvel,
687        })
688    }
689}
690
691impl SoftBody {
692    /// Solver-side gather of a cluster's rigid state from its particles' solver bodies: `(com,
693    /// linvel, angvel)` of the free particles, with the frozen per-step `inv_inertia` (world axes
694    /// about the fresh com). `pos`/`vel` map a particle index to its solver state.
695    pub(crate) fn gather_cluster_velocity(
696        &self,
697        ci: usize,
698        inv_inertia: &AngularInertia,
699        pos: impl Fn(u32) -> Vector,
700        vel: impl Fn(u32) -> Vector,
701    ) -> (Vector, Vector, AngVector) {
702        let cluster = &self.clusters[ci];
703        let mut mass = 0.0;
704        let mut com = Vector::ZERO;
705        for &v in &cluster.particles {
706            let p = &self.particles[v as usize];
707            if p.inv_mass > 0.0 {
708                mass += p.mass;
709                com += pos(v) * p.mass;
710            }
711        }
712        if mass > 0.0 {
713            com /= mass;
714        }
715        let inv_mass = crate::utils::inv(mass);
716        let mut linvel = Vector::ZERO;
717        let mut angmom = AngVector::default();
718        for &v in &cluster.particles {
719            let p = &self.particles[v as usize];
720            if p.inv_mass > 0.0 {
721                let r = pos(v) - com;
722                linvel += vel(v) * p.mass;
723                angmom += r.gcross(vel(v) * p.mass);
724            }
725        }
726        (com, linvel * inv_mass, inv_inertia.transform_vector(angmom))
727    }
728}
729
730/// The per-step frame and reduced mass matrix of a cluster (see `SoftBody::cluster_frame`).
731#[derive(Copy, Clone, Debug)]
732pub(crate) struct ClusterFrame {
733    /// Frame pose: origin at the free particles' weighted centroid, rotation from the cell or
734    /// the Kabsch fit.
735    pub pose: Pose,
736    /// Inverse of the free particles' total mass (`0`: immovable).
737    pub inv_mass: Real,
738    /// Inverse of the reduced angular inertia about the origin (world axes; zero when
739    /// rank-deficient).
740    pub inv_inertia: AngularInertia,
741    /// Mass-weighted mean velocity of the free particles.
742    pub linvel: Vector,
743    /// Angular velocity of the rigid fit: `I⁻¹ Σ rᵢ × mᵢvᵢ`.
744    pub angvel: AngVector,
745}
746
747/// Absolute noise floor of a cluster's reduced inertia: a zero-extent cluster (one free particle,
748/// a point-collapsed one) has a roundoff inertia whose inverse fits huge angular velocities, so
749/// an eigenvalue below the mass put at `sqrt(machine epsilon)` of the coordinate scale is noise.
750pub(crate) fn inertia_noise_floor(mass: Real, com: Vector) -> Real {
751    let noise_extent = Real::EPSILON.sqrt() * (1.0 + com.length());
752    mass * noise_extent * noise_extent
753}
754
755/// Pseudo-inverse of a reduced angular inertia: directions whose eigenvalue is negligible next
756/// to the largest one, or below the absolute floor `abs_floor` (see [`inertia_noise_floor`]),
757/// are zeroed instead of inverted (a singular inertia's naive inverse explodes).
758#[cfg(feature = "dim2")]
759pub(crate) fn pseudo_inverse_inertia(inertia: AngularInertia, abs_floor: Real) -> AngularInertia {
760    if inertia <= abs_floor {
761        0.0
762    } else {
763        crate::utils::inv(inertia)
764    }
765}
766
767/// See the 2D overload.
768#[cfg(feature = "dim3")]
769pub(crate) fn pseudo_inverse_inertia(inertia: AngularInertia, abs_floor: Real) -> AngularInertia {
770    const RELATIVE_EPS: Real = 1.0e-5;
771    let m = na::Matrix3::new(
772        inertia.m11,
773        inertia.m12,
774        inertia.m13,
775        inertia.m12,
776        inertia.m22,
777        inertia.m23,
778        inertia.m13,
779        inertia.m23,
780        inertia.m33,
781    );
782    let eig = m.symmetric_eigen();
783    let max = eig.eigenvalues.amax();
784    if max <= abs_floor {
785        return parry::utils::SdpMatrix3::zero();
786    }
787    let mut inv = na::Matrix3::zeros();
788    for k in 0..3 {
789        let lambda = eig.eigenvalues[k];
790        if lambda > (max * RELATIVE_EPS).max(abs_floor) {
791            let u = eig.eigenvectors.column(k);
792            inv += u * u.transpose() / lambda;
793        }
794    }
795    parry::utils::SdpMatrix3 {
796        m11: inv[(0, 0)],
797        m12: inv[(0, 1)],
798        m13: inv[(0, 2)],
799        m22: inv[(1, 1)],
800        m23: inv[(1, 2)],
801        m33: inv[(2, 2)],
802    }
803}