Skip to main content

alice_physics/
cloth.rs

1//! Cloth / Shell Simulation (XPBD Triangle Mesh)
2//!
3//! Position-based cloth using distance + bending constraints
4//! on a triangle mesh. Supports SDF collision.
5//!
6//! # Features
7//!
8//! - Distance constraints on mesh edges (stretch resistance)
9//! - Bending constraints on adjacent triangles
10//! - SDF collision for cloth-body interaction
11//! - Pin constraints for attaching cloth to bodies
12//! - Wind force and gravity
13//!
14//! Author: Moroya Sakamoto
15
16use crate::math::{Fix128, Vec3Fix};
17#[cfg(feature = "std")]
18use crate::sdf_collider::SdfCollider;
19
20#[cfg(not(feature = "std"))]
21use alloc::vec;
22#[cfg(not(feature = "std"))]
23use alloc::vec::Vec;
24
25// ============================================================================
26// Cloth Configuration
27// ============================================================================
28
29/// Cloth simulation configuration
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31pub struct ClothConfig {
32    /// Solver iterations per substep
33    pub iterations: usize,
34    /// Number of substeps per step
35    pub substeps: usize,
36    /// Gravity vector
37    pub gravity: Vec3Fix,
38    /// Velocity retention per frame (`step()` call), applied once per frame since 1.2.0
39    pub damping: Fix128,
40    /// Stretch constraint compliance (0 = rigid edges)
41    pub stretch_compliance: Fix128,
42    /// Bending constraint compliance (higher = more flexible)
43    pub bend_compliance: Fix128,
44    /// SDF collision friction
45    pub sdf_friction: Fix128,
46    /// Cloth thickness for collision
47    pub thickness: Fix128,
48    /// Enable self-collision
49    ///
50    /// Covers the proximity passes (vertex-vs-face and edge-vs-edge) and the frame-level
51    /// repair of tunnelling: vertex chords that pierced a triangle, and edge pairs that met
52    /// inside the frame and ended on the far side of where edge `a` started (a swept test,
53    /// `resolve_self_contact_over_frame`). ⚠️ The edge-edge repair triggers only on a
54    /// **completed crossing** (`gap < 0`); a pair that met and separated again on its own
55    /// side is left to the proximity pass. Two parallel (or, in `Fix128`, numerically
56    /// parallel) edges are left to the vertex-face pass — see `closest_points_on_segments`.
57    /// `remaining_self_contact_crossings` counts the same two kinds of event.
58    pub self_collision: bool,
59    /// Contact thickness: a vertex is kept at least this far from every non-incident triangle
60    pub self_collision_distance: Fix128,
61}
62
63impl Default for ClothConfig {
64    fn default() -> Self {
65        Self {
66            iterations: 8,
67            substeps: 4,
68            gravity: Vec3Fix::new(Fix128::ZERO, Fix128::from_int(-10), Fix128::ZERO),
69            damping: Fix128::from_ratio(99, 100),
70            stretch_compliance: Fix128::ZERO,
71            bend_compliance: Fix128::from_ratio(1, 100),
72            sdf_friction: Fix128::from_ratio(2, 10),
73            thickness: Fix128::from_ratio(1, 100),
74            self_collision: false,
75            self_collision_distance: Fix128::from_ratio(2, 100), // 0.02
76        }
77    }
78}
79
80// ============================================================================
81// Cloth Constraints
82// ============================================================================
83
84/// Edge (stretch) constraint
85#[derive(Clone, Copy, Debug, PartialEq)]
86struct EdgeConstraint {
87    i0: usize,
88    i1: usize,
89    rest_length: Fix128,
90}
91
92/// Bending constraint between two triangles sharing an edge
93#[derive(Clone, Copy, Debug, PartialEq)]
94struct BendConstraint {
95    /// The four vertices: shared edge (i0, i1) and opposite vertices (i2, i3)
96    i0: usize,
97    i1: usize,
98    i2: usize,
99    i3: usize,
100    /// Rest dihedral angle
101    rest_angle: Fix128,
102}
103
104// ============================================================================
105// Cloth
106// ============================================================================
107
108/// Cloth simulation
109#[repr(C, align(64))]
110pub struct Cloth {
111    /// Particle positions
112    pub positions: Vec<Vec3Fix>,
113    /// Previous positions
114    pub prev_positions: Vec<Vec3Fix>,
115    /// Velocities
116    pub velocities: Vec<Vec3Fix>,
117    /// Inverse mass per particle
118    pub inv_masses: Vec<Fix128>,
119    /// Triangle indices (i0, i1, i2)
120    pub triangles: Vec<[usize; 3]>,
121    /// Edge constraints
122    edge_constraints: Vec<EdgeConstraint>,
123    /// Bending constraints
124    bend_constraints: Vec<BendConstraint>,
125    /// Pinned particles
126    pub pinned: Vec<usize>,
127    /// Wind force
128    pub wind: Vec3Fix,
129    /// Configuration
130    pub config: ClothConfig,
131    /// Rest tether: per-particle spring-damper toward `rest_positions` (`None` = off)
132    rest_tether: Option<RestTether>,
133    /// Rest positions the tether pulls toward (empty until set)
134    rest_positions: Vec<Vec3Fix>,
135}
136
137/// Stiffness / damping of the rest tether (see [`Cloth::set_rest_tether`]).
138#[derive(Clone, Copy, Debug, PartialEq, Eq)]
139struct RestTether {
140    stiffness: Fix128,
141    damping: Fix128,
142}
143
144/// One exact step of the damped oscillator `ë = −ω² e − γ ė + a` (constant `a`) over `h`:
145///
146/// ```text
147/// e(h) = A e0 + B v0 + C a          A = B' + γ B,   C = ∫₀ʰ B
148/// v(h) = −ω² B e0 + B' v0 + B a     B = impulse response (B(0) = 0, B'(0) = 1)
149/// ```
150///
151/// With `p = γh/2` and `q = (ω² − γ²/4) h²`, `B = h e^(−p) S(q)`, `A = e^(−p) (K(q) + p S(q))`,
152/// `B' = e^(−p) (K(q) − p S(q))`, where `S(q) = sin √q / √q` and `K(q) = cos √q` (`sinh` /
153/// `cosh` of `√−q` for `q < 0`) are entire in `q`. Three branches:
154///
155/// - `|q| <= 1/4` (near critical): `S`, `K` by their series, no division by `√q`;
156/// - `q > 1/4` (underdamped): the series at `q / 4ᵐ <= 1/4`, then `m` doublings
157///   `S(4q) = S(q) K(q)`, `K(4q) = 2 K(q)² − 1`;
158/// - `q < −1/4` (overdamped): the two decay rates `λ₁ h = ω²h² / λ₂ h`, `λ₂ h = p + √−q`
159///   (no cancellation) and their exponentials.
160///
161/// `C = h² (1 − A) / (ω²h²)` when `ω²h² >= 1/16`; below that (where the quotient would cancel)
162/// by the Taylor series of `B` (near critical) or `h² (φ₁(λ₁h) − φ₁(λ₂h)) / (λ₂h − λ₁h)`,
163/// `φ₁(x) = (1 − e^(−x)) / x` (overdamped). Every function is evaluated in `Fix128` with
164/// fixed term counts, so the step is deterministic; `e^(−x)` is the crate's 2D-drive
165/// `exp_neg` (a few units of 2⁻⁶⁴, the `Fix128::exp` CORDIC is only ~1e-6).
166#[derive(Clone, Copy, Debug, PartialEq, Eq)]
167struct OscillatorStep {
168    /// `A`
169    pos_e: Fix128,
170    /// `B`
171    pos_v: Fix128,
172    /// `C`
173    pos_a: Fix128,
174    /// `−ω² B`
175    vel_e: Fix128,
176    /// `B'`
177    vel_v: Fix128,
178    /// `B`
179    vel_a: Fix128,
180}
181
182impl OscillatorStep {
183    /// Coefficients for `ω² = omega_sq >= 0`, `γ = gamma >= 0` and step `h > 0`.
184    fn new(omega_sq: Fix128, gamma: Fix128, h: Fix128) -> Self {
185        let quarter = Fix128::from_ratio(1, 4);
186        let p = (gamma * h).half();
187        let w2h2 = omega_sq * h * h;
188        let q = w2h2 - p * p;
189        let h2 = h * h;
190        let (b_over_h, a, bd, c_over_h2) = if q > quarter {
191            // underdamped
192            let (s, k) = sinc_cos(q);
193            let e = crate::physics2d::exp_neg(p);
194            let a = e * (k + p * s);
195            (e * s, a, e * (k - p * s), (Fix128::ONE - a) / w2h2)
196        } else if q < Fix128::ZERO - quarter {
197            // overdamped
198            let r = (Fix128::ZERO - q).sqrt();
199            let l2 = p + r;
200            let l1 = w2h2 / l2;
201            let d = r.double();
202            let e1 = crate::physics2d::exp_neg(l1);
203            let e2 = crate::physics2d::exp_neg(l2);
204            let a = (l2 * e1 - l1 * e2) / d;
205            let c = if w2h2 >= Fix128::from_ratio(1, 16) {
206                (Fix128::ONE - a) / w2h2
207            } else {
208                (phi1(l1) - phi1(l2)) / d
209            };
210            ((e1 - e2) / d, a, (l2 * e2 - l1 * e1) / d, c)
211        } else {
212            // near critical
213            let (s, k) = sinc_cos_series(q);
214            let e = crate::physics2d::exp_neg(p);
215            let a = e * (k + p * s);
216            let c = if w2h2 >= Fix128::from_ratio(1, 16) {
217                (Fix128::ONE - a) / w2h2
218            } else {
219                impulse_integral_series(p.double(), w2h2)
220            };
221            (e * s, a, e * (k - p * s), c)
222        };
223        let b = h * b_over_h;
224        Self {
225            pos_e: a,
226            pos_v: b,
227            pos_a: h2 * c_over_h2,
228            vel_e: Fix128::ZERO - omega_sq * b,
229            vel_v: bd,
230            vel_a: b,
231        }
232    }
233}
234
235/// `(S(q), K(q))` = `(sin √q / √q, cos √q)` for `|q| <= 1/4` by their series (the remainder
236/// after 14 terms is below `4⁻¹⁴ / 28!`).
237fn sinc_cos_series(q: Fix128) -> (Fix128, Fix128) {
238    let minus_q = Fix128::ZERO - q;
239    let (mut s, mut k) = (Fix128::ONE, Fix128::ONE);
240    let (mut ts, mut tk) = (Fix128::ONE, Fix128::ONE);
241    for n in 1..=14_i64 {
242        ts = ts * minus_q / Fix128::from_int((2 * n) * (2 * n + 1));
243        tk = tk * minus_q / Fix128::from_int((2 * n - 1) * (2 * n));
244        s = s + ts;
245        k = k + tk;
246    }
247    (s, k)
248}
249
250/// `(S(q), K(q))` for `q > 0`: the series at `q / 4ᵐ <= 1/4`, then `m` doublings.
251fn sinc_cos(q: Fix128) -> (Fix128, Fix128) {
252    let quarter = Fix128::from_ratio(1, 4);
253    let mut r = q;
254    let mut m = 0_u32;
255    while r > quarter {
256        r = r.shr_bits(2);
257        m += 1;
258    }
259    let (mut s, mut k) = sinc_cos_series(r);
260    for _ in 0..m {
261        s = s * k;
262        k = (k * k).double() - Fix128::ONE;
263    }
264    (s, k)
265}
266
267/// `φ₁(x) = (1 − e^(−x)) / x` for `x >= 0` (series below 1/2, where the quotient cancels).
268fn phi1(x: Fix128) -> Fix128 {
269    if x > Fix128::from_ratio(1, 2) {
270        return (Fix128::ONE - crate::physics2d::exp_neg(x)) / x;
271    }
272    // Σ (−x)ⁿ / (n+1)!
273    let mut sum = Fix128::ONE;
274    let mut term = Fix128::ONE;
275    for n in 1..=26_i64 {
276        term = Fix128::ZERO - term * x / Fix128::from_int(n + 1);
277        sum = sum + term;
278    }
279    sum
280}
281
282/// `C / h² = ∫₀¹ B̃` for the scaled impulse response `B̃'' + g B̃' + s B̃ = 0`, `B̃(0) = 0`,
283/// `B̃'(0) = 1` (`g = γh`, `s = ω²h²`), from its Taylor coefficients
284/// `b_{n+2} = −(g (n+1) b_{n+1} + s b_n) / ((n+2)(n+1))`. Used near critical damping with
285/// `s < 1/16` (so `g <= 1.12`), where 40 terms leave a remainder below 2⁻⁸⁰.
286fn impulse_integral_series(g: Fix128, s: Fix128) -> Fix128 {
287    let (mut b0, mut b1) = (Fix128::ZERO, Fix128::ONE);
288    // Σ b_n / (n+1): n = 0 contributes 0, n = 1 contributes 1/2
289    let mut sum = Fix128::ONE.half();
290    for n in 0..40_i64 {
291        let b2 = Fix128::ZERO
292            - (g * b1 * Fix128::from_int(n + 1) + s * b0) / Fix128::from_int((n + 2) * (n + 1));
293        sum = sum + b2 / Fix128::from_int(n + 3);
294        b0 = b1;
295        b1 = b2;
296    }
297    sum
298}
299
300/// Returned by [`Cloth::set_rest_positions`] when the slice does not have one position per
301/// particle.
302#[derive(Clone, Copy, Debug, PartialEq, Eq)]
303pub struct RestPositionsMismatch {
304    /// Number of particles in the cloth.
305    pub expected: usize,
306    /// Number of positions given.
307    pub got: usize,
308}
309
310impl Cloth {
311    /// Create a rectangular cloth grid
312    ///
313    /// `width` x `height` in world units, `res_x` x `res_y` particles
314    #[must_use]
315    pub fn new_grid(
316        origin: Vec3Fix,
317        width: Fix128,
318        height: Fix128,
319        res_x: usize,
320        res_y: usize,
321        mass_per_particle: Fix128,
322    ) -> Self {
323        let n = res_x * res_y;
324        let inv_mass = if mass_per_particle.is_zero() {
325            Fix128::ZERO
326        } else {
327            Fix128::ONE / mass_per_particle
328        };
329
330        // Precompute reciprocals for grid UV generation — avoids per-iteration division.
331        let recip_res_x = if res_x > 1 {
332            Fix128::ONE / Fix128::from_int((res_x - 1) as i64)
333        } else {
334            Fix128::ZERO
335        };
336        let recip_res_y = if res_y > 1 {
337            Fix128::ONE / Fix128::from_int((res_y - 1) as i64)
338        } else {
339            Fix128::ZERO
340        };
341
342        // Generate particles
343        let mut positions = Vec::with_capacity(n);
344        for j in 0..res_y {
345            for i in 0..res_x {
346                let u = Fix128::from_int(i as i64) * recip_res_x;
347                let v = Fix128::from_int(j as i64) * recip_res_y;
348                positions.push(Vec3Fix::new(
349                    origin.x + width * u,
350                    origin.y,
351                    origin.z + height * v,
352                ));
353            }
354        }
355
356        // Generate triangles
357        let mut triangles = Vec::new();
358        for j in 0..(res_y - 1) {
359            for i in 0..(res_x - 1) {
360                let i00 = j * res_x + i;
361                let i10 = i00 + 1;
362                let i01 = i00 + res_x;
363                let i11 = i01 + 1;
364
365                triangles.push([i00, i10, i01]);
366                triangles.push([i10, i11, i01]);
367            }
368        }
369
370        let mut cloth = Self {
371            prev_positions: positions.clone(),
372            velocities: vec![Vec3Fix::ZERO; n],
373            positions,
374            inv_masses: vec![inv_mass; n],
375            triangles,
376            edge_constraints: Vec::new(),
377            bend_constraints: Vec::new(),
378            pinned: Vec::new(),
379            wind: Vec3Fix::ZERO,
380            config: ClothConfig::default(),
381            rest_tether: None,
382            rest_positions: Vec::new(),
383        };
384
385        cloth.build_constraints();
386        cloth
387    }
388
389    /// Build edge and bending constraints from triangle mesh
390    fn build_constraints(&mut self) {
391        // Edge constraints: collect unique edges from triangles
392        let mut edge_set: Vec<(usize, usize)> = Vec::new();
393
394        for tri in &self.triangles {
395            let edges = [(tri[0], tri[1]), (tri[1], tri[2]), (tri[2], tri[0])];
396            for (a, b) in edges {
397                let (lo, hi) = if a < b { (a, b) } else { (b, a) };
398                if !edge_set.contains(&(lo, hi)) {
399                    edge_set.push((lo, hi));
400                    let rest = (self.positions[a] - self.positions[b]).length();
401                    self.edge_constraints.push(EdgeConstraint {
402                        i0: a,
403                        i1: b,
404                        rest_length: rest,
405                    });
406                }
407            }
408        }
409
410        // Bending constraints: find triangles sharing edges
411        for i in 0..self.triangles.len() {
412            for j in (i + 1)..self.triangles.len() {
413                if let Some(bend) =
414                    find_shared_edge(&self.triangles[i], &self.triangles[j], &self.positions)
415                {
416                    self.bend_constraints.push(bend);
417                }
418            }
419        }
420    }
421
422    /// Number of particles
423    #[inline(always)]
424    #[must_use]
425    pub fn particle_count(&self) -> usize {
426        self.positions.len()
427    }
428
429    /// Pin a particle in place
430    pub fn pin(&mut self, idx: usize) {
431        self.inv_masses[idx] = Fix128::ZERO;
432        if !self.pinned.contains(&idx) {
433            self.pinned.push(idx);
434        }
435    }
436
437    /// Pin the top row (for curtain-like behavior)
438    pub fn pin_top_row(&mut self, res_x: usize) {
439        for i in 0..res_x {
440            self.pin(i);
441        }
442    }
443
444    /// Pull every particle toward its rest position with the spring-damper
445    /// `F = stiffness·(rest − x) − damping·v` (a soft sheet springing back to its rest
446    /// shape).
447    ///
448    /// `stiffness` is in N/m and `damping` in N·s/m, per particle; with particle mass `m`
449    /// (`1 / inv_masses[i]`), angular frequency `ω = sqrt(k/m)` and damping ratio
450    /// `ζ = c / (2 sqrt(k m))`. `ζ < 1` springs back through the rest position and
451    /// oscillates, `ζ = 1` returns without overshoot. Each substep advances every free
452    /// particle's `(x − rest, v)` by the exact solution of the damped oscillator over the
453    /// substep (with gravity and wind as a constant acceleration), evaluated in `Fix128`
454    /// (see `OscillatorStep`), so a particle without constraints follows the continuous
455    /// law for any `substeps` and `iterations` — no numerical damping, no stability limit
456    /// on `ω h`. The edge, bending and self-contact constraints then act as before, and add
457    /// what they moved the particle by to its velocity. Pinned particles (`inv_mass = 0`)
458    /// are not affected.
459    ///
460    /// The rest positions are the current positions unless [`set_rest_positions`] was
461    /// called before. Negative values are treated as 0; `stiffness = damping = 0` is
462    /// equivalent to no tether. `config.damping` is applied on top (set it to 1 for the
463    /// pure law).
464    ///
465    /// [`set_rest_positions`]: Self::set_rest_positions
466    pub fn set_rest_tether(&mut self, stiffness: Fix128, damping: Fix128) {
467        if self.rest_positions.len() != self.positions.len() {
468            self.rest_positions = self.positions.clone();
469        }
470        let clamp = |x: Fix128| if x.is_negative() { Fix128::ZERO } else { x };
471        self.rest_tether = Some(RestTether {
472            stiffness: clamp(stiffness),
473            damping: clamp(damping),
474        });
475    }
476
477    /// Set the rest positions the tether pulls toward (one per particle). Does not enable
478    /// the tether by itself.
479    ///
480    /// # Errors
481    ///
482    /// [`RestPositionsMismatch`] if `rest.len()` is not the particle count; the rest
483    /// positions are then left unchanged.
484    pub fn set_rest_positions(&mut self, rest: &[Vec3Fix]) -> Result<(), RestPositionsMismatch> {
485        if rest.len() != self.positions.len() {
486            return Err(RestPositionsMismatch {
487                expected: self.positions.len(),
488                got: rest.len(),
489            });
490        }
491        self.rest_positions.clear();
492        self.rest_positions.extend_from_slice(rest);
493        Ok(())
494    }
495
496    /// Turn the rest tether off (the rest positions are kept).
497    pub fn clear_rest_tether(&mut self) {
498        self.rest_tether = None;
499    }
500
501    /// `(stiffness, damping)` of the rest tether, if enabled.
502    #[must_use]
503    pub fn rest_tether(&self) -> Option<(Fix128, Fix128)> {
504        self.rest_tether.map(|t| (t.stiffness, t.damping))
505    }
506
507    /// Rest positions the tether pulls toward (empty until set or enabled).
508    #[must_use]
509    pub fn rest_positions(&self) -> &[Vec3Fix] {
510        &self.rest_positions
511    }
512
513    /// Step cloth simulation
514    #[inline(always)]
515    pub fn step(&mut self, dt: Fix128) {
516        let frame_start = self.snapshot_for_self_contact();
517        let substep_dt = dt / Fix128::from_int(self.config.substeps as i64);
518        for _ in 0..self.config.substeps {
519            self.substep(substep_dt);
520        }
521        if let Some(start) = frame_start {
522            self.resolve_self_contact_over_frame(&start, dt);
523        }
524        self.apply_frame_damping();
525    }
526
527    /// Step with SDF collision
528    #[cfg(feature = "std")]
529    #[inline(always)]
530    pub fn step_with_sdf(&mut self, dt: Fix128, sdf_colliders: &[SdfCollider]) {
531        let frame_start = self.snapshot_for_self_contact();
532        let substep_dt = dt / Fix128::from_int(self.config.substeps as i64);
533        for _ in 0..self.config.substeps {
534            self.substep(substep_dt);
535            self.resolve_sdf_collisions(sdf_colliders);
536        }
537        if let Some(start) = frame_start {
538            self.resolve_self_contact_over_frame(&start, dt);
539        }
540        self.apply_frame_damping();
541    }
542
543    /// Frame-start positions, kept only when self-contact is enabled.
544    ///
545    /// `None` (rather than an unconditional clone) so that `self_collision = false`
546    /// allocates nothing and stays bit-identical to the pre-1.4.x behaviour.
547    fn snapshot_for_self_contact(&self) -> Option<Vec<Vec3Fix>> {
548        if self.config.self_collision && !self.config.self_collision_distance.is_zero() {
549            Some(self.positions.clone())
550        } else {
551            None
552        }
553    }
554
555    /// `config.damping` once per frame (velocity retention per `step()` call).
556    ///
557    /// 1.2.0: applied per substep before, which made the terminal velocity
558    /// depend on `substeps` (`g·h·d/(1−d)`, `h = dt/substeps`) — the same
559    /// defect as the rigid-body solver's frame damping fix.
560    fn apply_frame_damping(&mut self) {
561        let d = self.config.damping;
562        for v in &mut self.velocities {
563            *v = *v * d;
564        }
565    }
566
567    /// Single substep
568    #[inline(always)]
569    fn substep(&mut self, dt: Fix128) {
570        let n = self.particle_count();
571
572        // 1. Predict positions (with the rest tether: the exact damped-oscillator step,
573        //    which also returns the predicted positions for the velocity update)
574        let tether = self
575            .rest_tether
576            .filter(|t| !(t.stiffness.is_zero() && t.damping.is_zero()));
577        let predicted = match tether {
578            Some(t) => self.predict_with_rest_tether(t, dt),
579            None => {
580                for i in 0..n {
581                    if self.inv_masses[i].is_zero() {
582                        continue;
583                    }
584                    self.prev_positions[i] = self.positions[i];
585
586                    // Gravity + wind
587                    let wind_force = self.compute_wind_force(i);
588                    self.velocities[i] = self.velocities[i]
589                        + (self.config.gravity + wind_force * self.inv_masses[i]) * dt;
590                    self.positions[i] = self.positions[i] + self.velocities[i] * dt;
591                }
592                Vec::new()
593            }
594        };
595
596        // 2. Solve constraints
597        //
598        // Self-contact candidates are collected once here rather than once per
599        // iteration: the AABB margin (2·thickness) covers how far the iterations can
600        // move a particle, and the collection is the O(V·T) part.
601        let margin = self.config.self_collision_distance.double();
602        let (candidates, edge_candidates) = if self.config.self_collision {
603            (
604                self.collect_vertex_face_candidates(margin),
605                self.collect_edge_edge_candidates(margin),
606            )
607        } else {
608            (Vec::new(), Vec::new())
609        };
610        for _ in 0..self.config.iterations {
611            self.solve_edge_constraints(dt);
612            self.solve_bend_constraints(dt);
613            if self.config.self_collision {
614                self.solve_self_collision(&candidates, &edge_candidates);
615            }
616        }
617
618        // 3. Update velocities
619        let inv_dt = Fix128::ONE / dt;
620        if predicted.is_empty() {
621            for i in 0..n {
622                if self.inv_masses[i].is_zero() {
623                    continue;
624                }
625                self.velocities[i] = (self.positions[i] - self.prev_positions[i]) * inv_dt;
626            }
627        } else {
628            // The exact step left the oscillator's velocity in `velocities`; the
629            // constraints add what they moved the particle by (for the plain symplectic
630            // Euler prediction this is the same as `(x − x_prev) / h`).
631            for (i, x_pred) in predicted.iter().enumerate() {
632                if self.inv_masses[i].is_zero() {
633                    continue;
634                }
635                self.velocities[i] = self.velocities[i] + (self.positions[i] - *x_pred) * inv_dt;
636            }
637        }
638    }
639
640    /// Predict step with the rest tether: every free particle's error `e = x − rest` and
641    /// velocity `v` advance by the exact solution of `ë = −ω² e − γ ė + a` over `h`
642    /// (`ω² = k w`, `γ = c w`, `a` = gravity + wind · w, constant over the substep).
643    /// Returns the predicted positions; `velocities` holds the oscillator's velocity.
644    fn predict_with_rest_tether(&mut self, tether: RestTether, h: Fix128) -> Vec<Vec3Fix> {
645        let n = self.particle_count();
646        let mut predicted = Vec::with_capacity(n);
647        // The coefficients depend only on the inverse mass; a cloth usually has one.
648        let mut cache: Option<(Fix128, OscillatorStep)> = None;
649        for i in 0..n {
650            let w = self.inv_masses[i];
651            let x = self.positions[i];
652            if w.is_zero() {
653                predicted.push(x);
654                continue;
655            }
656            let step = match cache {
657                Some((cw, s)) if cw == w => s,
658                _ => {
659                    let s = OscillatorStep::new(tether.stiffness * w, tether.damping * w, h);
660                    cache = Some((w, s));
661                    s
662                }
663            };
664            self.prev_positions[i] = x;
665            let accel = self.config.gravity + self.compute_wind_force(i) * w;
666            let rest = self.rest_positions[i];
667            let e0 = x - rest;
668            let v0 = self.velocities[i];
669            let x1 = rest + e0 * step.pos_e + v0 * step.pos_v + accel * step.pos_a;
670            let v1 = e0 * step.vel_e + v0 * step.vel_v + accel * step.vel_a;
671            self.positions[i] = x1;
672            self.velocities[i] = v1;
673            predicted.push(x1);
674        }
675        predicted
676    }
677
678    /// Compute approximate wind force on a particle
679    #[inline(always)]
680    const fn compute_wind_force(&self, _particle_idx: usize) -> Vec3Fix {
681        // LIMITATION(COV-SOFT-071): Simplified: uniform wind force
682        // Simplified: uniform wind force
683        self.wind
684    }
685
686    /// Solve edge (stretch) constraints
687    #[inline(always)]
688    fn solve_edge_constraints(&mut self, dt: Fix128) {
689        let compliance = self.config.stretch_compliance / (dt * dt);
690
691        for c_idx in 0..self.edge_constraints.len() {
692            let c = self.edge_constraints[c_idx];
693            let p0 = self.positions[c.i0];
694            let p1 = self.positions[c.i1];
695            let w0 = self.inv_masses[c.i0];
696            let w1 = self.inv_masses[c.i1];
697
698            let w_sum = w0 + w1 + compliance;
699            if w_sum.is_zero() {
700                continue;
701            }
702
703            let delta = p1 - p0;
704            let dist = delta.length();
705            if dist.is_zero() {
706                continue;
707            }
708
709            let error = dist - c.rest_length;
710            let lambda = error / w_sum;
711            let inv_dist = Fix128::ONE / dist;
712            let correction = delta * inv_dist * lambda;
713
714            if !w0.is_zero() {
715                self.positions[c.i0] = self.positions[c.i0] + correction * w0;
716            }
717            if !w1.is_zero() {
718                self.positions[c.i1] = self.positions[c.i1] - correction * w1;
719            }
720        }
721    }
722
723    /// Solve bending constraints (dihedral angle).
724    ///
725    /// Standard position-based dihedral constraint (Müller et al. 2007,
726    /// "Position Based Dynamics", Appendix A): with the shared edge `p0 p1`
727    /// and the opposite vertices `p2 p3`,
728    ///
729    /// ```text
730    /// n1 = normalize((p1-p0) × (p2-p0)),  n2 = normalize((p1-p0) × (p3-p0)),
731    /// d  = n1·n2,  C = atan2(|n1×n2|, d) - φ0
732    /// ```
733    ///
734    /// and the four gradients `q_i` of Appendix A. The correction is
735    /// `Δp_i = -w_i · sin(angle) · C / (Σ w_j |q_j|² + α/dt²) · q_i`, so a
736    /// flat rest configuration (`d = -1`, `sin = 0`) produces **no** correction
737    /// and cannot pump energy.
738    ///
739    /// Before 1.1.1 this used the simplified `cos(angle) - cos(φ0)` push along
740    /// the face normals. With the correct `atan2` (fixed in 1.1.1) the flat rest
741    /// angle is exactly `π`, so that error term became `1 + cos(angle) ≥ 0` —
742    /// sign-blind — and the cloth accumulated energy until it flew upward.
743    #[inline(always)]
744    #[allow(clippy::many_single_char_names, clippy::similar_names)]
745    fn solve_bend_constraints(&mut self, dt: Fix128) {
746        let compliance = self.config.bend_compliance / (dt * dt);
747
748        for c_idx in 0..self.bend_constraints.len() {
749            let c = self.bend_constraints[c_idx];
750            let p0 = self.positions[c.i0];
751            // Translate so that p0 is the origin (Appendix A convention p1 = 0)
752            let p1 = self.positions[c.i1] - p0;
753            let p2 = self.positions[c.i2] - p0;
754            let p3 = self.positions[c.i3] - p0;
755
756            let c12 = p1.cross(p2);
757            let c13 = p1.cross(p3);
758            let len12 = c12.length();
759            let len13 = c13.length();
760            if len12.is_zero() || len13.is_zero() {
761                continue;
762            }
763            let inv12 = Fix128::ONE / len12;
764            let inv13 = Fix128::ONE / len13;
765            let n1 = c12 * inv12;
766            let n2 = c13 * inv13;
767
768            let d = n1.dot(n2);
769            let sin_angle = n1.cross(n2).length();
770            let angle = Fix128::atan2(sin_angle, d);
771            let error = angle - c.rest_angle;
772
773            // Gradients (Appendix A, with p1 := our p1 (edge end), p3 := p2, p4 := p3)
774            let q2 = (p1.cross(n2) + (n1.cross(p1)) * d) * inv12;
775            let q3 = (p1.cross(n1) + (n2.cross(p1)) * d) * inv13;
776            let q1 = (p2.cross(n2) + (n1.cross(p2)) * d) * (-inv12)
777                + (p3.cross(n1) + (n2.cross(p3)) * d) * (-inv13);
778            let q0 = -(q1 + q2 + q3);
779
780            let w0 = self.inv_masses[c.i0];
781            let w1 = self.inv_masses[c.i1];
782            let w2 = self.inv_masses[c.i2];
783            let w3 = self.inv_masses[c.i3];
784            let denom = w0 * q0.length_squared()
785                + w1 * q1.length_squared()
786                + w2 * q2.length_squared()
787                + w3 * q3.length_squared()
788                + compliance;
789            if denom.is_zero() {
790                continue;
791            }
792
793            // s = sin(angle) · C / denom (sqrt(1 - d²) == |n1 × n2|)
794            let scale = sin_angle * error / denom;
795            if scale.is_zero() {
796                continue;
797            }
798
799            if !w0.is_zero() {
800                self.positions[c.i0] = self.positions[c.i0] - q0 * (w0 * scale);
801            }
802            if !w1.is_zero() {
803                self.positions[c.i1] = self.positions[c.i1] - q1 * (w1 * scale);
804            }
805            if !w2.is_zero() {
806                self.positions[c.i2] = self.positions[c.i2] - q2 * (w2 * scale);
807            }
808            if !w3.is_zero() {
809                self.positions[c.i3] = self.positions[c.i3] - q3 * (w3 * scale);
810            }
811        }
812    }
813
814    /// Candidate (vertex, triangle) pairs for vertex-face self-contact.
815    ///
816    /// Built **once per substep** (not once per solver iteration) from the predicted
817    /// positions: the iterations only move particles by the constraint residual, so an
818    /// AABB inflated by `margin` covers the whole iteration sweep. A pair is kept when
819    /// the vertex lies inside the triangle's AABB grown by `margin` on every axis.
820    ///
821    /// Excluded: vertices incident to the triangle (they are handled by the stretch and
822    /// bending constraints), and pairs in which all four participants are pinned
823    /// (`inv_mass == 0`), which can produce no correction.
824    ///
825    /// ⚠️ **Pinned vertices are kept as collision participants.** They cannot move, but a
826    /// kinematically driven vertex sweeping into a free triangle must still push that
827    /// triangle away; dropping them was the reason the old particle-particle pass never
828    /// saw the driven boundary of a crumpling sheet.
829    fn collect_vertex_face_candidates(&self, margin: Fix128) -> Vec<(u32, u32)> {
830        let n = self.particle_count();
831        let mut out: Vec<(u32, u32)> = Vec::new();
832        for (ti, tri) in self.triangles.iter().enumerate() {
833            let (a, b, c) = (
834                self.positions[tri[0]],
835                self.positions[tri[1]],
836                self.positions[tri[2]],
837            );
838            let tri_pinned = self.inv_masses[tri[0]].is_zero()
839                && self.inv_masses[tri[1]].is_zero()
840                && self.inv_masses[tri[2]].is_zero();
841            let lo = Vec3Fix::new(
842                min3(a.x, b.x, c.x) - margin,
843                min3(a.y, b.y, c.y) - margin,
844                min3(a.z, b.z, c.z) - margin,
845            );
846            let hi = Vec3Fix::new(
847                max3(a.x, b.x, c.x) + margin,
848                max3(a.y, b.y, c.y) + margin,
849                max3(a.z, b.z, c.z) + margin,
850            );
851            for i in 0..n {
852                if i == tri[0] || i == tri[1] || i == tri[2] {
853                    continue;
854                }
855                if tri_pinned && self.inv_masses[i].is_zero() {
856                    continue; // nothing in this pair can move
857                }
858                let p = self.positions[i];
859                if p.x < lo.x || p.x > hi.x || p.y < lo.y || p.y > hi.y || p.z < lo.z || p.z > hi.z
860                {
861                    continue;
862                }
863                out.push((i as u32, ti as u32));
864            }
865        }
866        out
867    }
868
869    /// Unique undirected mesh edges, as sorted `[lo, hi]` vertex pairs.
870    ///
871    /// Rebuilt from `triangles` rather than read off `edge_constraints`, because the two
872    /// are not the same set in practice: a caller (or a test) may clear the stretch
873    /// constraints to look at collision in isolation, and collision topology must not
874    /// disappear with them. The result is sorted, so the pair enumeration below is a
875    /// function of the mesh alone and not of triangle insertion order.
876    fn mesh_edges(&self) -> Vec<[u32; 2]> {
877        let mut edges: Vec<[u32; 2]> = Vec::with_capacity(self.triangles.len() * 3);
878        for tri in &self.triangles {
879            for (a, b) in [(tri[0], tri[1]), (tri[1], tri[2]), (tri[2], tri[0])] {
880                let (lo, hi) = if a < b { (a, b) } else { (b, a) };
881                edges.push([lo as u32, hi as u32]);
882            }
883        }
884        edges.sort_unstable();
885        edges.dedup();
886        edges
887    }
888
889    /// Edge pairs whose `margin`-expanded AABBs overlap, as `[i0, i1, j0, j1]`.
890    ///
891    /// Two exclusions, both load-bearing:
892    ///
893    /// - ⚠️ **Edges that share a vertex are dropped.** Their closest distance is zero by
894    ///   construction, so every one of them would report a contact at every iteration and
895    ///   the pass would do nothing but fight the stretch constraints
896    ///   (`edge_edge_candidates_never_pair_two_edges_that_share_a_vertex` pins this; the
897    ///   mutation that removes it moves the vertices of a scene whose closed form says
898    ///   they must not move).
899    /// - Pairs in which all four participants are pinned, which can produce no correction.
900    ///
901    /// Collinear and parallel pairs are *not* excluded here — they are rejected inside
902    /// `closest_points_on_segments`, which is where "no isolated closest pair" is decided.
903    /// A flat grid's row-mates are exactly that case, which is why this pass does not fire
904    /// on an undeformed sheet.
905    fn collect_edge_edge_candidates(&self, margin: Fix128) -> Vec<[u32; 4]> {
906        let edges = self.mesh_edges();
907        let mut out: Vec<[u32; 4]> = Vec::new();
908        for (ei, e1) in edges.iter().enumerate() {
909            let (a0, a1) = (e1[0] as usize, e1[1] as usize);
910            let (pa0, pa1) = (self.positions[a0], self.positions[a1]);
911            let lo1 = Vec3Fix::new(
912                min2(pa0.x, pa1.x) - margin,
913                min2(pa0.y, pa1.y) - margin,
914                min2(pa0.z, pa1.z) - margin,
915            );
916            let hi1 = Vec3Fix::new(
917                max2(pa0.x, pa1.x) + margin,
918                max2(pa0.y, pa1.y) + margin,
919                max2(pa0.z, pa1.z) + margin,
920            );
921            let both_pinned_1 = self.inv_masses[a0].is_zero() && self.inv_masses[a1].is_zero();
922            for e2 in &edges[ei + 1..] {
923                let (b0, b1) = (e2[0] as usize, e2[1] as usize);
924                if a0 == b0 || a0 == b1 || a1 == b0 || a1 == b1 {
925                    continue; // shares a vertex: distance is 0 by construction
926                }
927                if both_pinned_1 && self.inv_masses[b0].is_zero() && self.inv_masses[b1].is_zero() {
928                    continue; // nothing in this pair can move
929                }
930                let (pb0, pb1) = (self.positions[b0], self.positions[b1]);
931                if min2(pb0.x, pb1.x) > hi1.x
932                    || max2(pb0.x, pb1.x) < lo1.x
933                    || min2(pb0.y, pb1.y) > hi1.y
934                    || max2(pb0.y, pb1.y) < lo1.y
935                    || min2(pb0.z, pb1.z) > hi1.z
936                    || max2(pb0.z, pb1.z) < lo1.z
937                {
938                    continue;
939                }
940                out.push([a0 as u32, a1 as u32, b0 as u32, b1 as u32]);
941            }
942        }
943        out
944    }
945
946    /// Edge pairs whose **swept** AABBs overlap over the frame `start → positions`.
947    ///
948    /// The box of an edge covers both of its endpoints at both ends of the frame, so a pair
949    /// that passes through each other somewhere inside the frame is always in the result —
950    /// which is the property the frame repair needs, and the reason the proximity broad
951    /// phase (`collect_edge_edge_candidates`, current positions only) cannot be reused here:
952    /// a pair that tunnels is typically far apart at both of the instants that one looks at.
953    ///
954    /// The same two exclusions apply as in the proximity broad phase, for the same reasons:
955    /// edges sharing a vertex are at distance zero by construction, and a pair in which all
956    /// four participants are pinned can produce no correction.
957    ///
958    /// ⚠️ Built **once per frame** rather than once per repair pass. The passes only move
959    /// vertices by their own corrections, and the box already spans the whole frame, so
960    /// rebuilding it between passes narrows a set that is deliberately a superset.
961    fn collect_swept_edge_edge_candidates(
962        &self,
963        start: &[Vec3Fix],
964        margin: Fix128,
965    ) -> Vec<[u32; 4]> {
966        let edges = self.mesh_edges();
967        let n = self.particle_count().min(start.len());
968        let swept_box = |i: usize, j: usize| {
969            let pts = [start[i], start[j], self.positions[i], self.positions[j]];
970            let lo = Vec3Fix::new(
971                min2(min2(pts[0].x, pts[1].x), min2(pts[2].x, pts[3].x)) - margin,
972                min2(min2(pts[0].y, pts[1].y), min2(pts[2].y, pts[3].y)) - margin,
973                min2(min2(pts[0].z, pts[1].z), min2(pts[2].z, pts[3].z)) - margin,
974            );
975            let hi = Vec3Fix::new(
976                max2(max2(pts[0].x, pts[1].x), max2(pts[2].x, pts[3].x)) + margin,
977                max2(max2(pts[0].y, pts[1].y), max2(pts[2].y, pts[3].y)) + margin,
978                max2(max2(pts[0].z, pts[1].z), max2(pts[2].z, pts[3].z)) + margin,
979            );
980            (lo, hi)
981        };
982
983        let mut out: Vec<[u32; 4]> = Vec::new();
984        for (ei, e1) in edges.iter().enumerate() {
985            let (a0, a1) = (e1[0] as usize, e1[1] as usize);
986            if a0 >= n || a1 >= n {
987                continue; // no start position recorded for this edge
988            }
989            let (lo1, hi1) = swept_box(a0, a1);
990            let both_pinned_1 = self.inv_masses[a0].is_zero() && self.inv_masses[a1].is_zero();
991            for e2 in &edges[ei + 1..] {
992                let (b0, b1) = (e2[0] as usize, e2[1] as usize);
993                if b0 >= n || b1 >= n {
994                    continue;
995                }
996                if a0 == b0 || a0 == b1 || a1 == b0 || a1 == b1 {
997                    continue; // shares a vertex: distance is 0 by construction
998                }
999                if both_pinned_1 && self.inv_masses[b0].is_zero() && self.inv_masses[b1].is_zero() {
1000                    continue; // nothing in this pair can move
1001                }
1002                let (lo2, hi2) = swept_box(b0, b1);
1003                if lo2.x > hi1.x
1004                    || hi2.x < lo1.x
1005                    || lo2.y > hi1.y
1006                    || hi2.y < lo1.y
1007                    || lo2.z > hi1.z
1008                    || hi2.z < lo1.z
1009                {
1010                    continue;
1011                }
1012                out.push([a0 as u32, a1 as u32, b0 as u32, b1 as u32]);
1013            }
1014        }
1015        out
1016    }
1017
1018    /// Solve vertex-face (point-triangle) self-contact, Jacobi accumulation.
1019    ///
1020    /// For every candidate pair the constraint is `C = |p − q| − thickness ≥ 0`, where
1021    /// `q` is the closest point of the triangle to `p` and `thickness` is
1022    /// `config.self_collision_distance`. With the barycentric weights `w` of `q`
1023    /// (`q = w₀a + w₁b + w₂c`, `Σw = 1`, `w ≥ 0`) the gradients are `∇_p C = n`,
1024    /// `∇_a C = −w₀n`, … with `n = (p − q)/|p − q|`, so the projection is
1025    ///
1026    /// ```text
1027    /// s   = (thickness − |p − q|) / (w_p + w_a w₀² + w_b w₁² + w_c w₂²)
1028    /// Δp  = +s·w_p·n,   Δa = −s·w_a·w₀·n,   Δb = −s·w_b·w₁·n,   Δc = −s·w_c·w₂·n
1029    /// ```
1030    ///
1031    /// # Determinism
1032    ///
1033    /// Corrections are accumulated into a per-vertex `Δ` buffer with `+` and applied
1034    /// after the whole sweep, then averaged by the number of contacts that touched the
1035    /// vertex. `Fix128` addition is the exact group operation of `Z/2¹²⁸`, so the sum is
1036    /// bit-identical for **any** enumeration order of the pairs
1037    /// (`tests/reduction_order_independence.rs`); the pair order is not load-bearing and
1038    /// does not need to be sorted. Each contact normal is computed **once** and reused
1039    /// for all four gradients — `Fix128::Mul` is not associative, so a normal built by
1040    /// accumulation would depend on the order it was summed in.
1041    ///
1042    /// Before 1.4.x this was a Gauss-Seidel particle-particle pass (`positions[i]`
1043    /// written in place and read by later pairs). Point-point is subsumed: the distance
1044    /// from `p` to a triangle containing `q` is never larger than `|p − q|`, and in this
1045    /// mesh every pair of vertices that share a triangle is also joined by an edge, so
1046    /// any non-adjacent pair closer than `thickness` is reported by at least one
1047    /// vertex-face pair at the same threshold. `test_point_triangle_subsumes_point_point`
1048    /// pins that inequality.
1049    ///
1050    /// Edge-edge contact is accumulated by `accumulate_edge_edge_contacts` into the **same**
1051    /// `Δ`/`hits` buffer: a vertex can be within `thickness` of no face at all while two
1052    /// edges interpenetrate (the X crossing), so neither stage subsumes the other, and
1053    /// ⚠️ **they must not average separately** (see `solve_self_collision`).
1054    fn accumulate_vertex_face_contacts(
1055        &self,
1056        candidates: &[(u32, u32)],
1057        thickness: Fix128,
1058        delta: &mut [Vec3Fix],
1059        hits: &mut [u32],
1060    ) {
1061        let thickness_sq = thickness * thickness;
1062        for &(vi, ti) in candidates {
1063            let vi = vi as usize;
1064            let tri = self.triangles[ti as usize];
1065            let p = self.positions[vi];
1066            let (a, b, c) = (
1067                self.positions[tri[0]],
1068                self.positions[tri[1]],
1069                self.positions[tri[2]],
1070            );
1071            let Some((q, w)) = closest_point_on_triangle(p, a, b, c) else {
1072                continue; // degenerate (zero-area) triangle
1073            };
1074            let diff = p - q;
1075            let dist_sq = diff.length_squared();
1076            if dist_sq >= thickness_sq {
1077                continue;
1078            }
1079            // One independent computation of the normal, reused for all four gradients.
1080            let (normal, dist) = if dist_sq.is_zero() {
1081                // `p` sits exactly on the triangle: no separation direction survives, so
1082                // fall back to the face normal. `closest_point_on_triangle` already
1083                // rejected a zero-area triangle, but the normalisation can still
1084                // underflow, and a pair with no normal is a pair with no correction.
1085                match (b - a).cross(c - a).try_normalize() {
1086                    Some(nrm) => (nrm, Fix128::ZERO),
1087                    None => continue,
1088                }
1089            } else {
1090                let d = dist_sq.sqrt();
1091                (diff * (Fix128::ONE / d), d)
1092            };
1093
1094            let (wp, wa, wb, wc) = (
1095                self.inv_masses[vi],
1096                self.inv_masses[tri[0]],
1097                self.inv_masses[tri[1]],
1098                self.inv_masses[tri[2]],
1099            );
1100            let denom = wp + wa * w[0] * w[0] + wb * w[1] * w[1] + wc * w[2] * w[2];
1101            if denom.is_zero() {
1102                continue;
1103            }
1104            let s = (thickness - dist) / denom;
1105
1106            delta[vi] = delta[vi] + normal * (s * wp);
1107            hits[vi] += 1;
1108            for (k, &idx) in tri.iter().enumerate() {
1109                let wk = self.inv_masses[idx];
1110                delta[idx] = delta[idx] - normal * (s * wk * w[k]);
1111                hits[idx] += 1;
1112            }
1113        }
1114    }
1115
1116    /// Accumulate edge-edge (segment-segment) self-contact, Jacobi accumulation.
1117    ///
1118    /// This is the half of cloth self-contact that vertex-face cannot see. Two sheets can
1119    /// cross so that each sheet's vertices stay far from every triangle of the other while
1120    /// an edge of one passes within `thickness` of an edge of the other — the X crossing.
1121    /// The vertex-face pass reports nothing there, because the closest feature pair is
1122    /// (edge, edge) and neither edge's endpoints are the witnesses.
1123    ///
1124    /// For every candidate pair the constraint is `C = |pₐ − p_b| − thickness ≥ 0` with
1125    /// `pₐ = p₁ + s(q₁ − p₁)`, `p_b = p₂ + t(q₂ − p₂)` at the closest parameters `(s, t)`.
1126    /// Holding `(s, t)` fixed (the usual treatment: they are stationary, so their
1127    /// derivatives do not enter `∇C` to first order) the gradients are
1128    ///
1129    /// ```text
1130    /// ∇_{p₁}C = (1−s)n   ∇_{q₁}C = s·n   ∇_{p₂}C = −(1−t)n   ∇_{q₂}C = −t·n
1131    /// n       = (pₐ − p_b)/|pₐ − p_b|
1132    /// λ       = (thickness − |pₐ − p_b|) / (w₁(1−s)² + w_{q₁}s² + w₂(1−t)² + w_{q₂}t²)
1133    /// Δp₁     = +λ·w₁·(1−s)·n   …   Δq₂ = −λ·w_{q₂}·t·n
1134    /// ```
1135    ///
1136    /// # Determinism
1137    ///
1138    /// Same construction as the vertex-face stage, into the **same** buffer: each pair's
1139    /// normal is computed **once** and reused for all four gradients, corrections are summed
1140    /// into a per-vertex `Δ` with `+` (the exact group operation of `Z/2¹²⁸`) and applied
1141    /// after the sweep, averaged by the number of contacts that touched the vertex. The pair
1142    /// order is therefore not load-bearing
1143    /// (`edge_edge_result_is_independent_of_the_pair_order`,
1144    /// `self_collision_is_independent_of_the_pair_order_across_both_stages`). No
1145    /// Gauss-Seidel in-place write, so no dependence on the traversal order of the edge list.
1146    ///
1147    /// # What it does not cover
1148    ///
1149    /// ⚠️ **Proximity only — there is no swept edge-edge test.** A crossing that both
1150    /// begins and ends inside one frame, with the edges more than `thickness` apart at the
1151    /// end of it, is not detected; the frame-level repair
1152    /// (`resolve_self_contact_over_frame`) tests vertex chords against triangles, not edge
1153    /// pairs against each other. ⚠️ **Parallel pairs are skipped** by
1154    /// `closest_points_on_segments`.
1155    fn accumulate_edge_edge_contacts(
1156        &self,
1157        candidates: &[[u32; 4]],
1158        thickness: Fix128,
1159        delta: &mut [Vec3Fix],
1160        hits: &mut [u32],
1161    ) {
1162        let thickness_sq = thickness * thickness;
1163        for &[i0, i1, j0, j1] in candidates {
1164            let (i0, i1, j0, j1) = (i0 as usize, i1 as usize, j0 as usize, j1 as usize);
1165            let (p1, q1) = (self.positions[i0], self.positions[i1]);
1166            let (p2, q2) = (self.positions[j0], self.positions[j1]);
1167            let Some((s, t)) = closest_points_on_segments(p1, q1, p2, q2) else {
1168                continue; // degenerate or parallel: no isolated closest pair
1169            };
1170            let (d1, d2) = (q1 - p1, q2 - p2);
1171            let pa = p1 + d1 * s;
1172            let pb = p2 + d2 * t;
1173            let diff = pa - pb;
1174            let dist_sq = diff.length_squared();
1175            if dist_sq >= thickness_sq {
1176                continue;
1177            }
1178            // One independent computation of the normal, reused for all four gradients.
1179            let (normal, dist) = if dist_sq.is_zero() {
1180                // The segments meet. The direction that separates them without rotating
1181                // either one is their common perpendicular; it exists because
1182                // `closest_points_on_segments` already rejected the parallel case, but the
1183                // normalisation can still underflow, and a pair with no normal is a pair
1184                // with no correction.
1185                match d1.cross(d2).try_normalize() {
1186                    Some(nrm) => (nrm, Fix128::ZERO),
1187                    None => continue,
1188                }
1189            } else {
1190                let d = dist_sq.sqrt();
1191                (diff * (Fix128::ONE / d), d)
1192            };
1193
1194            let (w0, w1, w2, w3) = (
1195                self.inv_masses[i0],
1196                self.inv_masses[i1],
1197                self.inv_masses[j0],
1198                self.inv_masses[j1],
1199            );
1200            let (u, v) = (Fix128::ONE - s, Fix128::ONE - t);
1201            let denom = w0 * u * u + w1 * s * s + w2 * v * v + w3 * t * t;
1202            if denom.is_zero() {
1203                continue;
1204            }
1205            let lambda = (thickness - dist) / denom;
1206
1207            delta[i0] = delta[i0] + normal * (lambda * w0 * u);
1208            delta[i1] = delta[i1] + normal * (lambda * w1 * s);
1209            delta[j0] = delta[j0] - normal * (lambda * w2 * v);
1210            delta[j1] = delta[j1] - normal * (lambda * w3 * t);
1211            hits[i0] += 1;
1212            hits[i1] += 1;
1213            hits[j0] += 1;
1214            hits[j1] += 1;
1215        }
1216    }
1217
1218    /// One self-contact iteration: both stages into **one** `Δ`/`hits` buffer, applied once.
1219    ///
1220    /// ⚠️ **The shared buffer is load-bearing, not a tidiness choice.** With a buffer per
1221    /// stage, a vertex touched by 5 vertex-face contacts and 1 edge-edge contact receives
1222    /// `(Σ_vf)/5 + (Σ_ee)/1`, so the single edge-edge contact is weighted five times what
1223    /// each vertex-face contact gets. The averaging denominator is supposed to be "the
1224    /// number of contacts that touched **the vertex**", which is 6, not "the number that
1225    /// touched it within its own stage".
1226    ///
1227    /// Measured on the 9x9 crumple scene (`tests/analytic_self_contact.rs`, radius 0.05,
1228    /// 60 steps) when edge-edge was first wired as a second, separately averaged pass:
1229    ///
1230    /// | | vertex-face only | separate buffers | shared buffer |
1231    /// |---|---|---|---|
1232    /// | min vertex-face distance², self-contact ON | 8.64e-4 | **1.79e-5** | see the test |
1233    /// | same, OFF | 6.08e-5 | 6.08e-5 | 6.08e-5 |
1234    ///
1235    /// ⚠️ With separate buffers, **turning self-contact on made the vertex-face separation
1236    /// worse than turning it off** — and 61 of 61 firing edge-edge contacts were genuine
1237    /// long-range pairs (0 in-surface neighbours), so it was the weighting, not spurious
1238    /// contacts. `self_collision_improves_the_vertex_face_separation_it_constrains` is the
1239    /// test that catches it.
1240    ///
1241    /// ⚠️ **Two guards, and they cover different things.** The single-stage entry points are
1242    /// `#[cfg(test)]`, so a release build has exactly one way in and cannot average per
1243    /// stage by accident. The *wiring* in `substep` is guarded only by the crumple
1244    /// integration test: the mutation that replaces the one call with two
1245    /// (`SPLIT-average-per-stage`) leaves `cargo test --lib cloth::` **green** — including
1246    /// `the_averaging_denominator_counts_contacts_from_both_stages`, which pins this
1247    /// function's contract and not its caller — and turns
1248    /// `self_collision_improves_the_vertex_face_separation_it_constrains` red. Do not delete
1249    /// that integration test as redundant.
1250    fn solve_self_collision(&mut self, vertex_face: &[(u32, u32)], edge_edge: &[[u32; 4]]) {
1251        let thickness = self.config.self_collision_distance;
1252        if thickness.is_zero() || (vertex_face.is_empty() && edge_edge.is_empty()) {
1253            return;
1254        }
1255        let n = self.particle_count();
1256        let mut delta = vec![Vec3Fix::ZERO; n];
1257        let mut hits = vec![0u32; n];
1258
1259        self.accumulate_vertex_face_contacts(vertex_face, thickness, &mut delta, &mut hits);
1260        self.accumulate_edge_edge_contacts(edge_edge, thickness, &mut delta, &mut hits);
1261
1262        for i in 0..n {
1263            if hits[i] == 0 || self.inv_masses[i].is_zero() {
1264                continue;
1265            }
1266            self.positions[i] = self.positions[i] + delta[i] / Fix128::from_int(i64::from(hits[i]));
1267        }
1268    }
1269
1270    /// Vertex-face stage on its own — the closed-form tests drive one stage at a time
1271    #[cfg(test)]
1272    fn solve_vertex_face_self_collision(&mut self, candidates: &[(u32, u32)]) {
1273        self.solve_self_collision(candidates, &[]);
1274    }
1275
1276    /// Edge-edge stage on its own — the closed-form tests drive one stage at a time
1277    #[cfg(test)]
1278    fn solve_edge_edge_self_collision(&mut self, candidates: &[[u32; 4]]) {
1279        self.solve_self_collision(&[], candidates);
1280    }
1281
1282    /// Number of repair passes `resolve_self_contact_over_frame` is allowed.
1283    ///
1284    /// Each pass shortens the offending chords (a pierced vertex is pulled back toward
1285    /// where it started), so the passes are contractive and in the crumple scene the
1286    /// loop reaches its fixed point well inside this budget. The cap exists so a
1287    /// pathological configuration degrades into "some tunnelling survives this frame"
1288    /// instead of hanging; `remaining_self_contact_crossings` makes that observable.
1289    const SELF_CONTACT_PASSES: usize = 16;
1290
1291    /// Push back every **edge pair** that met inside the frame, Jacobi accumulation.
1292    ///
1293    /// The edge-edge half of the frame repair, and the counterpart of the vertex-face half
1294    /// in the body of `resolve_self_contact_over_frame`. Neither subsumes the other: two
1295    /// edges can pass through each other while every vertex stays far from every face (the
1296    /// X crossing), and a vertex can tunnel through the middle of a triangle without any
1297    /// edge pair meeting.
1298    ///
1299    /// For each pair `edge_pair_frame_contact` supplies the instant they met, the side edge
1300    /// `a` started the frame on, and the end-of-frame closest-point parameters. The
1301    /// constraint is then the same one the proximity pass projects, with one difference
1302    /// that is the whole point of this stage:
1303    ///
1304    /// ```text
1305    /// C = n·(pₐ − p_b) − thickness ≥ 0        (n fixed from the START of the frame)
1306    /// ∇_{a₀}C = (1−s)n   ∇_{a₁}C = s·n   ∇_{b₀}C = −(1−t)n   ∇_{b₁}C = −t·n
1307    /// ```
1308    ///
1309    /// ⚠️ **`n·(pₐ − p_b)` is signed, where the proximity pass reads `|pₐ − p_b|`.** An
1310    /// unsigned distance is the same whichever side the edges are on, so a pair that has
1311    /// already passed through looks to the proximity pass like a pair that is merely
1312    /// separating, and it pushes them further apart in the wrong direction. Reading the
1313    /// gap against the starting side turns the same projection into one that brings them
1314    /// back.
1315    ///
1316    /// # Determinism
1317    ///
1318    /// Corrections go into the caller's `Δ`/`hits` buffer with `+`, and `Fix128` addition
1319    /// is the exact group operation of `Z/2¹²⁸`, so the result does not depend on the order
1320    /// the pairs are enumerated in. The normal is computed once per pair and reused for all
1321    /// four gradients, because `Fix128::Mul` is not associative.
1322    ///
1323    /// # What triggers it
1324    ///
1325    /// Only a pair that **completed a crossing**: it met inside the frame and ended it with
1326    /// `gap < 0`, on the far side of where edge `a` started. This is the same trigger
1327    /// `remaining_self_contact_crossings` counts, so the repair acts on exactly the events
1328    /// the invariant reports, and the projection then restores the full `thickness` just as
1329    /// the vertex-face half does after a pierce.
1330    ///
1331    /// ⚠️ **Proximity is deliberately left to the substep pass.** Triggering on
1332    /// `gap < thickness` here would make this stage a second, frame-scale copy of
1333    /// `accumulate_edge_edge_contacts` running for all `SELF_CONTACT_PASSES` — see the
1334    /// measured regression recorded at the early-out below.
1335    ///
1336    /// Returns the number of pairs that produced a correction, which the caller uses to
1337    /// decide whether another pass is worth running. A pair that met but ended clear on its
1338    /// starting side is not progress and is not counted.
1339    fn accumulate_swept_edge_edge_repairs(
1340        &self,
1341        candidates: &[[u32; 4]],
1342        start: &[Vec3Fix],
1343        thickness: Fix128,
1344        delta: &mut [Vec3Fix],
1345        hits: &mut [u32],
1346    ) -> usize {
1347        let mut corrected = 0usize;
1348        for &[i0, i1, j0, j1] in candidates {
1349            let (i0, i1, j0, j1) = (i0 as usize, i1 as usize, j0 as usize, j1 as usize);
1350            let Some(contact) = edge_pair_frame_contact(
1351                (start[i0], self.positions[i0]),
1352                (start[i1], self.positions[i1]),
1353                (start[j0], self.positions[j0]),
1354                (start[j1], self.positions[j1]),
1355                thickness,
1356            ) else {
1357                continue;
1358            };
1359            if contact.gap >= Fix128::ZERO {
1360                // Met inside the frame but ended on the side it started on: a near miss,
1361                // not a crossing. ⚠️ **Acting here instead would duplicate the proximity
1362                // pass at frame scope and lose this stage's purpose.** Measured on the
1363                // folded 5x5 scene of `test_cloth_self_collision`
1364                // (`thickness = 3/2` against a grid spacing of `1`, so most in-layer edge
1365                // pairs are both coplanar and within `thickness`): with the threshold at
1366                // `thickness` the stage fired on the bulk of the mesh and re-applied the
1367                // proximity correction for all 16 passes, and the layer gap with
1368                // self-contact ON came out at 2.0035 against 2.7589 with it OFF — turning
1369                // self-contact on made the layers *closer*. The vertex-face half does not
1370                // have this failure mode because its trigger, `segment_pierces_triangle`,
1371                // is a crossing test and not a distance test; `gap < 0` is the edge-edge
1372                // statement of the same thing.
1373                continue;
1374            }
1375            let (s, t) = (contact.s, contact.t);
1376            let (u, v) = (Fix128::ONE - s, Fix128::ONE - t);
1377            let (w0, w1, w2, w3) = (
1378                self.inv_masses[i0],
1379                self.inv_masses[i1],
1380                self.inv_masses[j0],
1381                self.inv_masses[j1],
1382            );
1383            let denom = w0 * u * u + w1 * s * s + w2 * v * v + w3 * t * t;
1384            if denom.is_zero() {
1385                continue;
1386            }
1387            // ⚠️ Restore to `gap = 0`, **not** to `gap = thickness`. The invariant this
1388            // stage exists to maintain is "the cloth did not pass through itself", which is
1389            // `gap ≥ 0` and is exactly what `remaining_self_contact_crossings` counts;
1390            // establishing a `thickness` clearance on top of that is the substep proximity
1391            // pass's job. Overshooting to `thickness` makes the correction
1392            // `thickness − gap`, which for a deep tunnel is far larger than a mesh edge:
1393            // measured at `gap = −1.07` against `thickness = 3/2`, a single move of 2.57.
1394            // That is past the point where the constraint set is satisfiable, so particles
1395            // are thrown and *new* tunnelling appears — on the 9x9 crumple scene the
1396            // vertex-face count went 0 → 3 and the edge-edge violation count went from
1397            // 3 (self-contact OFF) to 8 (ON), i.e. the repair made both halves worse.
1398            let lambda = -contact.gap / denom;
1399            let normal = contact.normal;
1400
1401            delta[i0] = delta[i0] + normal * (lambda * w0 * u);
1402            delta[i1] = delta[i1] + normal * (lambda * w1 * s);
1403            delta[j0] = delta[j0] - normal * (lambda * w2 * v);
1404            delta[j1] = delta[j1] - normal * (lambda * w3 * t);
1405            hits[i0] += 1;
1406            hits[i1] += 1;
1407            hits[j0] += 1;
1408            hits[j1] += 1;
1409            corrected += 1;
1410        }
1411        corrected
1412    }
1413
1414    /// Push back every vertex whose **frame chord** pierced a non-incident triangle.
1415    ///
1416    /// This is the discrete-collision half of the pair (Bridson et al. 2002, *Robust
1417    /// Treatment of Collisions, Contact and Friction for Cloth Animation*): the
1418    /// proximity repulsion inside the substeps keeps surfaces apart while they are
1419    /// close, and this pass catches what a large time step let through anyway. It runs
1420    /// on the **frame**, not the substep, because the frame is the interval the caller
1421    /// integrates over — a vertex that ends the frame on the far side of a triangle has
1422    /// tunnelled regardless of which substep it happened in.
1423    ///
1424    /// For every pierce it takes the entry point `q` on the triangle, orients the face
1425    /// normal `n` toward the side the chord started on, and projects the constraint
1426    /// `C = n·(p − q) − thickness ≥ 0` exactly as the proximity pass does (same
1427    /// gradients, same Jacobi accumulation, same averaging). Both endpoints of the chord
1428    /// then lie strictly on the entry side of the triangle's plane, and a segment whose
1429    /// endpoints share a side cannot cross it.
1430    ///
1431    // LIMITATION(COV-SOFT-062): This repair is a **position** projection: it can stretch an edge past what `solve_edge_constraints` would allow, and it does not build rigid impact zones, so a pile-up of simultaneous contacts is resolved approximately rather than exactly.
1432    /// ⚠️ This repair is a **position** projection: it can stretch an edge past what
1433    /// `solve_edge_constraints` would allow, and it does not build rigid impact zones,
1434    /// so a pile-up of simultaneous contacts is resolved approximately rather than
1435    /// exactly. The invariant it does guarantee is the one
1436    /// `remaining_self_contact_crossings` measures.
1437    ///
1438    /// The correction is mirrored into the velocities (`Δ/dt`), otherwise the next frame
1439    /// re-integrates the velocity that caused the tunnelling and the cloth jitters
1440    /// against the repair.
1441    fn resolve_self_contact_over_frame(&mut self, start: &[Vec3Fix], dt: Fix128) {
1442        let thickness = self.config.self_collision_distance;
1443        let n = self.particle_count();
1444        let inv_dt = if dt.is_zero() {
1445            Fix128::ZERO
1446        } else {
1447            Fix128::ONE / dt
1448        };
1449        let mut delta = vec![Vec3Fix::ZERO; n];
1450        let mut hits = vec![0u32; n];
1451        let mut total = vec![Vec3Fix::ZERO; n];
1452        let edge_pairs = self.collect_swept_edge_edge_candidates(start, thickness);
1453
1454        for _ in 0..Self::SELF_CONTACT_PASSES {
1455            delta.fill(Vec3Fix::ZERO);
1456            hits.fill(0);
1457            // Corrections actually accumulated this pass — not pierces detected. A pierce
1458            // the projection cannot act on is not a reason to run another pass.
1459            let mut found = 0usize;
1460
1461            found += self.accumulate_swept_edge_edge_repairs(
1462                &edge_pairs,
1463                start,
1464                thickness,
1465                &mut delta,
1466                &mut hits,
1467            );
1468
1469            for tri in &self.triangles {
1470                let (a, b, c) = (
1471                    self.positions[tri[0]],
1472                    self.positions[tri[1]],
1473                    self.positions[tri[2]],
1474                );
1475                let lo = Vec3Fix::new(
1476                    min3(a.x, b.x, c.x) - thickness,
1477                    min3(a.y, b.y, c.y) - thickness,
1478                    min3(a.z, b.z, c.z) - thickness,
1479                );
1480                let hi = Vec3Fix::new(
1481                    max3(a.x, b.x, c.x) + thickness,
1482                    max3(a.y, b.y, c.y) + thickness,
1483                    max3(a.z, b.z, c.z) + thickness,
1484                );
1485                let Some(face) = (b - a).cross(c - a).try_normalize() else {
1486                    continue; // zero-area triangle has no side to be on
1487                };
1488
1489                for i in 0..n {
1490                    if i == tri[0] || i == tri[1] || i == tri[2] {
1491                        continue;
1492                    }
1493                    let p0 = start[i];
1494                    let p1 = self.positions[i];
1495                    // Chord AABB vs triangle AABB (cheap reject before the predicate)
1496                    if min2(p0.x, p1.x) > hi.x
1497                        || max2(p0.x, p1.x) < lo.x
1498                        || min2(p0.y, p1.y) > hi.y
1499                        || max2(p0.y, p1.y) < lo.y
1500                        || min2(p0.z, p1.z) > hi.z
1501                        || max2(p0.z, p1.z) < lo.z
1502                    {
1503                        continue;
1504                    }
1505                    let Some((t_scaled, det)) = segment_pierces_triangle(p0, p1, a, b, c) else {
1506                        continue;
1507                    };
1508
1509                    // Entry point, and the barycentric weights the gradients need
1510                    let t = t_scaled / det;
1511                    let entry = p0 + (p1 - p0) * t;
1512                    let Some((q, w)) = closest_point_on_triangle(entry, a, b, c) else {
1513                        continue;
1514                    };
1515                    // One independent computation, oriented toward the side the chord
1516                    // started on (accumulating a normal would make it order-dependent).
1517                    let normal = if face.dot(p0 - q) < Fix128::ZERO {
1518                        -face
1519                    } else {
1520                        face
1521                    };
1522
1523                    let (wp, wa, wb, wc) = (
1524                        self.inv_masses[i],
1525                        self.inv_masses[tri[0]],
1526                        self.inv_masses[tri[1]],
1527                        self.inv_masses[tri[2]],
1528                    );
1529                    let denom = wp + wa * w[0] * w[0] + wb * w[1] * w[1] + wc * w[2] * w[2];
1530                    if denom.is_zero() {
1531                        continue;
1532                    }
1533                    let c_val = normal.dot(p1 - q) - thickness;
1534                    if c_val >= Fix128::ZERO {
1535                        // The chord only grazed the plane at its start (`p0` exactly on
1536                        // it), so the end is already clear and there is nothing to undo.
1537                        // This does **not** count as progress: counting it would keep the
1538                        // loop spinning for all `SELF_CONTACT_PASSES` with no correction.
1539                        continue;
1540                    }
1541                    let s = c_val / denom;
1542                    found += 1;
1543
1544                    delta[i] = delta[i] - normal * (s * wp);
1545                    hits[i] += 1;
1546                    for (k, &idx) in tri.iter().enumerate() {
1547                        delta[idx] = delta[idx] + normal * (s * self.inv_masses[idx] * w[k]);
1548                        hits[idx] += 1;
1549                    }
1550                }
1551            }
1552
1553            if found == 0 {
1554                break;
1555            }
1556            for i in 0..n {
1557                if hits[i] == 0 || self.inv_masses[i].is_zero() {
1558                    continue;
1559                }
1560                let applied = delta[i] / Fix128::from_int(i64::from(hits[i]));
1561                self.positions[i] = self.positions[i] + applied;
1562                total[i] = total[i] + applied;
1563            }
1564        }
1565
1566        for (i, moved) in total.iter().enumerate() {
1567            if self.inv_masses[i].is_zero() {
1568                continue;
1569            }
1570            self.velocities[i] = self.velocities[i] + *moved * inv_dt;
1571        }
1572    }
1573
1574    /// Number of surviving self-intersection events over the frame `start → positions`.
1575    ///
1576    /// This is the invariant `step` maintains, exposed so that a caller (or a test) can
1577    /// check it instead of trusting that it holds. `0` is the only value that means
1578    /// "no part of the cloth is left on the far side of another part"; any positive value
1579    /// is the number of surviving tunnelling events, not a quality score.
1580    ///
1581    /// # ⚠️ The invariant is stated at the **end** of the frame
1582    ///
1583    /// Both halves are built the same way, and the distinction matters for what `0` is
1584    /// allowed to mean:
1585    ///
1586    /// - **detection** asks whether the two primitives met *anywhere inside* the frame —
1587    ///   a chord against a triangle, and for edges the roots of the coplanarity cubic
1588    ///   (`edge_pair_frame_contact`);
1589    /// - **counting** then asks whether that encounter *left them on the far side at the
1590    ///   end of the frame* — the chord's two endpoints straddling the triangle, and for
1591    ///   edges `gap < 0` against the side the edge started on.
1592    ///
1593    /// ⚠️ **A pair that passes through and comes back inside one frame is therefore not
1594    /// counted, by either half.** This is not a weakness introduced with the edge-edge
1595    /// half: the vertex-face chord test has always had it, because a vertex that leaves
1596    /// and returns ends the frame with a chord that no longer pierces anything. The older
1597    /// wording here — "the cloth did not pass through itself over this frame" — claimed
1598    /// the whole interval and was the inaccurate part.
1599    /// `a_pair_that_crosses_and_returns_inside_one_interval_is_not_counted` pins the case.
1600    ///
1601    /// The practical range of that gap is small, because the **repair** does not share the
1602    /// end-of-frame condition: `resolve_self_contact_over_frame` fires on the detection
1603    /// stage, so an excursion is still pushed back out while it is happening. It only
1604    /// escapes notice here if the repair failed to act on it at all.
1605    ///
1606    /// # The two kinds of event, and why both are needed
1607    ///
1608    /// A surface can pass through itself in two independent ways, and neither implies the
1609    /// other:
1610    ///
1611    /// - a **frame chord** `start[i] → positions[i]` that pierces a non-incident triangle
1612    ///   at its current position, counted once per (vertex, triangle) pair;
1613    /// - an **edge pair** that met inside the frame and ended it on opposite sides of where
1614    ///   it started, counted once per pair of non-adjacent mesh edges.
1615    ///
1616    /// ⚠️ Two sheets can cross in an X with every vertex far from every face, so a count
1617    /// that reads only the first kind returns `0` for a cloth that visibly passes through
1618    /// itself. Before 1.5.x this function counted only chords, and that is exactly the
1619    /// configuration it was blind to (`an_edge_pair_that_swaps_sides_in_one_frame_is_seen_by_the_crossings_invariant`
1620    /// pins the case; the measured residual on the 9x9 crumple scene was 22 edge-edge
1621    /// crossings while this function reported 0).
1622    ///
1623    /// The two counts are summed because both are event counts of the same invariant, but
1624    /// they are not interchangeable units: `3` can mean three pierced triangles, three
1625    /// crossed edge pairs, or any mix.
1626    ///
1627    /// # Shared with the repair
1628    ///
1629    /// Each kind is detected with **the same predicate the repair uses** —
1630    /// `segment_pierces_triangle` and `edge_pair_frame_contact` respectively, both also
1631    /// called from `resolve_self_contact_over_frame`. The two cannot drift apart into a
1632    /// repair that fixes one thing and a check that measures another.
1633    ///
1634    /// The edge-edge half is biased toward over-reporting (see `edge_pair_frame_contact`),
1635    /// so a non-zero value may include a pair that came within `thickness` at a bracketed
1636    /// instant without strictly passing through. It is never biased the other way: a
1637    /// completed crossing is always counted.
1638    #[must_use]
1639    pub fn remaining_self_contact_crossings(&self, start: &[Vec3Fix]) -> usize {
1640        let n = self.particle_count().min(start.len());
1641        let mut count = 0usize;
1642        for tri in &self.triangles {
1643            let (a, b, c) = (
1644                self.positions[tri[0]],
1645                self.positions[tri[1]],
1646                self.positions[tri[2]],
1647            );
1648            for (i, from) in start.iter().enumerate().take(n) {
1649                if i == tri[0] || i == tri[1] || i == tri[2] {
1650                    continue;
1651                }
1652                if segment_pierces_triangle(*from, self.positions[i], a, b, c).is_some() {
1653                    count += 1;
1654                }
1655            }
1656        }
1657
1658        let thickness = self.config.self_collision_distance;
1659        for [i0, i1, j0, j1] in self.collect_swept_edge_edge_candidates(start, thickness) {
1660            let (i0, i1, j0, j1) = (i0 as usize, i1 as usize, j0 as usize, j1 as usize);
1661            let Some(contact) = edge_pair_frame_contact(
1662                (start[i0], self.positions[i0]),
1663                (start[i1], self.positions[i1]),
1664                (start[j0], self.positions[j0]),
1665                (start[j1], self.positions[j1]),
1666                thickness,
1667            ) else {
1668                continue;
1669            };
1670            // Met **and** ended on the far side. A pair that met and separated again on the
1671            // side it started on is not interpenetrating at the end of the frame, which is
1672            // the property this function reports on; the repair still acts on it, because
1673            // its threshold is `thickness` rather than zero.
1674            if contact.gap < Fix128::ZERO {
1675                count += 1;
1676            }
1677        }
1678        count
1679    }
1680
1681    /// Resolve SDF collisions for all particles
1682    #[cfg(feature = "std")]
1683    #[inline(always)]
1684    fn resolve_sdf_collisions(&mut self, sdf_colliders: &[SdfCollider]) {
1685        let thickness = self.config.thickness.to_f32();
1686
1687        for i in 0..self.particle_count() {
1688            if self.inv_masses[i].is_zero() {
1689                continue;
1690            }
1691
1692            for sdf in sdf_colliders {
1693                let (lx, ly, lz) = sdf.world_to_local(self.positions[i]);
1694                let dist = sdf.field.distance(lx, ly, lz) * sdf.scale_f32;
1695
1696                if dist < thickness {
1697                    let (nx, ny, nz) = sdf.field.normal(lx, ly, lz);
1698                    let normal = sdf.local_normal_to_world(nx, ny, nz);
1699                    let push = Fix128::from_f32(thickness - dist);
1700
1701                    self.positions[i] = self.positions[i] + normal * push;
1702
1703                    // Friction
1704                    let vel = self.velocities[i];
1705                    let vn = normal * vel.dot(normal);
1706                    let vt = vel - vn;
1707                    self.velocities[i] = vt * (Fix128::ONE - self.config.sdf_friction);
1708                }
1709            }
1710        }
1711    }
1712
1713    /// Compute per-triangle normals (for rendering)
1714    #[must_use]
1715    pub fn compute_normals(&self) -> Vec<Vec3Fix> {
1716        let mut normals = vec![Vec3Fix::ZERO; self.particle_count()];
1717
1718        for tri in &self.triangles {
1719            let e1 = self.positions[tri[1]] - self.positions[tri[0]];
1720            let e2 = self.positions[tri[2]] - self.positions[tri[0]];
1721            let face_normal = e1.cross(e2);
1722
1723            normals[tri[0]] = normals[tri[0]] + face_normal;
1724            normals[tri[1]] = normals[tri[1]] + face_normal;
1725            normals[tri[2]] = normals[tri[2]] + face_normal;
1726        }
1727
1728        for n in &mut normals {
1729            *n = n.normalize();
1730        }
1731
1732        normals
1733    }
1734}
1735
1736impl core::fmt::Debug for Cloth {
1737    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1738        f.debug_struct("Cloth")
1739            .field(
1740                "positions",
1741                &format_args!("[{} items]", self.positions.len()),
1742            )
1743            .field(
1744                "prev_positions",
1745                &format_args!("[{} items]", self.prev_positions.len()),
1746            )
1747            .field(
1748                "velocities",
1749                &format_args!("[{} items]", self.velocities.len()),
1750            )
1751            .field(
1752                "inv_masses",
1753                &format_args!("[{} items]", self.inv_masses.len()),
1754            )
1755            .field(
1756                "triangles",
1757                &format_args!("[{} items]", self.triangles.len()),
1758            )
1759            .field(
1760                "edge_constraints",
1761                &format_args!("[{} items]", self.edge_constraints.len()),
1762            )
1763            .field(
1764                "bend_constraints",
1765                &format_args!("[{} items]", self.bend_constraints.len()),
1766            )
1767            .field("pinned", &format_args!("[{} items]", self.pinned.len()))
1768            .field("wind", &self.wind)
1769            .field("config", &self.config)
1770            .field("rest_tether", &self.rest_tether)
1771            .field(
1772                "rest_positions",
1773                &format_args!("[{} items]", self.rest_positions.len()),
1774            )
1775            .finish()
1776    }
1777}
1778
1779/// Smaller of two `Fix128`
1780fn min2(a: Fix128, b: Fix128) -> Fix128 {
1781    if a < b {
1782        a
1783    } else {
1784        b
1785    }
1786}
1787
1788/// Larger of two `Fix128`
1789fn max2(a: Fix128, b: Fix128) -> Fix128 {
1790    if a > b {
1791        a
1792    } else {
1793        b
1794    }
1795}
1796
1797/// Does the segment `p0 → p1` pass through the interior of triangle `(a, b, c)`?
1798///
1799/// Möller–Trumbore without the division: the determinant is normalised to a positive
1800/// sign and every acceptance test is then a comparison, so no rounding enters the
1801/// decision. Returns `(t_scaled, det)` with the crossing parameter `t = t_scaled / det`
1802/// for callers that need the entry point; `det` is strictly positive on return.
1803///
1804/// `None` covers three distinct rejections that a caller never needs to tell apart —
1805/// parallel to the plane, outside the barycentric triangle, outside the segment's
1806/// parameter range — because in all three the segment does not cross this triangle and
1807/// there is nothing to repair.
1808#[allow(clippy::many_single_char_names)]
1809fn segment_pierces_triangle(
1810    p0: Vec3Fix,
1811    p1: Vec3Fix,
1812    a: Vec3Fix,
1813    b: Vec3Fix,
1814    c: Vec3Fix,
1815) -> Option<(Fix128, Fix128)> {
1816    let dir = p1 - p0;
1817    let e1 = b - a;
1818    let e2 = c - a;
1819    let h = dir.cross(e2);
1820    let mut det = e1.dot(h);
1821    if det.is_zero() {
1822        return None; // segment parallel to the triangle's plane
1823    }
1824    let s = p0 - a;
1825    let q = s.cross(e1);
1826    let mut u = s.dot(h);
1827    let mut v = dir.dot(q);
1828    let mut t = e2.dot(q);
1829
1830    if det < Fix128::ZERO {
1831        det = Fix128::ZERO - det;
1832        u = Fix128::ZERO - u;
1833        v = Fix128::ZERO - v;
1834        t = Fix128::ZERO - t;
1835    }
1836    if u < Fix128::ZERO || v < Fix128::ZERO || u + v > det {
1837        return None; // outside the triangle
1838    }
1839    if t < Fix128::ZERO || t > det {
1840        return None; // outside the segment
1841    }
1842    Some((t, det))
1843}
1844
1845/// Smallest of three `Fix128`
1846fn min3(a: Fix128, b: Fix128, c: Fix128) -> Fix128 {
1847    let m = if a < b { a } else { b };
1848    if m < c {
1849        m
1850    } else {
1851        c
1852    }
1853}
1854
1855/// Largest of three `Fix128`
1856fn max3(a: Fix128, b: Fix128, c: Fix128) -> Fix128 {
1857    let m = if a > b { a } else { b };
1858    if m > c {
1859        m
1860    } else {
1861        c
1862    }
1863}
1864
1865/// Closest point of triangle `(a, b, c)` to `p`, with its barycentric weights.
1866///
1867/// Voronoi-region form (Ericson, *Real-Time Collision Detection* §5.1.5): the seven
1868/// regions (3 vertex, 3 edge, 1 face) are separated by sign tests on dot products, so
1869/// only the region that actually wins performs a division.
1870///
1871/// Returns `(q, [w₀, w₁, w₂])` with `q = w₀a + w₁b + w₂c`, `Σw = 1` and `w ≥ 0`. The
1872/// weights are what the contact gradients are built from, so they are returned rather
1873/// than recomputed: `q` alone cannot distinguish "on edge `ab`" from "inside the face
1874/// next to `ab`", and those two have different gradients.
1875///
1876/// `None` means one thing only: **this triangle cannot be projected onto in `Fix128`** —
1877/// it has zero area, or an edge so short that its squared length underflows Q64.64 (the
1878/// region denominators are `|ab|²`, `|ac|²`, `|bc|²` and `2·area`). The caller's action is
1879/// the same in every case: skip the pair. It is deliberately not "return vertex `a`",
1880/// which used to be four separate silent fallbacks returning a point that is not the
1881/// closest one.
1882///
1883/// ⚠️ The clamping into vertex and edge regions is load-bearing: without it an
1884/// unclamped face-region solve returns a point outside the triangle, and the contact is
1885/// reported against a plane instead of against the triangle
1886/// (`closest_point_clamps_into_the_edge_and_vertex_regions` pins each region).
1887#[allow(clippy::many_single_char_names, clippy::similar_names)]
1888fn closest_point_on_triangle(
1889    p: Vec3Fix,
1890    a: Vec3Fix,
1891    b: Vec3Fix,
1892    c: Vec3Fix,
1893) -> Option<(Vec3Fix, [Fix128; 3])> {
1894    const ZERO: Fix128 = Fix128::ZERO;
1895    const ONE: Fix128 = Fix128::ONE;
1896    let ab = b - a;
1897    let ac = c - a;
1898
1899    // A zero-area triangle has no face to project onto. Rejecting it here is what makes
1900    // every `den.is_zero()` below a genuine underflow guard rather than a silent answer.
1901    let area2 = ab.cross(ac);
1902    if area2.x.is_zero() && area2.y.is_zero() && area2.z.is_zero() {
1903        return None;
1904    }
1905
1906    // Vertex region A
1907    let ap = p - a;
1908    let d1 = ab.dot(ap);
1909    let d2 = ac.dot(ap);
1910    if d1 <= ZERO && d2 <= ZERO {
1911        return Some((a, [ONE, ZERO, ZERO]));
1912    }
1913
1914    // Vertex region B
1915    let bp = p - b;
1916    let d3 = ab.dot(bp);
1917    let d4 = ac.dot(bp);
1918    if d3 >= ZERO && d4 <= d3 {
1919        return Some((b, [ZERO, ONE, ZERO]));
1920    }
1921
1922    // Edge region AB
1923    let vc = d1 * d4 - d3 * d2;
1924    if vc <= ZERO && d1 >= ZERO && d3 <= ZERO {
1925        let den = d1 - d3;
1926        if den.is_zero() {
1927            return None; // |ab|² underflowed
1928        }
1929        let v = d1 / den;
1930        return Some((a + ab * v, [ONE - v, v, ZERO]));
1931    }
1932
1933    // Vertex region C
1934    let cp = p - c;
1935    let d5 = ab.dot(cp);
1936    let d6 = ac.dot(cp);
1937    if d6 >= ZERO && d5 <= d6 {
1938        return Some((c, [ZERO, ZERO, ONE]));
1939    }
1940
1941    // Edge region AC
1942    let vb = d5 * d2 - d1 * d6;
1943    if vb <= ZERO && d2 >= ZERO && d6 <= ZERO {
1944        let den = d2 - d6;
1945        if den.is_zero() {
1946            return None; // |ac|² underflowed
1947        }
1948        let w = d2 / den;
1949        return Some((a + ac * w, [ONE - w, ZERO, w]));
1950    }
1951
1952    // Edge region BC
1953    let va = d3 * d6 - d5 * d4;
1954    let e_b = d4 - d3;
1955    let e_c = d5 - d6;
1956    if va <= ZERO && e_b >= ZERO && e_c >= ZERO {
1957        let den = e_b + e_c;
1958        if den.is_zero() {
1959            return None; // |bc|² underflowed
1960        }
1961        let w = e_b / den;
1962        return Some((b + (c - b) * w, [ZERO, ONE - w, w]));
1963    }
1964
1965    // Face region
1966    let den = va + vb + vc;
1967    if den.is_zero() {
1968        return None; // 2·area underflowed
1969    }
1970    let inv = ONE / den;
1971    let v = vb * inv;
1972    let w = vc * inv;
1973    Some((a + ab * v + ac * w, [ONE - v - w, v, w]))
1974}
1975
1976/// Closest parameters `(s, t)` of segments `p₁→q₁` and `p₂→q₂`, both in `[0, 1]`.
1977///
1978/// Ericson, *Real-Time Collision Detection* §5.1.9: solve the unconstrained 2x2 system,
1979/// clamp `s` into the segment, recompute `t`, and if `t` left its range pin it and resolve
1980/// `s`. The clamping is what turns a line-line answer into a segment-segment one.
1981///
1982/// ⚠️ The clamping is load-bearing. Without it the returned point can lie off the end of
1983/// the segment, and the contact is then reported between two infinite lines — in a mesh
1984/// that means a correction applied to an edge that is nowhere near the other one
1985/// (`closest_points_on_segments_clamps_into_the_endpoint_regions` pins each region).
1986///
1987/// `None` means one thing only: **this pair has no isolated closest point pair in
1988/// `Fix128`**. Three inputs produce it and the caller's action is the same for all three
1989/// (skip the pair):
1990///
1991/// - either segment has zero length, or a length whose square underflows Q64.64
1992///   (`|d|² < 2⁻⁶⁴`, i.e. `|d| < 2⁻³²`) — there is no direction to parametrise along;
1993/// - the two directions are parallel, so `a·e − b² = 0` and the minimum is attained on a
1994///   whole interval rather than at a point. Returning an arbitrary member of that interval
1995///   would make the contact gradients a function of which member was picked, not of the
1996///   geometry. ⚠️ Parallel pairs are therefore **not** covered by the edge-edge pass; they
1997///   are covered by vertex-face, where two parallel sheets put each sheet's vertices over
1998///   the other's triangles. A flat grid's row-mates are the common instance, and this
1999///   rejection is why the pass is silent on an undeformed sheet;
2000/// - the same determinant underflowing to zero for a merely near-parallel pair, which is
2001///   the fixed-point form of the previous case and gets the same answer.
2002fn closest_points_on_segments(
2003    p1: Vec3Fix,
2004    q1: Vec3Fix,
2005    p2: Vec3Fix,
2006    q2: Vec3Fix,
2007) -> Option<(Fix128, Fix128)> {
2008    const ZERO: Fix128 = Fix128::ZERO;
2009    const ONE: Fix128 = Fix128::ONE;
2010
2011    let d1 = q1 - p1;
2012    let d2 = q2 - p2;
2013    let a = d1.length_squared();
2014    let e = d2.length_squared();
2015    if a.is_zero() || e.is_zero() {
2016        return None; // zero-length (or underflowed) segment
2017    }
2018    let b = d1.dot(d2);
2019    let denom = a * e - b * b;
2020    if denom.is_zero() {
2021        return None; // parallel: the minimum is an interval, not a point
2022    }
2023
2024    let r = p1 - p2;
2025    let c = d1.dot(r);
2026    let f = d2.dot(r);
2027
2028    let clamp01 = |x: Fix128| {
2029        if x < ZERO {
2030            ZERO
2031        } else if x > ONE {
2032            ONE
2033        } else {
2034            x
2035        }
2036    };
2037
2038    let mut s = clamp01((b * f - c * e) / denom);
2039    let mut t = (b * s + f) / e;
2040    if t < ZERO {
2041        t = ZERO;
2042        s = clamp01(-c / a);
2043    } else if t > ONE {
2044        t = ONE;
2045        s = clamp01((b - c) / a);
2046    }
2047    Some((s, t))
2048}
2049
2050/// Position of a linearly moving point at frame parameter `t ∈ [0, 1]`
2051#[inline]
2052fn at_frame_time(start: Vec3Fix, end: Vec3Fix, t: Fix128) -> Vec3Fix {
2053    start + (end - start) * t
2054}
2055
2056/// Is a closest-point parameter strictly inside its segment?
2057///
2058/// `closest_points_on_segments` returns the **clamped** parameter, so `0` and `1` are
2059/// exactly the values it produces when the unconstrained solution fell outside the segment
2060/// — the vertex and edge Voronoi regions. An unconstrained solution landing exactly on an
2061/// endpoint has measure zero, so reading the clamped boundary as "outside" loses nothing
2062/// real.
2063#[inline]
2064fn is_interior(x: Fix128) -> bool {
2065    x > Fix128::ZERO && x < Fix128::ONE
2066}
2067
2068/// How finely the coplanarity cubic is subdivided before a bracket is accepted as a root.
2069///
2070/// `2⁻¹²` of a frame. The bracket width only sets *where* the geometry below is sampled; it
2071/// does not gate whether the pair is considered, because the convex-hull rejection is
2072/// applied at every level and is exact regardless of depth.
2073const COPLANARITY_SUBDIVISION_DEPTH: u32 = 12;
2074
2075/// Bernstein control points (scaled by 3) of the edge-edge coplanarity cubic over one frame.
2076///
2077/// Each argument is one vertex as `(start, end)`; the edges are `a₀→a₁` and `b₀→b₁`. With
2078/// every endpoint moving linearly over `t ∈ [0, 1]`,
2079///
2080/// ```text
2081/// f(t) = ((a₁(t) − a₀(t)) × (b₁(t) − b₀(t))) · (b₀(t) − a₀(t))
2082/// ```
2083///
2084/// is a cubic, and `f(t) = 0` is a **necessary** condition for the two segments to touch:
2085/// two segments that meet are coplanar at that instant. Every edge-edge crossing therefore
2086/// passes through a root of `f` inside the frame.
2087///
2088/// ⚠️ Returning the **Bernstein** rather than the power coefficients is what makes the
2089/// rejection usable as a conservative filter. A Bézier curve lies inside the convex hull of
2090/// its control points, so *all four strictly positive (or all strictly negative) proves
2091/// there is no root in the interval* — double roots included, which is the case a sign test
2092/// on `f(0)`/`f(1)` alone silently misses. The power coefficients admit no such bound.
2093///
2094/// The ×3 keeps every value exact: `c₁ = k₀ + k₁/3` is not a dyadic rational and would round
2095/// in `Fix128`, while `3c₁ = 3k₀ + k₁` is a sum of exact products. Scaling all four by the
2096/// same positive constant leaves every sign — the only thing read — unchanged.
2097fn coplanarity_control_points(
2098    a0: (Vec3Fix, Vec3Fix),
2099    a1: (Vec3Fix, Vec3Fix),
2100    b0: (Vec3Fix, Vec3Fix),
2101    b1: (Vec3Fix, Vec3Fix),
2102) -> [Fix128; 4] {
2103    // Each of the three difference vectors is affine in `t`: value at 0 and derivative.
2104    let av = a1.0 - a0.0;
2105    let ad = (a1.1 - a1.0) - (a0.1 - a0.0);
2106    let bv = b1.0 - b0.0;
2107    let bd = (b1.1 - b1.0) - (b0.1 - b0.0);
2108    let cv = b0.0 - a0.0;
2109    let cd = (b0.1 - b0.0) - (a0.1 - a0.0);
2110
2111    let p0 = av.cross(bv);
2112    let p1 = av.cross(bd) + ad.cross(bv);
2113    let p2 = ad.cross(bd);
2114
2115    let k0 = p0.dot(cv);
2116    let k1 = p0.dot(cd) + p1.dot(cv);
2117    let k2 = p1.dot(cd) + p2.dot(cv);
2118    let k3 = p2.dot(cd);
2119
2120    let two = Fix128::from_int(2);
2121    let three = Fix128::from_int(3);
2122    [
2123        three * k0,
2124        three * k0 + k1,
2125        three * k0 + two * k1 + k2,
2126        three * (k0 + k1 + k2 + k3),
2127    ]
2128}
2129
2130/// De Casteljau split of a cubic Bézier at its midpoint.
2131///
2132/// Only halvings, so the subdivision stays in the dyadic rationals that `Fix128` represents
2133/// exactly down to its last bit.
2134fn bezier_split_half(c: [Fix128; 4]) -> ([Fix128; 4], [Fix128; 4]) {
2135    let h = Fix128::from_ratio(1, 2);
2136    let l1 = (c[0] + c[1]) * h;
2137    let m = (c[1] + c[2]) * h;
2138    let r2 = (c[2] + c[3]) * h;
2139    let l2 = (l1 + m) * h;
2140    let r1 = (m + r2) * h;
2141    let mid = (l2 + r1) * h;
2142    ([c[0], l1, l2, mid], [mid, r1, r2, c[3]])
2143}
2144
2145/// Bracket the roots of the coplanarity cubic in `[lo, hi]`, appending their midpoints.
2146///
2147/// ⚠️ **Conservative in one direction on purpose**: a bracket is discarded only when the
2148/// convex-hull test *proves* the cubic cannot reach zero inside it, so no crossing is ever
2149/// filtered out here. The opposite error — reporting a bracket that holds no crossing — is
2150/// accepted, and the geometric test in `edge_pair_frame_contact` is what removes those.
2151///
2152/// A cubic has at most three roots, so the four-slot buffer cannot overflow for a
2153/// non-degenerate curve. It is read as a budget rather than a count: a cubic that is
2154/// identically zero (two edges that stay coplanar for the whole frame) makes every bracket
2155/// survive, and filling the buffer stops the descent instead of walking `2¹²` leaves.
2156fn push_coplanarity_root_times(
2157    c: [Fix128; 4],
2158    lo: Fix128,
2159    hi: Fix128,
2160    depth: u32,
2161    out: &mut [Fix128; 4],
2162    n: &mut usize,
2163) {
2164    if *n == out.len() {
2165        return;
2166    }
2167    let all_positive = c.iter().all(|v| *v > Fix128::ZERO);
2168    let all_negative = c.iter().all(|v| *v < Fix128::ZERO);
2169    if all_positive || all_negative {
2170        return; // convex hull of the control points misses zero: no root in [lo, hi]
2171    }
2172    let mid = (lo + hi) * Fix128::from_ratio(1, 2);
2173    if depth == 0 {
2174        out[*n] = mid;
2175        *n += 1;
2176        return;
2177    }
2178    let (left, right) = bezier_split_half(c);
2179    push_coplanarity_root_times(left, lo, mid, depth - 1, out, n);
2180    push_coplanarity_root_times(right, mid, hi, depth - 1, out, n);
2181}
2182
2183/// Contact data for an edge pair that met at some point inside the frame.
2184///
2185/// `gap` is **signed against the side edge `a` started the frame on**, which is what lets a
2186/// completed crossing be told apart from a near miss: a pair that passed through each other
2187/// ends with `gap < 0`, a pair that merely came close ends with `0 ≤ gap < thickness`. The
2188/// proximity pass cannot express the first case, because it reads an unsigned distance.
2189struct FrameEdgeContact {
2190    /// Closest-point parameter on edge `a` at the end of the frame
2191    s: Fix128,
2192    /// Closest-point parameter on edge `b` at the end of the frame
2193    t: Fix128,
2194    /// Unit separation direction, oriented toward the side `a` started on
2195    normal: Vec3Fix,
2196    /// `normal · (a_closest − b_closest)` at the end of the frame
2197    gap: Fix128,
2198}
2199
2200/// Swept edge-edge test over one frame: did these two edges meet, and where did that leave
2201/// them?
2202///
2203/// Each argument is one vertex as `(start, end)`. This is the **shared predicate**: the
2204/// frame repair (`resolve_self_contact_over_frame`) and the invariant check
2205/// (`Cloth::remaining_self_contact_crossings`) both go through it, exactly as the
2206/// vertex-face side of both shares `segment_pierces_triangle`. They differ only in the
2207/// threshold they then apply to `gap`, so the two can never disagree about *whether* a pair
2208/// met.
2209///
2210/// ⚠️⚠️ **The sharing is deliberate, and it has a cost: this function is a common-mode
2211/// failure point.** Sharing is what stops the repair and the check from drifting into
2212/// "fixes one thing, measures another" — an error in here reaches both, which is the point.
2213/// But *the death of this function* also reaches both: if it stops reporting contacts, the
2214/// repair never fires (so the cloth tunnels) **and** the invariant never counts (so it
2215/// reports `0`). ⚠️ **An oracle that asserts "the invariant is `0`" cannot distinguish a
2216/// clean cloth from a dead predicate.**
2217///
2218/// Measured 2026-10-01 with `return None` inserted at the top of this function
2219/// (`W3-predicate-always-none`): the whole of `tests/analytic_self_contact.rs` passed,
2220/// **15 passed / 0 failed**, including the target oracle
2221/// `a_crumpled_cloth_does_not_pass_through_itself`.
2222///
2223/// ⚠️ **The only things that caught it are the two positive controls** — the lib tests
2224/// `an_edge_pair_that_swaps_sides_in_one_frame_is_seen_by_the_crossings_invariant` and
2225/// `a_pair_that_crosses_and_returns_inside_one_interval_is_not_counted`, both of which
2226/// assert a **non-zero** count under a known-bad condition. Changing this function's
2227/// contract without checking that those two still fail for the right reason removes the
2228/// only evidence that it is alive.
2229///
2230/// # Which way it is conservative
2231///
2232/// ⚠️ **Toward false positives, in both of its two stages**, because a missed crossing is
2233/// a hole in the invariant while a spurious one costs a correction that the proximity pass
2234/// would have made anyway:
2235///
2236/// - the coplanarity stage discards a bracket only when the convex hull of the Bernstein
2237///   control points proves the cubic has no root in it;
2238/// - the geometric stage accepts the pair when the segments come within `thickness` at a
2239///   bracketed instant, not when they are exactly incident. At a true crossing that distance
2240///   is zero, so the margin only admits extra pairs.
2241///
2242/// Both statements hold up to `Fix128` rounding in the products — the same standard as the
2243/// rest of this module, not an exact-arithmetic claim.
2244///
2245/// # ⚠️ Endpoint contacts are excluded, and that is not a weakening
2246///
2247/// The closest points must be **strictly interior** to both segments at the instant they
2248/// meet. A clamped parameter means the closest approach is at a vertex, so the
2249/// configuration is vertex-vertex or vertex-edge rather than edge-edge, and it is the
2250/// vertex-face stage that owns it. "Which side was edge `a` on" is not even well defined
2251/// there: for two coplanar neighbours in the same sheet the separation direction lies *in*
2252/// the sheet, and its sign flips on any slight deformation.
2253///
2254/// Measured on the folded 5x5 scene of `test_cloth_self_collision`: without this condition
2255/// the stage fired on in-sheet neighbours with `s = 1, t = 0` (several pairs meeting at one
2256/// shared vertex, reported with identical gaps), and the layer gap with self-contact ON
2257/// dropped to 1.8748 against 2.7589 with it OFF. Every pair it fired on had a clamped
2258/// parameter; none were interior crossings.
2259///
2260/// # What it still does not see
2261///
2262/// A pair that crosses and crosses back inside one frame ends on the side it started on, so
2263/// `gap` comes out positive and the instrument does not count it. It is also not
2264/// interpenetrating at the end of the frame, which is the property `step` maintains.
2265///
2266/// `None` means the pair never met: either no coplanarity root survives, or no bracketed
2267/// instant brings the segments within `thickness`, or the end configuration is parallel and
2268/// has no isolated closest pair to state the constraint on.
2269fn edge_pair_frame_contact(
2270    a0: (Vec3Fix, Vec3Fix),
2271    a1: (Vec3Fix, Vec3Fix),
2272    b0: (Vec3Fix, Vec3Fix),
2273    b1: (Vec3Fix, Vec3Fix),
2274    thickness: Fix128,
2275) -> Option<FrameEdgeContact> {
2276    let control = coplanarity_control_points(a0, a1, b0, b1);
2277    let mut times = [Fix128::ZERO; 4];
2278    let mut found = 0usize;
2279    push_coplanarity_root_times(
2280        control,
2281        Fix128::ZERO,
2282        Fix128::ONE,
2283        COPLANARITY_SUBDIVISION_DEPTH,
2284        &mut times,
2285        &mut found,
2286    );
2287    if found == 0 {
2288        return None; // the two edges are never coplanar inside this frame
2289    }
2290
2291    // Coplanar is not the same as touching: two edges in a common plane can still be far
2292    // apart. Require that one of the bracketed instants actually brings them together.
2293    let thickness_sq = thickness * thickness;
2294    let mut met = false;
2295    for tau in &times[..found] {
2296        let pa0 = at_frame_time(a0.0, a0.1, *tau);
2297        let pa1 = at_frame_time(a1.0, a1.1, *tau);
2298        let pb0 = at_frame_time(b0.0, b0.1, *tau);
2299        let pb1 = at_frame_time(b1.0, b1.1, *tau);
2300        let Some((s, t)) = closest_points_on_segments(pa0, pa1, pb0, pb1) else {
2301            continue; // degenerate or parallel at this instant
2302        };
2303        if !is_interior(s) || !is_interior(t) {
2304            continue; // endpoint contact: not this stage's to resolve (see below)
2305        }
2306        let ca = pa0 + (pa1 - pa0) * s;
2307        let cb = pb0 + (pb1 - pb0) * t;
2308        if (ca - cb).length_squared() < thickness_sq {
2309            met = true;
2310            break;
2311        }
2312    }
2313    if !met {
2314        return None;
2315    }
2316
2317    // The separation direction is read off the **start** of the frame. Reading it from the
2318    // end instead would point the wrong way for exactly the pairs this stage exists for: a
2319    // pair that tunnelled has its end-of-frame difference on the far side, so a correction
2320    // built from it would push the two edges further through each other.
2321    let start_difference = closest_points_on_segments(a0.0, a1.0, b0.0, b1.0).map(|(s, t)| {
2322        let ca = a0.0 + (a1.0 - a0.0) * s;
2323        let cb = b0.0 + (b1.0 - b0.0) * t;
2324        ca - cb
2325    });
2326    let normal = match start_difference.and_then(Vec3Fix::try_normalize) {
2327        Some(n) => n,
2328        // The edges already met at the start of the frame (or were parallel then): fall back
2329        // to their common perpendicular, the direction that separates them without rotating
2330        // either one. A pair with no normal is a pair with no correction.
2331        None => (a1.0 - a0.0).cross(b1.0 - b0.0).try_normalize()?,
2332    };
2333
2334    // The constraint is stated on the end-of-frame configuration, which is what the solver
2335    // is allowed to move.
2336    let (s, t) = closest_points_on_segments(a0.1, a1.1, b0.1, b1.1)?;
2337    let ca = a0.1 + (a1.1 - a0.1) * s;
2338    let cb = b0.1 + (b1.1 - b0.1) * t;
2339    Some(FrameEdgeContact {
2340        s,
2341        t,
2342        normal,
2343        gap: normal.dot(ca - cb),
2344    })
2345}
2346
2347/// Find shared edge between two triangles and create a bending constraint
2348fn find_shared_edge(
2349    tri_a: &[usize; 3],
2350    tri_b: &[usize; 3],
2351    positions: &[Vec3Fix],
2352) -> Option<BendConstraint> {
2353    for ia in 0..3 {
2354        for ib in 0..3 {
2355            let a0 = tri_a[ia];
2356            let a1 = tri_a[(ia + 1) % 3];
2357            let b0 = tri_b[ib];
2358            let b1 = tri_b[(ib + 1) % 3];
2359
2360            if (a0 == b0 && a1 == b1) || (a0 == b1 && a1 == b0) {
2361                // Shared edge found
2362                let opposite_a = tri_a[(ia + 2) % 3];
2363                let opposite_b = tri_b[(ib + 2) % 3];
2364
2365                // Compute rest dihedral angle
2366                let edge = positions[a1] - positions[a0];
2367                let n1 = edge.cross(positions[opposite_a] - positions[a0]);
2368                let n2 = edge.cross(positions[opposite_b] - positions[a0]);
2369
2370                let n1_len = n1.length();
2371                let n2_len = n2.length();
2372                let rest_angle = if n1_len.is_zero() || n2_len.is_zero() {
2373                    Fix128::ZERO
2374                } else {
2375                    // Compute actual dihedral angle via atan2(sin, cos)
2376                    let n1u = n1 / n1_len;
2377                    let n2u = n2 / n2_len;
2378                    let cos_val = n1u.dot(n2u);
2379                    let sin_val = n1u.cross(n2u).length();
2380                    Fix128::atan2(sin_val, cos_val)
2381                };
2382
2383                return Some(BendConstraint {
2384                    i0: a0,
2385                    i1: a1,
2386                    i2: opposite_a,
2387                    i3: opposite_b,
2388                    rest_angle,
2389                });
2390            }
2391        }
2392    }
2393    None
2394}
2395
2396// ============================================================================
2397// Tests
2398// ============================================================================
2399
2400#[cfg(test)]
2401mod tests {
2402    use super::*;
2403    #[cfg(not(feature = "std"))]
2404    use alloc::format;
2405
2406    #[test]
2407    fn test_cloth_creation() {
2408        let cloth = Cloth::new_grid(
2409            Vec3Fix::ZERO,
2410            Fix128::from_int(2),
2411            Fix128::from_int(2),
2412            5,
2413            5,
2414            Fix128::from_ratio(1, 100),
2415        );
2416
2417        assert_eq!(cloth.particle_count(), 25);
2418        assert!(
2419            !cloth.edge_constraints.is_empty(),
2420            "Should have edge constraints"
2421        );
2422    }
2423
2424    #[test]
2425    fn test_cloth_pinning() {
2426        let mut cloth = Cloth::new_grid(
2427            Vec3Fix::ZERO,
2428            Fix128::from_int(2),
2429            Fix128::from_int(2),
2430            5,
2431            5,
2432            Fix128::from_ratio(1, 100),
2433        );
2434
2435        cloth.pin_top_row(5);
2436        assert_eq!(cloth.pinned.len(), 5);
2437
2438        for i in 0..5 {
2439            assert!(cloth.inv_masses[i].is_zero(), "Top row should be pinned");
2440        }
2441    }
2442
2443    #[test]
2444    fn test_cloth_drape() {
2445        let mut cloth = Cloth::new_grid(
2446            Vec3Fix::ZERO,
2447            Fix128::from_int(2),
2448            Fix128::from_int(2),
2449            5,
2450            5,
2451            Fix128::from_ratio(1, 100),
2452        );
2453        cloth.pin_top_row(5);
2454
2455        let dt = Fix128::from_ratio(1, 60);
2456        for _ in 0..120 {
2457            cloth.step(dt);
2458        }
2459
2460        // Bottom row should drape below starting position
2461        // With constraints, the cloth stretches slowly; verify it moved downward
2462        let initial_y = cloth.positions[0].y.to_f32(); // top row (pinned)
2463        let bottom_y = cloth.positions[20].y.to_f32(); // bottom row
2464        assert!(
2465            bottom_y < initial_y,
2466            "Bottom should be below top, top={initial_y}, bottom={bottom_y}"
2467        );
2468    }
2469
2470    /// 折り返して 2 層が重なった布で、自己接触が層を引き離すことを確かめる
2471    ///
2472    /// ⚠️ **旧 scene (5x5 を上端 pin で吊るす) は自己交差しなかったので、
2473    /// `self_collision = false` にしても green だった** (2026-09-29 破壊試験で実測:
2474    /// 最小粒子間距離 0.49994 に対し判定閾値 0.025 = 余裕 20 倍、`too_close` は 0 で
2475    /// assert は `0 < 25`) 余裕を締め直しても「自己交差しない場面で自己接触を測っている」
2476    /// ことは変わらないので、**scene を置換**した
2477    ///
2478    /// 折り返しは**等長**なので辺拘束と喧嘩しない (行 3 → `(y=1, z=2)`、行 4 → `(y=1, z=1)`
2479    /// で全ての辺と対角が元の長さを保つ) 行 1 と行 4 は辺で繋がっていない重なり対になる
2480    ///
2481    /// ⚠️ この test が言えるのは **粒子-粒子の斥力が働いていること**だけです
2482    /// 頂点が三角形の内部を通り抜ける経路は粒子-粒子では原理的に見られないので、
2483    /// そちらは `tests/analytic_self_contact.rs` の目標 oracle が担当する
2484    #[test]
2485    fn test_cloth_self_collision() {
2486        fn folded(self_collision: bool) -> Fix128 {
2487            const R: usize = 5;
2488            let mut cloth = Cloth::new_grid(
2489                Vec3Fix::ZERO,
2490                Fix128::from_int(4),
2491                Fix128::from_int(4),
2492                R,
2493                R,
2494                Fix128::from_ratio(1, 100),
2495            );
2496            cloth.config.self_collision = self_collision;
2497            // 層間 (1.0) より大きく取って、重なりが判定に入るようにする
2498            cloth.config.self_collision_distance = Fix128::from_ratio(3, 2);
2499            cloth.config.gravity = Vec3Fix::ZERO;
2500            for x in 0..R {
2501                cloth.positions[3 * R + x] =
2502                    Vec3Fix::new(cloth.positions[x].x, Fix128::ONE, Fix128::from_int(2));
2503                cloth.positions[4 * R + x] =
2504                    Vec3Fix::new(cloth.positions[x].x, Fix128::ONE, Fix128::ONE);
2505            }
2506            cloth.prev_positions = cloth.positions.clone();
2507            for x in 0..R {
2508                cloth.inv_masses[x] = Fix128::ZERO;
2509            }
2510            cloth.step(Fix128::from_ratio(1, 60));
2511            // 行 1 と行 4 の層間最小距離
2512            let mut gap = Fix128::from_int(1 << 20);
2513            for x in 0..R {
2514                let d = (cloth.positions[4 * R + x] - cloth.positions[R + x]).length();
2515                if d < gap {
2516                    gap = d;
2517                }
2518            }
2519            gap
2520        }
2521
2522        let gap_on = folded(true);
2523        let gap_off = folded(false);
2524        assert!(
2525            gap_on > gap_off,
2526            "自己接触を有効にしても層が引き離されていない (ON {} <= OFF {})",
2527            gap_on.to_f32(),
2528            gap_off.to_f32()
2529        );
2530    }
2531
2532    // ---- vertex-face self-contact -----------------------------------
2533
2534    /// `closest_point_on_triangle` の 7 領域を閉形式で固定する
2535    ///
2536    /// 三角形は `a = (0,0,0)`, `b = (1,0,0)`, `c = (0,0,1)` (XZ 平面) 座標は全て 2 進小数
2537    /// なので `Fix128` で厳密、許容差なしの等値で比較できる
2538    ///
2539    /// ⚠️ **重心座標も一緒に pin する** 最近接点だけを見ると「辺 `ab` の上」と「面領域で
2540    /// 辺 `ab` のすぐ内側」が区別できないが、この 2 つは**勾配が違う** (前者は `c` に
2541    /// 補正が行かない) 出所: 手計算
2542    #[test]
2543    fn closest_point_clamps_into_the_edge_and_vertex_regions() {
2544        let a = Vec3Fix::from_int(0, 0, 0);
2545        let b = Vec3Fix::from_int(1, 0, 0);
2546        let c = Vec3Fix::from_int(0, 0, 1);
2547        let h = Fix128::from_ratio(1, 2);
2548        let q = Fix128::from_ratio(1, 4);
2549        let two = Fix128::from_int(2);
2550        let neg_h = Fix128::ZERO - h;
2551        let neg_one = Fix128::from_int(-1);
2552        let o = Fix128::ZERO;
2553        let i = Fix128::ONE;
2554
2555        let cases: [(Vec3Fix, Vec3Fix, [Fix128; 3], &str); 7] = [
2556            // 面領域: 垂線の足 (1/4, 0, 1/4)、w = (1/2, 1/4, 1/4)
2557            (
2558                Vec3Fix::new(q, h, q),
2559                Vec3Fix::new(q, o, q),
2560                [h, q, q],
2561                "face",
2562            ),
2563            // 辺 ab: p は -z 側に外れる
2564            (
2565                Vec3Fix::new(h, h, neg_one),
2566                Vec3Fix::new(h, o, o),
2567                [h, h, o],
2568                "edge ab",
2569            ),
2570            // 辺 bc (斜辺): 平面への投影は u + v = 2 > 1
2571            (
2572                Vec3Fix::new(i, h, i),
2573                Vec3Fix::new(h, o, h),
2574                [o, h, h],
2575                "edge bc",
2576            ),
2577            // 辺 ca: p は -x 側に外れる
2578            (
2579                Vec3Fix::new(neg_one, h, h),
2580                Vec3Fix::new(o, o, h),
2581                [h, o, h],
2582                "edge ca",
2583            ),
2584            // 頂点 a: ab·ap = ac·ap = -1/2 < 0
2585            (Vec3Fix::new(neg_h, h, neg_h), a, [i, o, o], "vertex a"),
2586            // 頂点 b
2587            (Vec3Fix::new(two, h, neg_h), b, [o, i, o], "vertex b"),
2588            // 頂点 c
2589            (Vec3Fix::new(neg_h, h, two), c, [o, o, i], "vertex c"),
2590        ];
2591
2592        for (p, expect_q, expect_w, name) in cases {
2593            let Some((got_q, got_w)) = closest_point_on_triangle(p, a, b, c) else {
2594                panic!("{name}: 退化していない三角形で None が返った");
2595            };
2596            assert_eq!(got_q, expect_q, "{name}: 最近接点");
2597            assert_eq!(got_w, expect_w, "{name}: 重心座標");
2598            // 重心座標と最近接点が同じ点を指していること (勾配の整合性の前提)
2599            assert_eq!(
2600                a * got_w[0] + b * got_w[1] + c * got_w[2],
2601                expect_q,
2602                "{name}: w が最近接点を再構成しない"
2603            );
2604        }
2605
2606        // 退化 (面積 0) は None `Some(a)` 等の「最近接でない点」で誤魔化さない
2607        for (x, y, z, name) in [
2608            (a, b, b, "b と c が同一"),
2609            (a, a, a, "3 頂点が同一"),
2610            (a, b, Vec3Fix::from_int(2, 0, 0), "3 頂点が同一直線上"),
2611        ] {
2612            assert!(
2613                closest_point_on_triangle(Vec3Fix::new(q, h, q), x, y, z).is_none(),
2614                "{name}: 面積 0 の三角形で Some が返った"
2615            );
2616        }
2617    }
2618
2619    /// 頂点-面の判定が頂点-頂点の判定を含む (同じ閾値で取りこぼさない)
2620    ///
2621    /// 2 つの根拠を一緒に pin する:
2622    ///
2623    /// 1. 点-三角形距離 ≤ 点-頂点距離 (三角形の頂点も三角形の一部なので自明だが、
2624    ///    `closest_point_on_triangle` の領域分岐が壊れるとここが破れる)
2625    /// 2. `new_grid` の mesh では、**同じ三角形に属する頂点対は必ず辺拘束にもなっている**
2626    ///    ので、旧 particle-particle 判定が除外していた対 (= 辺で繋がった対) と、
2627    ///    新判定が除外する対 (= 三角形に属する頂点) が一致する
2628    #[test]
2629    fn point_triangle_subsumes_point_point() {
2630        let cloth = Cloth::new_grid(
2631            Vec3Fix::ZERO,
2632            Fix128::from_int(2),
2633            Fix128::from_int(2),
2634            3,
2635            3,
2636            Fix128::from_ratio(1, 100),
2637        );
2638
2639        // (2) 三角形内の頂点対は全て辺拘束にある
2640        let mut edges: Vec<(usize, usize)> = cloth
2641            .edge_constraints
2642            .iter()
2643            .map(|c| (c.i0.min(c.i1), c.i0.max(c.i1)))
2644            .collect();
2645        edges.sort_unstable();
2646        for tri in &cloth.triangles {
2647            for (x, y) in [(tri[0], tri[1]), (tri[1], tri[2]), (tri[2], tri[0])] {
2648                assert!(
2649                    edges.binary_search(&(x.min(y), x.max(y))).is_ok(),
2650                    "三角形 {tri:?} の頂点対 ({x}, {y}) が辺拘束に無い"
2651                );
2652            }
2653        }
2654
2655        // (1) どの頂点から測っても、三角形までの距離の方が近い
2656        let a = Vec3Fix::from_int(0, 0, 0);
2657        let b = Vec3Fix::from_int(1, 0, 0);
2658        let c = Vec3Fix::from_int(0, 0, 1);
2659        let probes = [
2660            Vec3Fix::new(
2661                Fix128::from_ratio(1, 4),
2662                Fix128::from_ratio(1, 2),
2663                Fix128::from_ratio(1, 4),
2664            ),
2665            Vec3Fix::new(
2666                Fix128::from_int(3),
2667                Fix128::from_int(1),
2668                Fix128::from_int(-2),
2669            ),
2670            Vec3Fix::new(
2671                Fix128::from_ratio(-3, 4),
2672                Fix128::ZERO,
2673                Fix128::from_ratio(7, 8),
2674            ),
2675            Vec3Fix::new(
2676                Fix128::from_int(2),
2677                Fix128::from_int(2),
2678                Fix128::from_int(2),
2679            ),
2680        ];
2681        for p in probes {
2682            let (q, _) = closest_point_on_triangle(p, a, b, c).expect("退化していない");
2683            let face_d2 = (p - q).length_squared();
2684            for v in [a, b, c] {
2685                assert!(
2686                    face_d2 <= (p - v).length_squared(),
2687                    "点 {p:?}: 三角形までの距離² {face_d2:?} が頂点までの距離²より大きい"
2688                );
2689            }
2690        }
2691    }
2692
2693    /// 接触対の列挙順が結果を変えない (Jacobi 蓄積の決定性)
2694    ///
2695    /// ⚠️ **これは「単一スレッドで順序が固定だから決定的」とは別の主張です** 順序が
2696    /// **どうであっても**同じ、を要求している broad-phase の実装を差し替えても、
2697    /// SIMD で分割しても、結果が動かないことがここで担保される
2698    /// (`Fix128` の加算は `Z/2¹²⁸` の厳密な群演算、`tests/reduction_order_independence.rs`)
2699    #[test]
2700    fn self_contact_result_is_independent_of_the_pair_order() {
2701        fn folded_cloth() -> Cloth {
2702            const R: usize = 5;
2703            let mut cloth = Cloth::new_grid(
2704                Vec3Fix::ZERO,
2705                Fix128::from_int(4),
2706                Fix128::from_int(4),
2707                R,
2708                R,
2709                Fix128::from_ratio(1, 100),
2710            );
2711            cloth.config.self_collision = true;
2712            cloth.config.self_collision_distance = Fix128::from_ratio(3, 2);
2713            for x in 0..R {
2714                cloth.positions[3 * R + x] =
2715                    Vec3Fix::new(cloth.positions[x].x, Fix128::ONE, Fix128::from_int(2));
2716                cloth.positions[4 * R + x] =
2717                    Vec3Fix::new(cloth.positions[x].x, Fix128::ONE, Fix128::ONE);
2718            }
2719            cloth
2720        }
2721
2722        let mut forward = folded_cloth();
2723        let mut reverse = folded_cloth();
2724        let margin = forward.config.self_collision_distance.double();
2725        let mut candidates = forward.collect_vertex_face_candidates(margin);
2726        assert!(
2727            !candidates.is_empty(),
2728            "折り返した布で接触候補が 0 件 この scene は順序非依存性を試せない"
2729        );
2730        forward.solve_vertex_face_self_collision(&candidates);
2731        candidates.reverse();
2732        reverse.solve_vertex_face_self_collision(&candidates);
2733
2734        assert_eq!(
2735            forward.positions, reverse.positions,
2736            "接触対を逆順に処理すると結果が変わる = 蓄積が順序依存"
2737        );
2738    }
2739
2740    /// 1 frame で布を貫くはずの頂点が、貫かずに入射側へ戻される
2741    ///
2742    /// # scene (閉形式)
2743    ///
2744    /// 5x5 の格子を XZ 平面 (`y = 0`) に置き、**辺拘束と曲げ拘束を外して**衝突だけを見る
2745    /// 粒子 0 以外は全て pin、粒子 0 は far corner の三角形の**内部**の真上
2746    /// `(3.5, 2, 3.25)` に置いて `v = (0, -240, 0)` で落とす (`dt = 1/60` なので 1 frame の
2747    /// 変位は `-4`) 落下線 `x + z = 6.75 < 7` は対角線 `x + z = 7` の内側なので、
2748    /// 三角形 `[(3,3), (4,3), (3,4)]` の内部を通る 粒子 0 はこの三角形の頂点ではない
2749    ///
2750    /// 期待: 自己接触 OFF なら `y < 0` (素通り)、ON なら `y > 0` (入射側に残る) で
2751    /// `remaining_self_contact_crossings` が 0
2752    #[test]
2753    fn a_vertex_driven_through_the_sheet_is_pushed_back_to_the_entry_side() {
2754        fn scene(self_collision: bool) -> (Cloth, Vec<Vec3Fix>) {
2755            let mut cloth = Cloth::new_grid(
2756                Vec3Fix::ZERO,
2757                Fix128::from_int(4),
2758                Fix128::from_int(4),
2759                5,
2760                5,
2761                Fix128::from_ratio(1, 100),
2762            );
2763            // 衝突だけを測るため、内力を外す (布としてでなく三角形集合として見る)
2764            cloth.edge_constraints.clear();
2765            cloth.bend_constraints.clear();
2766            cloth.config.gravity = Vec3Fix::ZERO;
2767            cloth.config.damping = Fix128::ONE;
2768            cloth.config.self_collision = self_collision;
2769            cloth.config.self_collision_distance = Fix128::from_ratio(1, 10);
2770            for i in 1..cloth.particle_count() {
2771                cloth.inv_masses[i] = Fix128::ZERO;
2772            }
2773            cloth.positions[0] = Vec3Fix::new(
2774                Fix128::from_ratio(7, 2),
2775                Fix128::from_int(2),
2776                Fix128::from_ratio(13, 4),
2777            );
2778            cloth.prev_positions[0] = cloth.positions[0];
2779            cloth.velocities[0] = Vec3Fix::new(Fix128::ZERO, Fix128::from_int(-240), Fix128::ZERO);
2780            let start = cloth.positions.clone();
2781            cloth.step(Fix128::from_ratio(1, 60));
2782            (cloth, start)
2783        }
2784
2785        let (off, start_off) = scene(false);
2786        assert!(
2787            off.positions[0].y < Fix128::ZERO,
2788            "自己接触 OFF で粒子が布を素通りしなかった (y = {}) この scene は貫通を試せていない",
2789            off.positions[0].y.to_f32()
2790        );
2791        assert!(
2792            off.remaining_self_contact_crossings(&start_off) > 0,
2793            "自己接触 OFF で貫通が検出されない"
2794        );
2795
2796        let (on, start_on) = scene(true);
2797        assert_eq!(
2798            on.remaining_self_contact_crossings(&start_on),
2799            0,
2800            "自己接触 ON で貫通が残った (y = {})",
2801            on.positions[0].y.to_f32()
2802        );
2803        assert!(
2804            on.positions[0].y > Fix128::ZERO,
2805            "自己接触 ON で粒子が布の裏側に居る (y = {}) 押し戻しの向きが入射側でない",
2806            on.positions[0].y.to_f32()
2807        );
2808    }
2809
2810    // -----------------------------------------------------------------------
2811    // 近接斥力段の強さ — 自分自身の三角形を除外していることを測る
2812    //
2813    // ⚠️ 2026-10-01 の破壊試験で、変異 `M3` (`collect_vertex_face_candidates` から
2814    // `if i == tri[0] || ... { continue }` を外す) が **lib / 統合 oracle とも green**
2815    // で生き残りました 自分自身の対では最近接点が頂点自身になり、`delta` への寄与が
2816    // 同じ index で厳密に相殺するので、**位置の差として出るのは `hits` の水増しによる
2817    // 平均化の弱まりだけ**です 以下 2 本はその弱まりを直接測ります
2818    // -----------------------------------------------------------------------
2819
2820    /// 候補対に「その三角形自身の頂点」が現れない (契約)
2821    ///
2822    /// ⚠️ **これは `collect_vertex_face_candidates` の内部条件の言い換えですが、それが
2823    /// 言い換えでしか固定できない契約です** 下の閉形式 test が「水増しの量」を測り、
2824    /// 本 test が「水増しが 1 件でも起きていないこと」を測ります 片方だけでは
2825    /// `M3` が生き残ります (統合 scene では相殺して見えない)
2826    #[test]
2827    fn candidate_pairs_never_contain_a_vertex_of_their_own_triangle() {
2828        let mut cloth = folded_cloth_with_contacts();
2829        let margin = cloth.config.self_collision_distance.double();
2830        let candidates = cloth.collect_vertex_face_candidates(margin);
2831        assert!(
2832            !candidates.is_empty(),
2833            "折り返した布で接触候補が 0 件 この scene は契約を試せない"
2834        );
2835        for &(vi, ti) in &candidates {
2836            let tri = cloth.triangles[ti as usize];
2837            assert!(
2838                vi as usize != tri[0] && vi as usize != tri[1] && vi as usize != tri[2],
2839                "候補対 ({vi}, {ti}) が三角形 {tri:?} 自身の頂点を含む \
2840                 自分の面に対する斥力は delta では相殺するが hits を水増しして \
2841                 他の接触の補正を薄める"
2842            );
2843        }
2844        // 自己対が混ざっていないことを件数でも押さえる (不在でなく数で判定する)
2845        let self_pairs = cloth
2846            .triangles
2847            .iter()
2848            .enumerate()
2849            .map(|(ti, tri)| {
2850                candidates
2851                    .iter()
2852                    .filter(|&&(vi, cti)| {
2853                        cti as usize == ti
2854                            && (vi as usize == tri[0]
2855                                || vi as usize == tri[1]
2856                                || vi as usize == tri[2])
2857                    })
2858                    .count()
2859            })
2860            .sum::<usize>();
2861        assert_eq!(self_pairs, 0, "自己対が {self_pairs} 件混ざっている");
2862        // 実際に解いても三角形の頂点が自分の面から押されない (念のため後段でも測る)
2863        let before = cloth.positions.clone();
2864        cloth.solve_vertex_face_self_collision(&candidates);
2865        assert!(
2866            before != cloth.positions,
2867            "折り返した布で斥力が 1 bit も発火しない この scene は判別に向かない"
2868        );
2869    }
2870
2871    /// 近接斥力 1 回で、頂点-面の隙間が **厳密に** `thickness` になる (閉形式)
2872    ///
2873    /// # scene (全ての値が 2 進小数なので許容差なしの等値 assert が書ける)
2874    ///
2875    /// 三角形 `a = (0,0,0)`, `b = (1,0,0)`, `c = (0,0,1)` (XZ 平面)、頂点
2876    /// `p = (1/4, 1/8, 1/4)` 最近接点は面領域の `q = (1/4, 0, 1/4)` で重心座標は
2877    /// `(w₀, w₁, w₂) = (1/2, 1/4, 1/4)` (`tests/analytic_self_contact.rs` 節 A と同じ配置)
2878    ///
2879    /// 逆質量を `w_p = 1/2`, `w_a = 1`, `w_b = w_c = 2` に選ぶと分母が **厳密に 1** になる:
2880    ///
2881    /// ```text
2882    /// denom = w_p + w_a w₀² + w_b w₁² + w_c w₂² = 1/2 + 1/4 + 2/16 + 2/16 = 1
2883    /// s     = (thickness − |p−q|) / denom = (1/4 − 1/8) / 1 = 1/8
2884    /// Δp    = +s·w_p·n = +1/16·n      (n = (0,1,0))
2885    /// Δa    = −s·w_a·w₀·n = −1/16·n   Δb = −s·w_b·w₁·n = −1/16·n   Δc = −1/16·n
2886    /// ```
2887    ///
2888    /// `Δa = Δb = Δc` なので三角形は XZ 平面に平行なまま `−1/16` 平行移動し、隙間は
2889    /// `1/8 + 1/16 + 1/16 = 1/4 = thickness` に**なるはず**です
2890    ///
2891    /// # ⚠️ 変異 `M3` でこの等式が壊れる量
2892    ///
2893    /// 自己対 3 件が加わると `hits[a] = hits[b] = hits[c] = 1 + 4 = 5` になり
2894    /// (`delta` は相殺するので値は変わらない)、三角形の移動量が `1/16` → `1/80` に
2895    /// 縮んで隙間が `1/8 + 1/16 + 1/80 ≠ 1/4` になります **`delta` でなく `hits` を
2896    /// 測るのが本 test の役目**です
2897    #[test]
2898    fn the_proximity_projection_restores_exactly_the_contact_thickness() {
2899        let eighth = Fix128::from_ratio(1, 8);
2900        let quarter = Fix128::from_ratio(1, 4);
2901        let sixteenth = Fix128::from_ratio(1, 16);
2902
2903        let mut cloth = Cloth::new_grid(Vec3Fix::ZERO, Fix128::ONE, Fix128::ONE, 2, 2, Fix128::ONE);
2904        cloth.edge_constraints.clear();
2905        cloth.bend_constraints.clear();
2906        cloth.triangles = vec![[1, 2, 3]];
2907        cloth.config.self_collision = true;
2908        cloth.config.self_collision_distance = quarter;
2909        cloth.positions[0] = Vec3Fix::new(quarter, eighth, quarter);
2910        cloth.positions[1] = Vec3Fix::from_int(0, 0, 0);
2911        cloth.positions[2] = Vec3Fix::from_int(1, 0, 0);
2912        cloth.positions[3] = Vec3Fix::from_int(0, 0, 1);
2913        cloth.inv_masses[0] = Fix128::from_ratio(1, 2);
2914        cloth.inv_masses[1] = Fix128::ONE;
2915        cloth.inv_masses[2] = Fix128::from_int(2);
2916        cloth.inv_masses[3] = Fix128::from_int(2);
2917
2918        let candidates = cloth.collect_vertex_face_candidates(quarter.double());
2919        // ⚠️ `M3` を入れるとここが 4 件 ((1,0) / (2,0) / (3,0) が増える) になり red
2920        assert_eq!(
2921            candidates,
2922            vec![(0u32, 0u32)],
2923            "候補は (頂点 0, 三角形 0) の 1 件だけであるべき"
2924        );
2925
2926        cloth.solve_vertex_face_self_collision(&candidates);
2927
2928        let neg_sixteenth = -sixteenth;
2929        assert_eq!(
2930            cloth.positions[0],
2931            Vec3Fix::new(quarter, eighth + sixteenth, quarter),
2932            "oracle: Δp = +1/16 (手計算、denom = 1 なので厳密)"
2933        );
2934        for (i, expected) in [
2935            (
2936                1usize,
2937                Vec3Fix::new(Fix128::ZERO, neg_sixteenth, Fix128::ZERO),
2938            ),
2939            (2, Vec3Fix::new(Fix128::ONE, neg_sixteenth, Fix128::ZERO)),
2940            (3, Vec3Fix::new(Fix128::ZERO, neg_sixteenth, Fix128::ONE)),
2941        ] {
2942            assert_eq!(
2943                cloth.positions[i], expected,
2944                "oracle: 三角形は −1/16 平行移動する (頂点 {i})"
2945            );
2946        }
2947        // 三角形は XZ 平面に平行なままなので、隙間は y 差がそのまま距離になる
2948        assert_eq!(
2949            cloth.positions[0].y - cloth.positions[1].y,
2950            quarter,
2951            "oracle: 1 回の投影で隙間が厳密に thickness = 1/4 になる"
2952        );
2953    }
2954
2955    // -----------------------------------------------------------------------
2956    // 辺-辺の自己接触
2957    // -----------------------------------------------------------------------
2958
2959    /// 辺-辺の最近接パラメータが閉形式と厳密一致する (内部点で最小を取る場合)
2960    ///
2961    /// 線分 A `(-1,0,0) → (1,0,0)`、線分 B `(0,1/8,-1) → (0,1/8,1)` は直交し、
2962    /// 最近接は両方の**中点** `s* = t* = 1/2`、最近接点は `(0,0,0)` と `(0,1/8,0)`、
2963    /// 距離² は `1/64` (手計算: `a = e = 4`, `b = 0`, `c = −2`, `f = 2`, `denom = 16`、
2964    /// `s = (bf − ce)/denom = 8/16`、`t = (bs + f)/e = 2/4`)
2965    #[test]
2966    fn closest_points_on_segments_matches_the_closed_form_in_the_interior() {
2967        let eighth = Fix128::from_ratio(1, 8);
2968        let half = Fix128::from_ratio(1, 2);
2969        let a0 = Vec3Fix::from_int(-1, 0, 0);
2970        let a1 = Vec3Fix::from_int(1, 0, 0);
2971        let b0 = Vec3Fix::new(Fix128::ZERO, eighth, Fix128::from_int(-1));
2972        let b1 = Vec3Fix::new(Fix128::ZERO, eighth, Fix128::ONE);
2973
2974        let Some((s, t)) = closest_points_on_segments(a0, a1, b0, b1) else {
2975            panic!("直交する 2 線分で最近接対が得られなかった");
2976        };
2977        assert_eq!(s, half, "oracle: s* = 1/2");
2978        assert_eq!(t, half, "oracle: t* = 1/2");
2979        let pa = a0 + (a1 - a0) * s;
2980        let pb = b0 + (b1 - b0) * t;
2981        assert_eq!(pa, Vec3Fix::ZERO, "oracle: 最近接点 A = (0,0,0)");
2982        assert_eq!(
2983            pb,
2984            Vec3Fix::new(Fix128::ZERO, eighth, Fix128::ZERO),
2985            "oracle: 最近接点 B = (0,1/8,0)"
2986        );
2987        assert_eq!(
2988            (pa - pb).length_squared(),
2989            Fix128::from_ratio(1, 64),
2990            "oracle: 最近接距離² = 1/64"
2991        );
2992    }
2993
2994    /// `s` / `t` が区間外に出る配置で端点へ clamp される (閉形式)
2995    ///
2996    /// | 配置 | 閉形式の素の解 | clamp 後 |
2997    /// |---|---|---|
2998    /// | A `(0,0,0)→(1,0,0)` / B `(2,1,-1)→(2,1,1)` | `s = 2` | `(s, t) = (1, 1/2)` |
2999    /// | A `(0,0,0)→(1,0,0)` / B `(1/2,1,2)→(1/2,1,3)` | `t = −2` | `(s, t) = (1/2, 0)` |
3000    ///
3001    /// ⚠️ **clamp を外すと線分でなく無限直線の答えになります** 変異 `EE1`
3002    /// (`clamp01` を恒等関数にする) では 1 行目が `s = 2` を返して red
3003    #[test]
3004    fn closest_points_on_segments_clamps_into_the_endpoint_regions() {
3005        let half = Fix128::from_ratio(1, 2);
3006        let a0 = Vec3Fix::from_int(0, 0, 0);
3007        let a1 = Vec3Fix::from_int(1, 0, 0);
3008
3009        // s が 1 を超える: 線分 B は A の先 (x = 2) にある
3010        let Some((s, t)) = closest_points_on_segments(
3011            a0,
3012            a1,
3013            Vec3Fix::from_int(2, 1, -1),
3014            Vec3Fix::from_int(2, 1, 1),
3015        ) else {
3016            panic!("端点領域の配置で最近接対が得られなかった");
3017        };
3018        assert_eq!(
3019            s,
3020            Fix128::ONE,
3021            "oracle: 素の s = 2 が端点 1 へ clamp される"
3022        );
3023        assert_eq!(t, half, "oracle: t = 1/2 は区間内なので動かない");
3024
3025        // t が 0 を下回る: 線分 B は z = 2..3 にあり A から見て手前の端点が最近接
3026        let Some((s, t)) = closest_points_on_segments(
3027            a0,
3028            a1,
3029            Vec3Fix::new(half, Fix128::ONE, Fix128::from_int(2)),
3030            Vec3Fix::new(half, Fix128::ONE, Fix128::from_int(3)),
3031        ) else {
3032            panic!("端点領域の配置で最近接対が得られなかった");
3033        };
3034        assert_eq!(
3035            t,
3036            Fix128::ZERO,
3037            "oracle: 素の t = −2 が端点 0 へ clamp される"
3038        );
3039        assert_eq!(s, half, "oracle: t を固定して解き直した s = 1/2");
3040    }
3041
3042    /// 退化入力と平行入力は `None` (「もっともらしい値」を返さない契約)
3043    ///
3044    /// ⚠️ 退化は統合 scene では作られないので、**専用の契約 test でしか固定できません**
3045    /// (実測: 入口の退化判定を落としても crumple の統合 oracle は green)
3046    #[test]
3047    fn closest_points_on_segments_rejects_degenerate_and_parallel_pairs() {
3048        let o = Vec3Fix::ZERO;
3049        let x1 = Vec3Fix::from_int(1, 0, 0);
3050        let half = Fix128::from_ratio(1, 2);
3051
3052        // (a) 片方が長さ 0
3053        assert!(
3054            closest_points_on_segments(
3055                o,
3056                o,
3057                Vec3Fix::from_int(0, 1, 0),
3058                Vec3Fix::from_int(1, 1, 0)
3059            )
3060            .is_none(),
3061            "長さ 0 の線分が受け付けられた"
3062        );
3063        // (b) 平行 (直交する離れた 2 直線でなく、同じ向き)
3064        assert!(
3065            closest_points_on_segments(
3066                o,
3067                x1,
3068                Vec3Fix::from_int(0, 1, 0),
3069                Vec3Fix::from_int(1, 1, 0)
3070            )
3071            .is_none(),
3072            "平行な 2 線分が受け付けられた 最小は区間なので (s,t) は幾何で決まらない"
3073        );
3074        // (c) 同一直線上 (平行の特別な場合)
3075        assert!(
3076            closest_points_on_segments(
3077                o,
3078                x1,
3079                Vec3Fix::new(half, Fix128::ZERO, Fix128::ZERO),
3080                Vec3Fix::new(Fix128::from_ratio(3, 2), Fix128::ZERO, Fix128::ZERO)
3081            )
3082            .is_none(),
3083            "同一直線上の 2 線分が受け付けられた"
3084        );
3085        // (d) 長さ² が Q64.64 で underflow する (|d| = 2⁻³³ < 2⁻³²)
3086        let tiny = Fix128::from_raw(0, 1 << 31);
3087        assert!(
3088            !tiny.is_zero() && (tiny * tiny).is_zero(),
3089            "この scene の前提 (|d| ≠ 0 だが |d|² が underflow) が崩れた"
3090        );
3091        assert!(
3092            closest_points_on_segments(
3093                o,
3094                Vec3Fix::new(tiny, Fix128::ZERO, Fix128::ZERO),
3095                Vec3Fix::from_int(0, 1, 0),
3096                Vec3Fix::from_int(0, 1, 1)
3097            )
3098            .is_none(),
3099            "長さ² が underflow した線分が受け付けられた"
3100        );
3101    }
3102
3103    /// **2 本目**の線分の退化を `e.is_zero()` が拾う (`denom` の判定では拾えない領域がある)
3104    ///
3105    /// ⚠️ `closest_points_on_segments_rejects_degenerate_and_parallel_pairs` の (a) / (d) は
3106    /// どちらも **1 本目**を退化させるので、入口の判定が `a.is_zero() || e.is_zero()` から
3107    /// `a.is_zero()` に減っても green のまま通ります (変異
3108    /// `EE24-only-first-segment-zero-check`)
3109    ///
3110    /// # ⚠️ `e.is_zero()` が死んで**いない**領域は狭い
3111    ///
3112    /// 2 本目が素直に長さ 0 なら `d₂ = 0` ⇒ `b = d₁·d₂ = 0` ⇒
3113    /// `denom = a·e − b² = 0` なので、**平行の判定が先に拾います** ⇒ そこを突いても
3114    /// `e.is_zero()` の有無は観測できません (実測: 本 test の初版はこの配置だったため
3115    /// 変異が green で生き残りました)
3116    ///
3117    /// `e` だけが 0 になるには、`|d₂|²` が Q64.64 で underflow する一方で `b²` が
3118    /// 生き残る必要があります `|d₂| = 2⁻³³` のとき `|d₂|² = 2⁻⁶⁶ → 0` で、
3119    /// `b² = (|d₁|·2⁻³³)² = |d₁|²·2⁻⁶⁶ ≥ 2⁻⁶⁴` は **`|d₁| ≥ 2`** で成立します
3120    /// ⇒ 1 本目を長さ 4 に取ると `denom = 16·0 − 2⁻⁶² = −2⁻⁶² ≠ 0` で平行判定を素通りします
3121    ///
3122    /// ⚠️ **素通りした先は panic でなく silent な誤答**です `Fix128::div` は 0 除算で
3123    /// `ZERO` を返すので (`src/math.rs` の `impl Div`)、`t = (b·s + f)/e` が無言で 0 になり
3124    /// `Some((s, 0))` という**もっともらしい値**が返ります
3125    ///
3126    /// # 過剰 pin の線引き
3127    ///
3128    /// 固定するのは **「孤立した最近接対を持たない入力は `None`」という契約だけ**で、
3129    /// **どの guard が拒否するかは固定しません** ⇒ 退化の判定を `denom` 側へ寄せる /
3130    /// `d₂` を先に正規化する等の実装変更は `None` を返す限り green のままです
3131    /// red にするのは「もっともらしい値を返すようになった時」だけです
3132    #[test]
3133    fn closest_points_on_segments_rejects_a_degenerate_second_segment() {
3134        let o = Vec3Fix::ZERO;
3135        let x4 = Vec3Fix::from_int(4, 0, 0);
3136        let up = Vec3Fix::from_int(0, 1, 0);
3137        let tiny = Fix128::from_raw(0, 1 << 31); // 2⁻³³
3138        assert!(
3139            !tiny.is_zero() && (tiny * tiny).is_zero(),
3140            "この scene の前提 (|d| ≠ 0 だが |d|² が underflow) が崩れた"
3141        );
3142
3143        // 前提: 1 本目を長さ 4 に取ると b² が生き残り、`denom` は 0 にならない
3144        // (= 平行判定では拾えない ⇒ `e.is_zero()` だけが防いでいる領域)
3145        let b = Fix128::from_int(4) * tiny;
3146        assert!(
3147            !(b * b).is_zero(),
3148            "b² まで underflow した この配置では denom = 0 なので平行判定が先に拾ってしまう"
3149        );
3150
3151        // (a) 2 本目の長さ² だけが underflow する (1 本目は長さ 4)
3152        assert!(
3153            closest_points_on_segments(o, x4, up, Vec3Fix::new(tiny, Fix128::ONE, Fix128::ZERO))
3154                .is_none(),
3155            "2 本目の長さ² が underflow した対が受け付けられた \
3156             (e = 0 のまま t = (b·s + f)/e を踏むと 0 除算が silent に 0 を返す)"
3157        );
3158        // (b) 2 本目が素直に長さ 0 (こちらは平行判定でも拾えるが、契約として固定する)
3159        assert!(
3160            closest_points_on_segments(o, x4, up, up).is_none(),
3161            "2 本目が長さ 0 の線分が受け付けられた"
3162        );
3163        // (c) 退化の拒否は引数の順序に依らない
3164        assert!(
3165            closest_points_on_segments(up, Vec3Fix::new(tiny, Fix128::ONE, Fix128::ZERO), o, x4)
3166                .is_none(),
3167            "同じ退化対を引数順を入れ替えると受け付けられた = 判定が非対称"
3168        );
3169    }
3170
3171    /// `t` を端点に固定した後、`s` を**解き直す** (clamp したままにしない)
3172    ///
3173    /// ⚠️ `closest_points_on_segments_clamps_into_the_endpoint_regions` は
3174    /// **解き直しの前後で `s` が一致する配置**しか持っていません (2 件目は素の `s = 1/2` を
3175    /// clamp せず、`t = 0` に固定した後の `−c/a` も 1/2) ⇒ 解き直しを丸ごと落としても
3176    /// green のまま通ります (変異 `EE7-no-resolve-s-after-pinning-t`)
3177    ///
3178    /// # 閉形式 (2 本とも `|d₁| = 1` の x 軸線分 A `(0,0,0) → (1,0,0)` に対して)
3179    ///
3180    /// | 枝 | 線分 B | `a, e, b` | 素の `s` | 固定後の `t` | **解き直した `s`** |
3181    /// |---|---|---|---|---|---|
3182    /// | `t > 1` | `(-5/2, 1/8, -5/2) → (1/2, 1/8, -3/2)` | `1, 10, 3` | `5 → 1` | `1` | `(b − c)/a = 1/2` |
3183    /// | `t < 0` | `(1/4, 1/8, -5/2) → (-11/4, 1/8, -11/2)` | `1, 18, −3` | `11/4 → 1` | `0` | `−c/a = 1/4` |
3184    ///
3185    /// 幾何でも同じ: どちらの枝でも最近接は **B の端点**で、その端点の `x` 座標
3186    /// (`1/2` と `1/4`、どちらも `[0, 1]` の内部) が `s` になります 解き直さないと
3187    /// `s` は clamp された `1` に居残り、A 上の最近接点が端点 `(1,0,0)` にずれます
3188    ///
3189    /// # 過剰 pin の線引き
3190    ///
3191    /// 期待値は **Ericson の手順でなく幾何そのもの** (真の最近接点) から来ているので、
3192    /// アルゴリズムを別形式 (例: 端点 clamp 4 候補 + 内部解の総当たり) に差し替えても
3193    /// **正しければ同じ値になります** ⇒ 実装の自由度 (clamp の書き方 / 中間変数 / 分岐順) は
3194    /// 固定していません 対照 `CTL3-closest-points-equivalent-rewrite` (同関数の等価な
3195    /// 書き換え) は green であることを実測済みです
3196    #[test]
3197    fn closest_points_on_segments_resolves_s_again_after_pinning_t() {
3198        let half = Fix128::from_ratio(1, 2);
3199        let quarter = Fix128::from_ratio(1, 4);
3200        let eighth = Fix128::from_ratio(1, 8);
3201        let a0 = Vec3Fix::from_int(0, 0, 0);
3202        let a1 = Vec3Fix::from_int(1, 0, 0);
3203
3204        // 枝 t > 1: B の端点 q₂ = (1/2, 1/8, -3/2) が最近接 ⇒ s = 1/2
3205        let b0 = Vec3Fix::new(-Fix128::from_ratio(5, 2), eighth, -Fix128::from_ratio(5, 2));
3206        let b1 = Vec3Fix::new(half, eighth, -Fix128::from_ratio(3, 2));
3207        let Some((s, t)) = closest_points_on_segments(a0, a1, b0, b1) else {
3208            panic!("t > 1 の枝で最近接対が得られなかった");
3209        };
3210        assert_eq!(
3211            t,
3212            Fix128::ONE,
3213            "oracle: 素の t = 13/10 が端点 1 へ固定される"
3214        );
3215        assert_eq!(
3216            s, half,
3217            "oracle: t を 1 に固定した後 s は (b − c)/a = 1/2 に解き直される \
3218             (clamp 後の 1 に居残ってはいけない)"
3219        );
3220        // 幾何の裏取り: 最近接点は B の端点 q₂、A 側はその x 座標
3221        assert_eq!(
3222            a0 + (a1 - a0) * s,
3223            Vec3Fix::new(half, Fix128::ZERO, Fix128::ZERO)
3224        );
3225        assert_eq!(b0 + (b1 - b0) * t, b1);
3226
3227        // 枝 t < 0: B の端点 p₂ = (1/4, 1/8, -5/2) が最近接 ⇒ s = 1/4
3228        let b0 = Vec3Fix::new(quarter, eighth, -Fix128::from_ratio(5, 2));
3229        let b1 = Vec3Fix::new(
3230            -Fix128::from_ratio(11, 4),
3231            eighth,
3232            -Fix128::from_ratio(11, 2),
3233        );
3234        let Some((s, t)) = closest_points_on_segments(a0, a1, b0, b1) else {
3235            panic!("t < 0 の枝で最近接対が得られなかった");
3236        };
3237        assert_eq!(
3238            t,
3239            Fix128::ZERO,
3240            "oracle: 素の t = −13/24 が端点 0 へ固定される"
3241        );
3242        assert_eq!(
3243            s, quarter,
3244            "oracle: t を 0 に固定した後 s は −c/a = 1/4 に解き直される"
3245        );
3246        assert_eq!(
3247            a0 + (a1 - a0) * s,
3248            Vec3Fix::new(quarter, Fix128::ZERO, Fix128::ZERO)
3249        );
3250        assert_eq!(b0 + (b1 - b0) * t, b0);
3251    }
3252
3253    /// 辺-辺の候補対が、頂点を共有する辺対を含まない (契約)
3254    ///
3255    /// ⚠️ 共有頂点の対は距離 0 が**構成上の事実**なので、残すと毎 iteration 発火して
3256    /// 伸び拘束と喧嘩するだけになります 変異 `EE2` でここが red
3257    #[test]
3258    fn edge_edge_candidates_never_pair_two_edges_that_share_a_vertex() {
3259        let cloth = folded_cloth_with_contacts();
3260        let margin = cloth.config.self_collision_distance.double();
3261        let candidates = cloth.collect_edge_edge_candidates(margin);
3262        assert!(
3263            !candidates.is_empty(),
3264            "折り返した布で辺-辺候補が 0 件 この scene は契約を試せない"
3265        );
3266        let shared = candidates
3267            .iter()
3268            .filter(|&&[i0, i1, j0, j1]| i0 == j0 || i0 == j1 || i1 == j0 || i1 == j1)
3269            .count();
3270        assert_eq!(shared, 0, "頂点を共有する辺対が {shared} 件混ざっている");
3271
3272        // 辺集合そのものが mesh から正しく作られていること (重複なし / 昇順)
3273        let edges = cloth.mesh_edges();
3274        let mut sorted = edges.clone();
3275        sorted.sort_unstable();
3276        sorted.dedup();
3277        assert_eq!(edges, sorted, "mesh_edges が昇順かつ重複なしでない");
3278        for e in &edges {
3279            assert!(e[0] < e[1], "辺 {e:?} が [lo, hi] に正規化されていない");
3280        }
3281    }
3282
3283    /// X 字交差の scene — **どの頂点もどの面にも近づかないまま、辺どうしが交差する**
3284    ///
3285    /// ```text
3286    ///   T0 = [0,1,2]   0 = (-1, 0, 0)   1 = (1, 0, 0)   2 = (0, -8, 0)      (XY 平面)
3287    ///   T1 = [3,4,5]   3 = (0, h, -1)   4 = (0, h, 1)   5 = (0, h+8, 0)     (YZ 平面)
3288    /// ```
3289    ///
3290    /// 辺 `(0,1)` は x 軸に沿い、辺 `(3,4)` は z 軸に沿って高さ `h` を通るので、
3291    /// 2 辺は上から見て直交して交差し、距離はちょうど `h` です
3292    ///
3293    /// # なぜ頂点-面では見えないか (閉形式)
3294    ///
3295    /// `T1` は平面 `x = 0` に載り `y ≥ h` なので、頂点 0 = `(-1,0,0)` の最近接点は
3296    /// 辺 `(3,4)` 上の `(0,h,0)` で距離² は `1 + h²` 頂点 1 も対称に同じ 頂点 3 / 4 も
3297    /// `T0` (平面 `z = 0`、`y ≤ 0`) に対して同じ値 残る頂点 2 / 5 は `|y|` が 8 以上
3298    /// 離れています **`h = 1/8`、`thickness = 1/4` では `1 + h² = 65/64 ≫ 1/16 = thickness²`
3299    /// なので、頂点-面の対は 1 つも閾値に入りません**
3300    fn x_crossing_scene(h: Fix128, thickness: Fix128) -> Cloth {
3301        let mut cloth = Cloth::new_grid(Vec3Fix::ZERO, Fix128::ONE, Fix128::ONE, 3, 2, Fix128::ONE);
3302        assert_eq!(cloth.particle_count(), 6);
3303        cloth.edge_constraints.clear();
3304        cloth.bend_constraints.clear();
3305        cloth.triangles = vec![[0, 1, 2], [3, 4, 5]];
3306        cloth.config.gravity = Vec3Fix::ZERO;
3307        cloth.config.damping = Fix128::ONE;
3308        cloth.config.self_collision = true;
3309        cloth.config.self_collision_distance = thickness;
3310        let eight = Fix128::from_int(8);
3311        cloth.positions[0] = Vec3Fix::from_int(-1, 0, 0);
3312        cloth.positions[1] = Vec3Fix::from_int(1, 0, 0);
3313        cloth.positions[2] = Vec3Fix::new(Fix128::ZERO, -eight, Fix128::ZERO);
3314        cloth.positions[3] = Vec3Fix::new(Fix128::ZERO, h, Fix128::from_int(-1));
3315        cloth.positions[4] = Vec3Fix::new(Fix128::ZERO, h, Fix128::ONE);
3316        cloth.positions[5] = Vec3Fix::new(Fix128::ZERO, h + eight, Fix128::ZERO);
3317        cloth.prev_positions = cloth.positions.clone();
3318        cloth.velocities.fill(Vec3Fix::ZERO);
3319        cloth
3320    }
3321
3322    /// 2 辺が軸平行なままである限り、符号付き隙間は `y₃ − y₀` そのもの
3323    ///
3324    /// ⚠️ **実装の距離関数を呼ばずに測るための独立な量です** 辺 `(0,1)` は x 軸、
3325    /// 辺 `(3,4)` は z 軸に沿い、上から見て交差しているので、`y₀ = y₁` かつ `y₃ = y₄`
3326    /// である限り 2 辺の最近接距離は `|y₃ − y₀|` に等しくなります (符号は交差の有無を
3327    /// 表すので捨てません) 前提の 2 つの等式は呼び出し側で assert します
3328    fn x_crossing_signed_gap(cloth: &Cloth) -> Fix128 {
3329        assert_eq!(
3330            cloth.positions[0].y, cloth.positions[1].y,
3331            "辺 (0,1) が x 軸に平行でなくなった この計器は使えない"
3332        );
3333        assert_eq!(
3334            cloth.positions[3].y, cloth.positions[4].y,
3335            "辺 (3,4) が z 軸に平行でなくなった この計器は使えない"
3336        );
3337        cloth.positions[3].y - cloth.positions[0].y
3338    }
3339
3340    /// 辺-辺の投影 1 回で、辺どうしの隙間が **厳密に** `thickness` になる (閉形式)
3341    ///
3342    /// # 閉形式 (`h = 1/8`, `thickness = 1/4`、逆質量は全て 1)
3343    ///
3344    /// ```text
3345    /// s = t = 1/2        n = (pₐ − p_b)/|pₐ − p_b| = (0,−1,0)
3346    /// denom = 1·(1/2)² + 1·(1/2)² + 1·(1/2)² + 1·(1/2)² = 1
3347    /// λ     = (1/4 − 1/8) / 1 = 1/8
3348    /// Δ₀ = Δ₁ = +λ·1·(1/2)·n = (0,−1/16,0)   Δ₃ = Δ₄ = −λ·1·(1/2)·n = (0,+1/16,0)
3349    /// ```
3350    ///
3351    /// 隙間は `1/8 + 1/16 + 1/16 = 1/4 = thickness` になり、接触に参加しない頂点 2 / 5 は
3352    /// `hits = 0` なので 1 bit も動きません
3353    ///
3354    /// ⚠️ **この 1 本が辺-辺側の変異 3 つを全部捕まえます** `EE1` (clamp 除去) は
3355    /// `s = 2` 側に飛んで Δ が変わる、`EE2` (共有頂点の除外を落とす) は T0 / T1 の
3356    /// 隣接辺対が距離 0 で発火して頂点 2 / 5 まで動く、`EE3` (閾値の比較反転) は
3357    /// 接触が skip されて 1 bit も動かない
3358    #[test]
3359    fn the_edge_edge_projection_restores_exactly_the_contact_thickness() {
3360        let eighth = Fix128::from_ratio(1, 8);
3361        let quarter = Fix128::from_ratio(1, 4);
3362        let sixteenth = Fix128::from_ratio(1, 16);
3363        let mut cloth = x_crossing_scene(eighth, quarter);
3364        let before = cloth.positions.clone();
3365        assert_eq!(
3366            x_crossing_signed_gap(&cloth),
3367            eighth,
3368            "scene の初期隙間は h = 1/8"
3369        );
3370
3371        // ⚠️ 頂点-面の段は、この scene では 1 bit も動かせない (閉形式で距離² = 65/64)
3372        let margin = quarter.double();
3373        let vf = cloth.collect_vertex_face_candidates(margin);
3374        cloth.solve_vertex_face_self_collision(&vf);
3375        assert_eq!(
3376            cloth.positions,
3377            before,
3378            "頂点-面の段がこの scene を動かした X 字交差の盲点を示す scene になっていない \
3379             (候補 {} 件)",
3380            vf.len()
3381        );
3382
3383        let ee = cloth.collect_edge_edge_candidates(margin);
3384        assert_eq!(
3385            ee.iter()
3386                .filter(|&&[i0, i1, j0, j1]| [i0, i1, j0, j1] == [0, 1, 3, 4])
3387                .count(),
3388            1,
3389            "辺 (0,1) × (3,4) の対が候補に 1 件だけ入っていない (候補 {ee:?})"
3390        );
3391        cloth.solve_edge_edge_self_collision(&ee);
3392
3393        let neg_sixteenth = -sixteenth;
3394        assert_eq!(
3395            cloth.positions[0],
3396            Vec3Fix::new(Fix128::from_int(-1), neg_sixteenth, Fix128::ZERO),
3397            "oracle: Δ₀ = −1/16 (denom = 1 なので厳密)"
3398        );
3399        assert_eq!(
3400            cloth.positions[1],
3401            Vec3Fix::new(Fix128::ONE, neg_sixteenth, Fix128::ZERO),
3402            "oracle: Δ₁ = −1/16"
3403        );
3404        assert_eq!(
3405            cloth.positions[3],
3406            Vec3Fix::new(Fix128::ZERO, eighth + sixteenth, Fix128::from_int(-1)),
3407            "oracle: Δ₃ = +1/16"
3408        );
3409        assert_eq!(
3410            cloth.positions[4],
3411            Vec3Fix::new(Fix128::ZERO, eighth + sixteenth, Fix128::ONE),
3412            "oracle: Δ₄ = +1/16"
3413        );
3414        assert_eq!(
3415            cloth.positions[2], before[2],
3416            "接触に参加しない頂点 2 が動いた (hits = 0 のはず)"
3417        );
3418        assert_eq!(
3419            cloth.positions[5], before[5],
3420            "接触に参加しない頂点 5 が動いた (hits = 0 のはず)"
3421        );
3422        assert_eq!(
3423            x_crossing_signed_gap(&cloth),
3424            quarter,
3425            "oracle: 1 回の投影で辺-辺の隙間が厳密に thickness = 1/4 になる"
3426        );
3427    }
3428
3429    /// 辺-辺の投影が `s` と `t` を**別々に**使い、逆質量で重み付けする (非対称な閉形式)
3430    ///
3431    /// ⚠️ `the_edge_edge_projection_restores_exactly_the_contact_thickness` の scene は
3432    /// `s = t = 1/2` かつ逆質量が全て 1 なので、**`s` と `t` を取り違えても / 逆質量を
3433    /// 無視して等分しても同じ答えになります** 実測でその 2 変異
3434    /// (`EE18-use-s-in-place-of-t` / `EE15-ignore-inv-mass-equal-split`) を red にしていたのは
3435    /// `test_cloth_self_collision` の `gap_on > gap_off` だけで、**閉形式の歯が無い**状態でした
3436    /// 本 test は `s ≠ t` かつ逆質量 4 種を全て違う値にして、その層に歯を置きます
3437    ///
3438    /// # scene
3439    ///
3440    /// ```text
3441    /// T0 = [0,1,2]   0 = (0,0,0)        1 = (1,0,0)        2 = (0,-8,0)
3442    /// T1 = [3,4,5]   3 = (1/4,1/8,-1/2) 4 = (1/4,1/8,1/2)  5 = (1/4,1/8+8,0)
3443    /// ```
3444    ///
3445    /// 辺 A = `(0,1)` は x 軸、辺 B = `(3,4)` は x = 1/4 を通る z 方向 ⇒ A 上の最近接は
3446    /// `x = 1/4` (= `s = 1/4`)、B 上の最近接は `z = 0` (= `t = 1/2`) で **`s ≠ t`**
3447    ///
3448    /// 距離は y 差の `1/8` のみ `thickness = 1/4` に対し他の対は全て閾値外です
3449    /// (頂点 0 → 辺 (3,4) が `1/16 + 1/64 = 5/64 > 4/64 = thickness²` で最接近、
3450    /// 辺 (0,2) × (3,4) も同じ `5/64`)
3451    ///
3452    /// # 閉形式 (逆質量 `w₀ = 0`, `w₁ = 8`, `w₂ = 1/2`, `w₃ = 3/2`)
3453    ///
3454    /// ```text
3455    /// u = 1 − s = 3/4   v = 1 − t = 1/2   n = (pₐ − p_b)/|pₐ − p_b| = (0,−1,0)
3456    /// denom = 0·(3/4)² + 8·(1/4)² + (1/2)·(1/2)² + (3/2)·(1/2)² = 1/2 + 1/8 + 3/8 = 1
3457    /// λ     = (1/4 − 1/8) / 1 = 1/8
3458    /// Δ₀ = +λ·0·u·n     = 0          Δ₁ = +λ·8·s·n   = (0,−1/4,0)
3459    /// Δ₃ = −λ·(1/2)·v·n = (0,1/32,0) Δ₄ = −λ·(3/2)·t·n = (0,3/32,0)
3460    /// ```
3461    ///
3462    /// `hits` は 4 頂点とも 1 なので平均で値は変わらず、頂点 0 は `inv_mass = 0` のため
3463    /// 適用時に skip されます `denom = 1` は偶然でなく重みの選び方で、全段が 2 進小数です
3464    ///
3465    /// # 過剰 pin の線引き
3466    ///
3467    /// 固定するのは `accumulate_edge_edge_contacts` の doc が宣言している **XPBD の投影式**
3468    /// (`(s, t)` を固定した 1 次の勾配 + 逆質量重み) の値です 候補の列挙順 / 法線の求め方 /
3469    /// 平均の実装は固定していません (本 scene は `hits = 1` なので平均の寄与が無い)
3470    /// ⚠️ **拘束の定式化そのものを変えると red になります** (例: `(s, t)` の微分項を入れる)
3471    /// それは近似モデルの変更なので、red は差し戻しでなく**裁定を求める合図**として正しい形です
3472    /// 対照として辺-辺の外側の変異 (`CTL1-bend-constraint-gradient-sign-flipped` /
3473    /// `CTL2-stretch-rest-length-doubled`) では本 test は green のままです (誤爆なしを実測)
3474    #[test]
3475    fn the_edge_edge_projection_weights_s_t_and_the_inverse_masses_separately() {
3476        let quarter = Fix128::from_ratio(1, 4);
3477        let half = Fix128::from_ratio(1, 2);
3478        let eighth = Fix128::from_ratio(1, 8);
3479        let eight = Fix128::from_int(8);
3480
3481        let scene = || {
3482            let mut c = Cloth::new_grid(Vec3Fix::ZERO, Fix128::ONE, Fix128::ONE, 3, 2, Fix128::ONE);
3483            assert_eq!(c.particle_count(), 6);
3484            c.edge_constraints.clear();
3485            c.bend_constraints.clear();
3486            c.triangles = vec![[0, 1, 2], [3, 4, 5]];
3487            c.config.gravity = Vec3Fix::ZERO;
3488            c.config.damping = Fix128::ONE;
3489            c.config.self_collision = true;
3490            c.config.self_collision_distance = quarter;
3491            c.positions[0] = Vec3Fix::from_int(0, 0, 0);
3492            c.positions[1] = Vec3Fix::from_int(1, 0, 0);
3493            c.positions[2] = Vec3Fix::new(Fix128::ZERO, -eight, Fix128::ZERO);
3494            c.positions[3] = Vec3Fix::new(quarter, eighth, -half);
3495            c.positions[4] = Vec3Fix::new(quarter, eighth, half);
3496            c.positions[5] = Vec3Fix::new(quarter, eighth + eight, Fix128::ZERO);
3497            // ⚠️ 4 つとも違う値 これが等分との差を作る
3498            c.inv_masses[0] = Fix128::ZERO;
3499            c.inv_masses[1] = eight;
3500            c.inv_masses[3] = half;
3501            c.inv_masses[4] = Fix128::from_ratio(3, 2);
3502            c.prev_positions = c.positions.clone();
3503            c.velocities.fill(Vec3Fix::ZERO);
3504            c
3505        };
3506
3507        // 契約: 最近接パラメータが閉形式どおり **s ≠ t** であること (scene の前提)
3508        let probe = scene();
3509        let Some((s, t)) = closest_points_on_segments(
3510            probe.positions[0],
3511            probe.positions[1],
3512            probe.positions[3],
3513            probe.positions[4],
3514        ) else {
3515            panic!("辺 (0,1) × (3,4) で最近接対が得られなかった");
3516        };
3517        assert_eq!(s, quarter, "oracle: s = 1/4");
3518        assert_eq!(t, half, "oracle: t = 1/2");
3519        assert!(
3520            s != t,
3521            "この scene は s = t なので s / t の取り違えを試せない"
3522        );
3523
3524        // ⚠️ 頂点-面の段はこの scene を 1 bit も動かせない (最接近が 5/64 > 4/64)
3525        let mut cloth = scene();
3526        let before = cloth.positions.clone();
3527        let margin = quarter.double();
3528        let vf = cloth.collect_vertex_face_candidates(margin);
3529        cloth.solve_vertex_face_self_collision(&vf);
3530        assert_eq!(
3531            cloth.positions,
3532            before,
3533            "頂点-面の段がこの scene を動かした (候補 {} 件) 辺-辺だけを測れていない",
3534            vf.len()
3535        );
3536
3537        // 発火するのは辺 (0,1) × (3,4) の 1 対だけ
3538        let ee = cloth.collect_edge_edge_candidates(margin);
3539        assert_eq!(
3540            ee.iter()
3541                .filter(|&&[i0, i1, j0, j1]| [i0, i1, j0, j1] == [0, 1, 3, 4])
3542                .count(),
3543            1,
3544            "辺 (0,1) × (3,4) の対が候補に 1 件だけ入っていない (候補 {ee:?})"
3545        );
3546        cloth.solve_edge_edge_self_collision(&ee);
3547
3548        let thirty_second = Fix128::from_ratio(1, 32);
3549        assert_eq!(
3550            cloth.positions[0], before[0],
3551            "oracle: 頂点 0 は inv_mass = 0 なので 1 bit も動かない"
3552        );
3553        assert_eq!(
3554            cloth.positions[1],
3555            Vec3Fix::new(Fix128::ONE, -quarter, Fix128::ZERO),
3556            "oracle: Δ₁ = −1/4 (= λ·w₁·s、w₁ = 8 と s = 1/4 の積)"
3557        );
3558        assert_eq!(
3559            cloth.positions[3],
3560            Vec3Fix::new(quarter, eighth + thirty_second, -half),
3561            "oracle: Δ₃ = +1/32 (= λ·w₂·v、w₂ = 1/2 と v = 1 − t = 1/2 の積)"
3562        );
3563        assert_eq!(
3564            cloth.positions[4],
3565            Vec3Fix::new(quarter, eighth + thirty_second * Fix128::from_int(3), half),
3566            "oracle: Δ₄ = +3/32 (= λ·w₃·t、w₃ = 3/2 と t = 1/2 の積)"
3567        );
3568        assert_eq!(
3569            cloth.positions[2], before[2],
3570            "接触に参加しない頂点 2 が動いた (hits = 0 のはず)"
3571        );
3572        assert_eq!(
3573            cloth.positions[5], before[5],
3574            "接触に参加しない頂点 5 が動いた (hits = 0 のはず)"
3575        );
3576        // 4 つの Δ は全部違う大きさ = 重みが潰れていない (等分だと |Δ| が揃う)
3577        assert!(
3578            (cloth.positions[1].y - before[1].y) != (cloth.positions[3].y - before[3].y)
3579                && (cloth.positions[3].y - before[3].y) != (cloth.positions[4].y - before[4].y),
3580            "Δ の大きさが揃っている = 逆質量 / s / t の重みが潰れている"
3581        );
3582    }
3583
3584    /// 辺-辺の対の列挙順が結果を変えない (Jacobi 蓄積の決定性)
3585    #[test]
3586    fn edge_edge_result_is_independent_of_the_pair_order() {
3587        let mut forward = folded_cloth_with_contacts();
3588        let mut reverse = folded_cloth_with_contacts();
3589        let margin = forward.config.self_collision_distance.double();
3590        let mut candidates = forward.collect_edge_edge_candidates(margin);
3591        assert!(
3592            candidates.len() > 1,
3593            "辺-辺候補が {} 件では順序非依存性を試せない",
3594            candidates.len()
3595        );
3596        let before = forward.positions.clone();
3597        forward.solve_edge_edge_self_collision(&candidates);
3598        assert!(
3599            forward.positions != before,
3600            "辺-辺の斥力が 1 bit も発火しない この scene は判別に向かない"
3601        );
3602        candidates.reverse();
3603        reverse.solve_edge_edge_self_collision(&candidates);
3604        assert_eq!(
3605            forward.positions, reverse.positions,
3606            "辺-辺の対を逆順に処理すると結果が変わる = 蓄積が順序依存"
3607        );
3608    }
3609
3610    /// 2 段を 1 本の buffer に統合しても、両方の対の列挙順が結果を変えない
3611    ///
3612    /// ⚠️ **buffer を統合すると蓄積の順序が変わる**ので、段ごとの順序非依存
3613    /// (`self_contact_result_is_independent_of_the_pair_order` /
3614    /// `edge_edge_result_is_independent_of_the_pair_order`) とは別に測り直す必要があります
3615    /// 成立の根拠は `Fix128` の加算が `Z/2¹²⁸` の厳密な群演算であること
3616    /// (`tests/reduction_order_independence.rs`) で、段をまたいでも同じです
3617    #[test]
3618    fn self_collision_is_independent_of_the_pair_order_across_both_stages() {
3619        let mut forward = folded_cloth_with_contacts();
3620        let mut reverse = folded_cloth_with_contacts();
3621        let margin = forward.config.self_collision_distance.double();
3622        let mut vf = forward.collect_vertex_face_candidates(margin);
3623        let mut ee = forward.collect_edge_edge_candidates(margin);
3624        assert!(
3625            vf.len() > 1 && ee.len() > 1,
3626            "候補が 頂点-面 {} 件 / 辺-辺 {} 件 では順序非依存性を試せない",
3627            vf.len(),
3628            ee.len()
3629        );
3630        let before = forward.positions.clone();
3631        forward.solve_self_collision(&vf, &ee);
3632        assert!(
3633            forward.positions != before,
3634            "2 段を統合した投影が 1 bit も発火しない この scene は判別に向かない"
3635        );
3636        vf.reverse();
3637        ee.reverse();
3638        reverse.solve_self_collision(&vf, &ee);
3639        assert_eq!(
3640            forward.positions, reverse.positions,
3641            "両段の対を逆順に処理すると結果が変わる = 統合 buffer の蓄積が順序依存"
3642        );
3643    }
3644
3645    /// 統合 buffer では、同じ頂点に触れた**両段**の件数で割る (段ごとに割らない)
3646    ///
3647    /// # 閉形式
3648    ///
3649    /// `x_crossing_scene` に、辺 `(0,1)` の両端 0 / 1 を巻き込む頂点-面接触を 1 件
3650    /// 追加した状態で 1 回投影します 頂点 0 / 1 は **辺-辺 1 件 + 頂点-面 1 件 = 2 件**に
3651    /// 触られるので、統合 buffer なら両方の寄与の和を **2** で割ります
3652    ///
3653    /// ⚠️ 段ごとに割ると `(Σ_ee)/1 + (Σ_vf)/1` になって **辺-辺の寄与が 2 倍**残ります
3654    /// それが crumple で「自己接触 ON が OFF より悪い」を生んだ形です
3655    /// (詳細は `solve_self_collision` の doc の表)
3656    #[test]
3657    fn the_averaging_denominator_counts_contacts_from_both_stages() {
3658        let eighth = Fix128::from_ratio(1, 8);
3659        let quarter = Fix128::from_ratio(1, 4);
3660        let ee_pair = [[0u32, 1, 3, 4]];
3661        let vf_pair = [(0u32, 1u32)]; // 頂点 0 と三角形 T1 = [3,4,5]
3662
3663        // 頂点 0 を x = −1 から x = −1/8 に寄せる T1 は平面 x = 0 に載るので、
3664        // 頂点 0 から辺 (3,4) までの距離² は (1/8)² + (1/8)² = 1/32 < 1/16 = thickness²
3665        // になり、頂点-面の接触が 1 件立つ 辺 (0,1) x (3,4) の交差は保たれる
3666        let scene = || {
3667            let mut c = x_crossing_scene(eighth, quarter);
3668            c.positions[0] = Vec3Fix::new(-eighth, Fix128::ZERO, Fix128::ZERO);
3669            c.prev_positions = c.positions.clone();
3670            c
3671        };
3672
3673        let base = scene().positions[0];
3674        let margin = quarter.double();
3675        let probe = scene();
3676        assert!(
3677            probe
3678                .collect_vertex_face_candidates(margin)
3679                .contains(&(0, 1)),
3680            "頂点 0 と三角形 1 の対が候補に入らない"
3681        );
3682        assert!(
3683            probe
3684                .collect_edge_edge_candidates(margin)
3685                .contains(&ee_pair[0]),
3686            "辺 (0,1) x (3,4) の対が候補に入らない"
3687        );
3688
3689        let mut vf_only = scene();
3690        vf_only.solve_self_collision(&vf_pair, &[]);
3691        let d_vf = vf_only.positions[0] - base;
3692        let mut ee_only = scene();
3693        ee_only.solve_self_collision(&[], &ee_pair);
3694        let d_ee = ee_only.positions[0] - base;
3695        assert!(
3696            d_vf != Vec3Fix::ZERO && d_ee != Vec3Fix::ZERO,
3697            "片方の段が発火していない (頂点-面 {d_vf:?} / 辺-辺 {d_ee:?}) この scene は判別に向かない"
3698        );
3699
3700        let mut both = scene();
3701        both.solve_self_collision(&vf_pair, &ee_pair);
3702        assert_eq!(
3703            both.positions[0] - base,
3704            (d_vf + d_ee) / Fix128::from_int(2),
3705            "統合 buffer の補正が「段ごとの寄与の和の 1/2」になっていない \
3706             = 平均の分母が両段の件数 2 になっていない \
3707             (段ごとに割ると和そのものが残り、辺-辺が 2 倍過大評価される)"
3708        );
3709    }
3710
3711    /// 1 frame で辺どうしが入れ替わる X 字交差が、辺-辺の段だけで止まる
3712    ///
3713    /// # scene
3714    ///
3715    /// `x_crossing_scene(h = 1/2, thickness = 1/4)` の `T1` を 1 frame で `−3/4`
3716    /// 平行移動させる (`v = (0, −48, 0)`, `dt = 1/64`) 符号付き隙間は `+1/2` から
3717    /// 自由落下なら `−1/4` になる = **2 辺が入れ替わる (貫通する)**
3718    ///
3719    /// ⚠️ **`dt = 1/64` は「60 fps を模す」よりも厳密性を優先した選択です** `1/60` は
3720    /// 2 進小数でないので `Fix128::from_ratio(1, 60)` が切り捨てられ、`substep_dt = dt/4`
3721    /// と `v·dt` を経た変位が閉形式から **12 ulp** ずれます (実測、`-0.25` と表示される
3722    /// のに `assert_eq!` が red) `1/64` なら `substep_dt = 1/256`、1 substep の変位は
3723    /// `−48/256 = −3/16` で全て厳密です
3724    ///
3725    /// # ⚠️ この test は 1.5.x で「盲点の記録」から「効果の測定」に変わりました
3726    ///
3727    /// 旧版の `remaining_self_contact_crossings` が測るのは「頂点の掃過線分が三角形を
3728    /// 貫いたか」だけだったので、辺どうしの交差は **自己接触 OFF でも 0** を返して
3729    /// いました 旧 assert はその `0` を pin しており、message は
3730    /// 「頂点-面の不変量が辺-辺の交差を検出した この scene は盲点を示していない」=
3731    /// **検出する方が誤りである**と読める文面でした
3732    ///
3733    /// ⚠️ **その message は doc と逆向きで、誤っていたのは message の方です** 同 doc が
3734    /// 当時から「`0` なのは正しいからでなく、その量を見ていないから」と正しく書いて
3735    /// います 不変量に辺-辺が入って盲点が閉じたので、`0` を pin する理由は無くなり、
3736    /// **OFF → `1` (貫通が見える) / ON → `0` (辺-辺の段が止めた)** の効果 test に
3737    /// 反転させました (計器が消えるのでなく、測る対象が盲点から効果に移った形)
3738    ///
3739    /// # ⚠️⚠️ `OFF → 1` を消さないこと — 共通モード故障を見る唯一の経路です
3740    ///
3741    /// この `OFF → 1` の assert は、**共有述語 `edge_pair_frame_contact` の共通モード
3742    /// 故障を検出する唯一の経路**です 修復と計器が同じ述語を通るので、⚠️ **述語が死ぬと
3743    /// 両方が同時に盲目になります** — 修復が発火しないので貫通が起きるのに、計器も
3744    /// 数えないので不変量は `0` を報告します ⇒ 「`0` = 貫通なし」と読む oracle は
3745    /// **原理的に検出できません**
3746    ///
3747    /// ⚠️ **`ON → 0` だけに弱めると、述語を殺す変異が全 test を素通りします** 実測
3748    /// (2026-10-01、変異 `W3-predicate-always-none` = `edge_pair_frame_contact` の先頭に
3749    /// `return None`):
3750    ///
3751    /// ```text
3752    /// tests/analytic_self_contact.rs: 15 passed; 0 failed   ← 目標 oracle も green
3753    /// 捕らえたのは本 test と a_pair_that_crosses_and_returns_inside_one_interval_is_not_counted の 2 本だけ
3754    /// ```
3755    ///
3756    /// ⚠️ **両方とも「非 `0` を assert する陽性対照」を持つから効いています** 冗長に
3757    /// 見えても消さないこと (`ON → 0` 側は対象の健全を、`OFF → 1` 側は**計器が生きて
3758    /// いること**を見ており、別のものを測っています)
3759    #[test]
3760    fn an_edge_pair_that_swaps_sides_in_one_frame_is_seen_by_the_crossings_invariant() {
3761        let half = Fix128::from_ratio(1, 2);
3762        let quarter = Fix128::from_ratio(1, 4);
3763        let dt = Fix128::from_ratio(1, 64);
3764
3765        let run = |self_collision: bool| -> (Fix128, usize) {
3766            let mut cloth = x_crossing_scene(half, quarter);
3767            cloth.config.self_collision = self_collision;
3768            // T1 を 1 frame で −3/4 平行移動 (剛体的に動かすので T1 の形は変わらない)
3769            for i in 3..6 {
3770                cloth.velocities[i] =
3771                    Vec3Fix::new(Fix128::ZERO, Fix128::from_int(-48), Fix128::ZERO);
3772            }
3773            let start = cloth.positions.clone();
3774            cloth.step(dt);
3775            let crossings = cloth.remaining_self_contact_crossings(&start);
3776            (x_crossing_signed_gap(&cloth), crossings)
3777        };
3778
3779        let (gap_off, crossings_off) = run(false);
3780        assert_eq!(
3781            gap_off,
3782            -quarter,
3783            "自己接触 OFF で符号付き隙間が −1/4 (= 2 辺が入れ替わった) にならない \
3784             この scene は貫通を試せていない (実測 {})",
3785            gap_off.to_f32()
3786        );
3787        assert_eq!(
3788            crossings_off, 1,
3789            "自己接触 OFF で 2 辺が入れ替わった (符号付き隙間 +1/2 → −1/4) のに \
3790             不変量が辺-辺の交差を数えていない 1.5.x 以前の盲点が再発している"
3791        );
3792
3793        let (gap_on, crossings_on) = run(true);
3794        assert!(
3795            gap_on >= quarter,
3796            "自己接触 ON で辺-辺の隙間が thickness = 1/4 を下回った (実測 {})",
3797            gap_on.to_f32()
3798        );
3799        assert_eq!(
3800            crossings_on, 0,
3801            "自己接触 ON で貫通が残った (頂点-面 / 辺-辺 の合算)"
3802        );
3803    }
3804
3805    /// 退化入力で panic しないこと (辺-辺の掃過 CCD 経路)
3806    ///
3807    /// 対象は `remaining_self_contact_crossings` → `collect_swept_edge_edge_candidates`
3808    /// → `edge_pair_frame_contact` の経路で、`start` の長さと幾何の両方を退化させます
3809    /// **正とする挙動は「panic しない」** (この API は `Result` を返さないので、測れない
3810    /// 入力は数えないのが契約です)
3811    ///
3812    /// # 歯 (この test が空振りでないことの実測)
3813    ///
3814    /// ⚠️ `collect_swept_edge_edge_candidates` の境界 guard `if a0 >= n || a1 >= n` を
3815    /// 外すと、**`start` が粒子数より短い case 3 本が panic (index out of bounds) で red**
3816    /// になります (2026-10-01 実測、変異 `F1-drop-start-bounds-guard`)
3817    /// 幾何の退化側は `closest_points_on_segments` / `try_normalize` の `None` 経路が
3818    /// 受けるので、そちらは既存の退化 test が歯になります
3819    #[test]
3820    fn the_crossings_invariant_does_not_panic_on_degenerate_input() {
3821        let quarter = Fix128::from_ratio(1, 4);
3822
3823        // 1. start が空
3824        let cloth = x_crossing_scene(Fix128::from_ratio(1, 2), quarter);
3825        assert_eq!(
3826            cloth.remaining_self_contact_crossings(&[]),
3827            0,
3828            "start が空の時は測れる対が 1 つも無いので 0"
3829        );
3830
3831        // 2. start が粒子数より短い (1 要素 / 粒子数 − 1 要素)
3832        for take in [1usize, cloth.particle_count() - 1] {
3833            let short: Vec<Vec3Fix> = cloth.positions.iter().copied().take(take).collect();
3834            let _ = cloth.remaining_self_contact_crossings(&short);
3835        }
3836
3837        // 3. start が粒子数より長い (余分は無視される)
3838        let mut long = cloth.positions.clone();
3839        long.push(Vec3Fix::ZERO);
3840        long.push(Vec3Fix::ZERO);
3841        let _ = cloth.remaining_self_contact_crossings(&long);
3842
3843        // 4. 全粒子が同一点 = 全辺が長さ 0 (`closest_points_on_segments` が None を返す側)
3844        let mut collapsed = x_crossing_scene(Fix128::from_ratio(1, 2), quarter);
3845        let start = collapsed.positions.clone();
3846        collapsed.positions.fill(Vec3Fix::ZERO);
3847        assert_eq!(
3848            collapsed.remaining_self_contact_crossings(&start),
3849            0,
3850            "長さ 0 の辺しか無い時に対を数えている"
3851        );
3852
3853        // 5. 接触厚 0 (thickness_sq が 0 になり「出会った」が成立しなくなる経路)
3854        let mut zero_thickness = x_crossing_scene(Fix128::from_ratio(1, 2), quarter);
3855        zero_thickness.config.self_collision_distance = Fix128::ZERO;
3856        let start = zero_thickness.positions.clone();
3857        assert_eq!(
3858            zero_thickness.remaining_self_contact_crossings(&start),
3859            0,
3860            "接触厚 0 で辺-辺の交差を数えている"
3861        );
3862
3863        // 6. start と現在位置が完全に同一 (frame 中に何も動いていない = 3 次式が定数)
3864        let still = x_crossing_scene(Fix128::from_ratio(1, 2), quarter);
3865        let same = still.positions.clone();
3866        assert_eq!(
3867            still.remaining_self_contact_crossings(&same),
3868            0,
3869            "静止した frame で交差を数えている"
3870        );
3871
3872        // 7. 三角形を持たない cloth (辺集合が空、二重 loop が 0 回)
3873        let mut no_tris = x_crossing_scene(Fix128::from_ratio(1, 2), quarter);
3874        let start = no_tris.positions.clone();
3875        no_tris.triangles.clear();
3876        assert_eq!(
3877            no_tris.remaining_self_contact_crossings(&start),
3878            0,
3879            "三角形が無いのに対を数えている"
3880        );
3881    }
3882
3883    /// ⚠️ **限界の記録** — 1 つの区間の中で通り抜けて戻った対は、不変量に出ない
3884    ///
3885    /// `remaining_self_contact_crossings` は **区間の終端** で貫通側に居るかを数えます
3886    /// (doc の § The invariant is stated at the end of the frame) ⇒ 行って戻った対は
3887    /// 終端では元の側に居るので **`0`** になります
3888    ///
3889    /// # scene (閉形式、`x_crossing_scene(h = 1/2, thickness = 1/4)`、自己接触 OFF)
3890    ///
3891    /// `T1` を `dt = 1/64` の 2 frame で往復させます (`v = ∓48`、変位 `∓3/4`)
3892    ///
3893    /// ```text
3894    /// 符号付き隙間:  +1/2  ──frame 1──▶  −1/4  ──frame 2──▶  +1/2
3895    /// frame ごとに測る:      1 件 (貫通)        1 件 (戻り)
3896    /// 2 frame を 1 区間として測る:         0 件  ← 本 test が pin する限界
3897    /// ```
3898    ///
3899    /// ⚠️ **これは辺-辺で新たに生じた弱点ではありません** 頂点-面の弦判定も同じ規約で、
3900    /// 出て戻った頂点は終端で弦がどの三角形も貫きません 区間を粗く取るほど見落とす
3901    /// 幅が広がるので、**呼び出し側は `step` 1 回ぶんの `start` を渡すこと**が使用条件です
3902    ///
3903    /// 実害が出るのは**修復が働かなかった時だけ**です 修復 (`resolve_self_contact_over_frame`)
3904    /// は終端の符号でなく検出段 (coplanarity 根) で発火するので、往復の最中に押し戻します
3905    ///
3906    /// # ⚠️⚠️ frame 単独の `1` を消さないこと — 共通モード故障を見る唯一の経路です
3907    ///
3908    /// 本 test が測る主題は「2 frame を 1 区間にすると `0`」ですが、⚠️ **その手前の
3909    /// 「frame 単独なら `1` ずつ数える」2 本の assert の方が、別の役目で load-bearing
3910    /// です** 共有述語 `edge_pair_frame_contact` が死ぬと修復と計器が同時に盲目になり、
3911    /// 貫通しているのに不変量が `0` を報告します ⇒ **非 `0` を assert するここだけが
3912    /// それを見ます**
3913    ///
3914    /// 実測 (2026-10-01、変異 `W3-predicate-always-none`): 統合 `analytic_self_contact.rs`
3915    /// は **15 passed / 0 failed** で素通りし、捕らえたのは本 test と
3916    /// `an_edge_pair_that_swaps_sides_in_one_frame_is_seen_by_the_crossings_invariant`
3917    /// の 2 本だけでした ⚠️ **`0` を assert する側だけ残して簡約しないこと**
3918    #[test]
3919    fn a_pair_that_crosses_and_returns_inside_one_interval_is_not_counted() {
3920        let half = Fix128::from_ratio(1, 2);
3921        let quarter = Fix128::from_ratio(1, 4);
3922        let dt = Fix128::from_ratio(1, 64);
3923        let speed = Fix128::from_int(48);
3924
3925        let mut cloth = x_crossing_scene(half, quarter);
3926        cloth.config.self_collision = false; // 修復を止めて計器の規約だけを見る
3927        let origin = cloth.positions.clone();
3928        assert_eq!(
3929            x_crossing_signed_gap(&cloth),
3930            half,
3931            "開始時の符号付き隙間が +1/2 でない"
3932        );
3933
3934        // frame 1: T1 を −3/4 動かして 2 辺を入れ替える
3935        for i in 3..6 {
3936            cloth.velocities[i] = Vec3Fix::new(Fix128::ZERO, -speed, Fix128::ZERO);
3937        }
3938        let start1 = cloth.positions.clone();
3939        cloth.step(dt);
3940        assert_eq!(
3941            x_crossing_signed_gap(&cloth),
3942            -quarter,
3943            "frame 1 で 2 辺が入れ替わっていない この scene は往復を試せていない"
3944        );
3945        assert_eq!(
3946            cloth.remaining_self_contact_crossings(&start1),
3947            1,
3948            "frame 1 を単独で測った時に貫通が数えられていない"
3949        );
3950
3951        // frame 2: 同じだけ戻す
3952        for i in 3..6 {
3953            cloth.velocities[i] = Vec3Fix::new(Fix128::ZERO, speed, Fix128::ZERO);
3954        }
3955        let start2 = cloth.positions.clone();
3956        cloth.step(dt);
3957        assert_eq!(
3958            x_crossing_signed_gap(&cloth),
3959            half,
3960            "frame 2 で元の隙間 +1/2 に戻っていない 往復が閉じていない"
3961        );
3962        assert_eq!(
3963            cloth.remaining_self_contact_crossings(&start2),
3964            1,
3965            "frame 2 を単独で測った時に戻りの交差が数えられていない"
3966        );
3967
3968        // ⚠️ 2 frame を 1 区間として測ると 0 — これが記録したい限界
3969        assert_eq!(
3970            cloth.remaining_self_contact_crossings(&origin),
3971            0,
3972            "往復を 1 区間として測った時に 0 以外が返った \
3973             終端の符号で数える規約が変わっている (doc § The invariant is stated at the end)"
3974        );
3975    }
3976
3977    /// 折り返して自己接触させた 5x5 の布 (頂点-面 / 辺-辺 の両方に候補が出る)
3978    fn folded_cloth_with_contacts() -> Cloth {
3979        const R: usize = 5;
3980        let mut cloth = Cloth::new_grid(
3981            Vec3Fix::ZERO,
3982            Fix128::from_int(4),
3983            Fix128::from_int(4),
3984            R,
3985            R,
3986            Fix128::from_ratio(1, 100),
3987        );
3988        cloth.config.self_collision = true;
3989        cloth.config.self_collision_distance = Fix128::from_ratio(3, 2);
3990        for x in 0..R {
3991            cloth.positions[3 * R + x] =
3992                Vec3Fix::new(cloth.positions[x].x, Fix128::ONE, Fix128::from_int(2));
3993            cloth.positions[4 * R + x] =
3994                Vec3Fix::new(cloth.positions[x].x, Fix128::ONE, Fix128::ONE);
3995        }
3996        cloth
3997    }
3998
3999    #[test]
4000    fn test_cloth_normals() {
4001        let cloth = Cloth::new_grid(
4002            Vec3Fix::ZERO,
4003            Fix128::from_int(2),
4004            Fix128::from_int(2),
4005            3,
4006            3,
4007            Fix128::from_ratio(1, 100),
4008        );
4009
4010        let normals = cloth.compute_normals();
4011        assert_eq!(normals.len(), 9);
4012
4013        // For a flat grid in XZ plane, normals should point in Y
4014        for n in &normals {
4015            let (_, ny, _) = n.to_f32();
4016            assert!(
4017                ny.abs() > 0.5,
4018                "Normals should point mostly in Y for flat grid"
4019            );
4020        }
4021    }
4022
4023    // ---- bending (dihedral) constraint, 1.1.1 ------------------------
4024
4025    /// 2 三角形 (0,1,2) / (1,0,3) が edge 0-1 を共有する最小 cloth、全 particle 質量 1
4026    fn two_triangle_cloth() -> Cloth {
4027        let mut cloth = Cloth::new_grid(
4028            Vec3Fix::ZERO,
4029            Fix128::ONE,
4030            Fix128::ONE,
4031            2,
4032            2,
4033            Fix128::from_ratio(1, 100),
4034        );
4035        assert_eq!(cloth.particle_count(), 4);
4036        assert_eq!(
4037            cloth.bend_constraints.len(),
4038            1,
4039            "2x2 grid = 2 triangles = 1 shared edge"
4040        );
4041        cloth.config.bend_compliance = Fix128::ZERO;
4042        cloth
4043    }
4044
4045    fn dihedral(cloth: &Cloth) -> Fix128 {
4046        let c = cloth.bend_constraints[0];
4047        let p0 = cloth.positions[c.i0];
4048        let e = cloth.positions[c.i1] - p0;
4049        let n1 = e.cross(cloth.positions[c.i2] - p0).normalize();
4050        let n2 = e.cross(cloth.positions[c.i3] - p0).normalize();
4051        Fix128::atan2(n1.cross(n2).length(), n1.dot(n2))
4052    }
4053
4054    #[test]
4055    fn bend_flat_rest_is_pi_and_flat_cloth_is_a_fixed_point() {
4056        let mut cloth = two_triangle_cloth();
4057        // 平面の隣接三角形: 法線は逆向き → rest = π (1.1.1 の atan2 修正で正確になった)
4058        let rest = cloth.bend_constraints[0].rest_angle;
4059        assert!(
4060            (rest - Fix128::PI).abs() < Fix128::from_ratio(1, 1_000_000),
4061            "rest {rest:?}"
4062        );
4063        let before = cloth.positions.clone();
4064        for _ in 0..50 {
4065            cloth.solve_bend_constraints(Fix128::from_ratio(1, 60));
4066        }
4067        // sin(π) = 0 → 補正 0、bit 単位で不動 (旧実装は 1 + cos ≥ 0 で押し続けた)
4068        assert_eq!(cloth.positions, before);
4069    }
4070
4071    #[test]
4072    fn bend_folded_cloth_relaxes_monotonically_toward_flat() {
4073        let mut cloth = two_triangle_cloth();
4074        let c = cloth.bend_constraints[0];
4075        // grid は x-z 平面なので法線方向 = y に持ち上げて折る
4076        cloth.positions[c.i3].y = Fix128::from_ratio(1, 2);
4077        let rest = cloth.bend_constraints[0].rest_angle;
4078        let mut prev_err = (dihedral(&cloth) - rest).abs();
4079        assert!(
4080            prev_err > Fix128::from_ratio(1, 10),
4081            "初期折れ角 {prev_err:?}"
4082        );
4083        for iter in 0..40 {
4084            cloth.solve_bend_constraints(Fix128::from_ratio(1, 60));
4085            let err = (dihedral(&cloth) - rest).abs();
4086            assert!(
4087                err <= prev_err,
4088                "iter {iter}: error grew {prev_err:?} -> {err:?}"
4089            );
4090            prev_err = err;
4091        }
4092        assert!(
4093            prev_err < Fix128::from_ratio(1, 100),
4094            "40 iter 後も {prev_err:?}"
4095        );
4096        // 位置が有限範囲に留まる (発散なし)
4097        for p in &cloth.positions {
4098            assert!(p.length() < Fix128::from_int(4), "{p:?}");
4099        }
4100    }
4101
4102    #[test]
4103    fn bend_pinned_vertices_do_not_move() {
4104        let mut cloth = two_triangle_cloth();
4105        let c = cloth.bend_constraints[0];
4106        cloth.inv_masses[c.i0] = Fix128::ZERO;
4107        cloth.inv_masses[c.i1] = Fix128::ZERO;
4108        cloth.inv_masses[c.i2] = Fix128::ZERO;
4109        cloth.positions[c.i3].y = Fix128::from_ratio(1, 2);
4110        let fixed = [
4111            cloth.positions[c.i0],
4112            cloth.positions[c.i1],
4113            cloth.positions[c.i2],
4114        ];
4115        let before_i3 = cloth.positions[c.i3];
4116        for _ in 0..10 {
4117            cloth.solve_bend_constraints(Fix128::from_ratio(1, 60));
4118        }
4119        assert_eq!(cloth.positions[c.i0], fixed[0]);
4120        assert_eq!(cloth.positions[c.i1], fixed[1]);
4121        assert_eq!(cloth.positions[c.i2], fixed[2]);
4122        assert!(cloth.positions[c.i3] != before_i3, "自由頂点だけが動く");
4123    }
4124
4125    #[test]
4126    fn drape_bottom_row_ends_below_pinned_top_and_above_free_fall() {
4127        // 上段 pin、120 step: 下段は下がる (drape) が、拘束があるので自由落下 (y = -g t²/2) より上
4128        let mut cloth = Cloth::new_grid(
4129            Vec3Fix::ZERO,
4130            Fix128::from_int(2),
4131            Fix128::from_int(2),
4132            5,
4133            5,
4134            Fix128::from_ratio(1, 100),
4135        );
4136        cloth.pin_top_row(5);
4137        let start_bottom = cloth.positions[20].y;
4138        let dt = Fix128::from_ratio(1, 60);
4139        for _ in 0..120 {
4140            cloth.step(dt);
4141        }
4142        let bottom = cloth.positions[20].y;
4143        assert!(
4144            bottom < start_bottom,
4145            "下がる: {start_bottom:?} -> {bottom:?}"
4146        );
4147        assert!(bottom < cloth.positions[0].y, "top より下");
4148        // 2 秒の自由落下 = -19.6 より上 (布は吊られている)
4149        assert!(bottom > Fix128::from_int(-20), "発散していない {bottom:?}");
4150        for p in &cloth.positions {
4151            assert!(p.length() < Fix128::from_int(30), "{p:?}");
4152        }
4153    }
4154
4155    /// 原点中心の単位球 SDF (f32 sqrt のみ、det_math gate 対象外)
4156    #[cfg(feature = "std")]
4157    fn unit_sphere_collider() -> crate::sdf_collider::SdfCollider {
4158        use crate::sdf_collider::{ClosureSdf, SdfCollider};
4159        let field = ClosureSdf::new(
4160            |x, y, z| (x * x + y * y + z * z).sqrt() - 1.0,
4161            |x, y, z| {
4162                let len = (x * x + y * y + z * z).sqrt();
4163                if len > 1e-6 {
4164                    (x / len, y / len, z / len)
4165                } else {
4166                    (0.0, 1.0, 0.0)
4167                }
4168            },
4169        );
4170        SdfCollider::new_static(
4171            Box::new(field),
4172            Vec3Fix::ZERO,
4173            crate::math::QuatFix::IDENTITY,
4174        )
4175    }
4176
4177    /// 単位球からの符号付き距離 (f32 oracle)
4178    #[cfg(feature = "std")]
4179    fn sphere_dist(p: Vec3Fix) -> f32 {
4180        let (x, y, z) = p.to_f32();
4181        (x * x + y * y + z * z).sqrt() - 1.0
4182    }
4183
4184    #[cfg(feature = "std")]
4185    #[test]
4186    fn step_with_sdf_drapes_cloth_over_unit_sphere_without_penetration() {
4187        // 2×2 の 5×5 grid を y = 2 に水平配置、中心 particle (index 12) が (0, 2, 0)
4188        let make = || {
4189            Cloth::new_grid(
4190                Vec3Fix::from_int(-1, 2, -1),
4191                Fix128::from_int(2),
4192                Fix128::from_int(2),
4193                5,
4194                5,
4195                Fix128::from_ratio(1, 100),
4196            )
4197        };
4198        let sphere = [unit_sphere_collider()];
4199        let mut cloth = make();
4200        let thickness = cloth.config.thickness.to_f32();
4201        let dt = Fix128::from_ratio(1, 60);
4202        let mut min_dist = f32::MAX;
4203        for frame in 0..60 {
4204            cloth.step_with_sdf(dt, &sphere);
4205            // 各 frame 終了時: 全 free particle は thickness 以上 球の外 (push-out は substep 末尾)
4206            for (i, p) in cloth.positions.iter().enumerate() {
4207                let d = sphere_dist(*p);
4208                min_dist = min_dist.min(d);
4209                assert!(
4210                    d >= thickness - 1e-3,
4211                    "frame {frame} particle {i} dist {d} < thickness {thickness}"
4212                );
4213            }
4214        }
4215        // 実際に接触している (自明に真ではない)
4216        assert!(
4217            min_dist < thickness + 0.05,
4218            "never touched: min dist {min_dist}"
4219        );
4220        // 中心 particle は球頂点 (y = 1 + thickness) 付近で静止
4221        let (cx, cy, cz) = cloth.positions[12].to_f32();
4222        assert!(
4223            (0.99..=1.1).contains(&cy),
4224            "center particle y {cy} (x {cx}, z {cz})"
4225        );
4226        assert!(
4227            cx.abs() < 0.1 && cz.abs() < 0.1,
4228            "center drifted: ({cx}, {cz})"
4229        );
4230
4231        // SDF なしなら同じ布は球を素通りして落下する (1 s、-10 m/s² → y ≈ -3)
4232        let mut free = make();
4233        for _ in 0..60 {
4234            free.step(dt);
4235        }
4236        let (_, fy, _) = free.positions[12].to_f32();
4237        assert!(
4238            fy < 0.0,
4239            "without SDF the cloth should fall through: y {fy}"
4240        );
4241
4242        // collider が空なら step と bit 一致
4243        let mut a = make();
4244        let mut b = make();
4245        for _ in 0..10 {
4246            a.step_with_sdf(dt, &[]);
4247            b.step(dt);
4248        }
4249        assert_eq!(a.positions, b.positions);
4250        assert_eq!(a.velocities, b.velocities);
4251    }
4252
4253    // ---- rest tether (closed-form checks; the full oracles are in
4254    // tests/analytic_cloth_rest_tether.rs) ----
4255
4256    fn lone_particle() -> Cloth {
4257        let mut c = Cloth::new_grid(
4258            Vec3Fix::ZERO,
4259            Fix128::ONE,
4260            Fix128::ONE,
4261            1,
4262            1,
4263            Fix128::from_int(2),
4264        );
4265        c.config.gravity = Vec3Fix::ZERO;
4266        c.config.damping = Fix128::ONE;
4267        c.config.substeps = 1;
4268        c
4269    }
4270
4271    /// `(sin x, cos x)` for `|x| <= π` by Taylor series (reference only).
4272    fn sin_cos_ref(x: f64) -> (f64, f64) {
4273        let (mut s, mut c, mut term) = (0.0_f64, 0.0_f64, 1.0_f64);
4274        for n in 0..40_u32 {
4275            match n % 4 {
4276                0 => c += term,
4277                1 => s += term,
4278                2 => c -= term,
4279                _ => s -= term,
4280            }
4281            term *= x / f64::from(n + 1);
4282        }
4283        (s, c)
4284    }
4285
4286    /// `(A, B, C, B')` of one exact step from the closed forms in `f64` (reference only):
4287    /// underdamped via sin / cos, critical, overdamped via the two rates.
4288    fn oscillator_ref(w2: f64, g: f64, h: f64) -> (f64, f64, f64, f64) {
4289        use crate::det_math::exp64;
4290        let al = g / 2.0;
4291        let disc = w2 - al * al;
4292        let (b, bd) = if disc.abs() < 1e-300 {
4293            (h * exp64(-al * h), exp64(-al * h) * (1.0 - al * h))
4294        } else if disc > 0.0 {
4295            let wd = disc.sqrt();
4296            let (s, c) = sin_cos_ref(wd * h);
4297            let e = exp64(-al * h);
4298            (e * s / wd, e * (c - al * s / wd))
4299        } else {
4300            let r = (-disc).sqrt();
4301            let (l1, l2) = (al - r, al + r);
4302            let (e1, e2) = (exp64(-l1 * h), exp64(-l2 * h));
4303            ((e1 - e2) / (l2 - l1), (l2 * e2 - l1 * e1) / (l2 - l1))
4304        };
4305        let a = bd + g * b;
4306        (a, b, (1.0 - a) / w2, bd)
4307    }
4308
4309    /// The coefficients of all three branches against the `f64` closed forms, including both
4310    /// sides of the branch boundaries `q = ±1/4` and the critical point: the `Fix128` step is
4311    /// at least as accurate as the `f64` reference (< 2e-15 relative to the coefficient's
4312    /// scale). Also `det [[A, B], [−ω²B, B']] = e^(−γh)` (Liouville) to 1e-17.
4313    #[test]
4314    fn oscillator_step_matches_the_closed_forms() {
4315        let h = Fix128::from_ratio(1, 60);
4316        let hf = 1.0 / 60.0;
4317        let mut checked = 0;
4318        // ω h ∈ {0.3, 0.9, 1.2, 2, 6}, ζ from undamped to strongly overdamped
4319        for wh in [3_i64, 9, 12, 20, 60] {
4320            for zeta_milli in [0_i64, 200, 420, 900, 960, 1000, 1040, 1200, 3000] {
4321                let w = Fix128::from_ratio(wh * 60, 10);
4322                let w2 = w * w;
4323                let g = Fix128::from_ratio(2 * zeta_milli, 1000) * w;
4324                let s = OscillatorStep::new(w2, g, h);
4325                let (a, b, c, bd) = oscillator_ref(w2.to_f64(), g.to_f64(), hf);
4326                let tol = 2e-15;
4327                assert!(
4328                    (s.pos_e.to_f64() - a).abs() < tol,
4329                    "A wh {wh} ζ {zeta_milli}"
4330                );
4331                assert!(
4332                    (s.pos_v.to_f64() - b).abs() < tol * hf,
4333                    "B wh {wh} ζ {zeta_milli}"
4334                );
4335                assert!(
4336                    (s.vel_v.to_f64() - bd).abs() < tol,
4337                    "B' wh {wh} ζ {zeta_milli}"
4338                );
4339                assert!(
4340                    (s.pos_a.to_f64() - c).abs() < tol * hf * hf,
4341                    "C wh {wh} ζ {zeta_milli}: {} vs {c}",
4342                    s.pos_a.to_f64()
4343                );
4344                assert_eq!(s.vel_a, s.pos_v);
4345                assert_eq!(s.vel_e, Fix128::ZERO - w2 * s.pos_v);
4346                let det = s.pos_e * s.vel_v - s.pos_v * s.vel_e;
4347                let want = crate::physics2d::exp_neg(g * h);
4348                assert!(
4349                    (det - want).abs().to_f64() < 1e-17,
4350                    "det wh {wh} ζ {zeta_milli}: {}",
4351                    (det - want).to_f64()
4352                );
4353                checked += 1;
4354            }
4355        }
4356        assert_eq!(checked, 45);
4357    }
4358
4359    /// Small `ω h` (the series branches of `C`) and `k = 0`: `C = h²/2 − γh³/6 + …` (Taylor to h⁵),
4360    /// `B = h − γh²/2 + …`; and the step tends to free flight as `k, c → 0`.
4361    #[test]
4362    fn oscillator_step_small_arguments() {
4363        // 4 units of 2⁻⁶⁴: the grid of Fix128 (h = 1/1000 itself is rounded to it)
4364        const ULP4: f64 = 2.2e-19;
4365        let h = Fix128::from_ratio(1, 1000);
4366        // ω² h² = 1e-6, γ h = 2e-4 (near critical branch, series for C)
4367        let s = OscillatorStep::new(Fix128::ONE, Fix128::from_ratio(1, 5), h);
4368        let (a, b, c, bd) = oscillator_ref(1.0, 0.2, 1e-3);
4369        assert!((s.pos_e.to_f64() - a).abs() < ULP4);
4370        assert!((s.pos_v.to_f64() - b).abs() < ULP4);
4371        assert!((s.vel_v.to_f64() - bd).abs() < ULP4);
4372        // the f64 reference's (1 − A)/ω² cancels here; compare with the series directly
4373        // Taylor of C to h⁵: b₂ = −γ/2, b₃ = (γ² − ω²)/6, b₄ = −(3γ b₃ + ω² b₂)/12
4374        let want_c = 1e-6 / 2.0 - 0.2 * 1e-9 / 6.0
4375            + (0.04 - 1.0) * 1e-12 / 24.0
4376            + (0.096 + 0.1) / 12.0 * 1e-15 / 5.0;
4377        assert!(
4378            (s.pos_a.to_f64() - want_c).abs() < ULP4,
4379            "{} vs {want_c} ({c})",
4380            s.pos_a.to_f64()
4381        );
4382        // overdamped with ω² h² < 1/16: φ₁ branch; k = 0 is the pure damper
4383        let g = Fix128::from_int(3000);
4384        let s = OscillatorStep::new(Fix128::ZERO, g, h);
4385        let e = crate::det_math::exp64(-3.0);
4386        assert!((s.pos_e.to_f64() - 1.0).abs() < ULP4);
4387        assert!((s.vel_v.to_f64() - e).abs() < ULP4);
4388        assert!((s.pos_v.to_f64() - (1.0 - e) / 3000.0).abs() < ULP4);
4389        let want_c = (1e-3 - (1.0 - e) / 3000.0) / 3000.0;
4390        assert!(
4391            (s.pos_a.to_f64() - want_c).abs() < ULP4,
4392            "{} vs {want_c}",
4393            s.pos_a.to_f64()
4394        );
4395        // φ₁ below and above its switch
4396        assert_eq!(phi1(Fix128::ZERO), Fix128::ONE);
4397        let x = Fix128::from_ratio(1, 2);
4398        assert!((phi1(x).to_f64() - (1.0 - crate::det_math::exp64(-0.5)) / 0.5).abs() < 1e-16);
4399        // free flight limit
4400        let s = OscillatorStep::new(Fix128::ZERO, Fix128::ZERO, h);
4401        assert_eq!(s.pos_e, Fix128::ONE);
4402        assert_eq!(s.vel_v, Fix128::ONE);
4403        assert!((s.pos_v - h).abs().to_f64() < ULP4);
4404        assert!((s.pos_a.to_f64() - 0.5e-6).abs() < ULP4);
4405    }
4406
4407    /// `sinc_cos` for large `q` (many doublings): cos √q against the reduced reference; the
4408    /// doublings cost about `4ᵐ` units of 2⁻⁶⁴ (`m = 15` here, θ = 2⁷·π·100).
4409    #[test]
4410    fn sinc_cos_large_argument() {
4411        for theta in [1.0_f64, 3.0, 40.0, 1000.0, 12_345.0] {
4412            let tf = Fix128::from_f64(theta);
4413            let (s, k) = sinc_cos(tf * tf);
4414            let tq = tf.to_f64();
4415            let r = tq.rem_euclid(2.0 * core::f64::consts::PI);
4416            let r = if r > core::f64::consts::PI {
4417                r - 2.0 * core::f64::consts::PI
4418            } else {
4419                r
4420            };
4421            let (sr, cr) = sin_cos_ref(r);
4422            let tol = 1e-15 + theta * 4e-16;
4423            assert!(
4424                (k.to_f64() - cr).abs() < tol,
4425                "θ {theta}: {} vs {cr}",
4426                k.to_f64()
4427            );
4428            assert!((s.to_f64() * tq - sr).abs() < tol, "θ {theta}");
4429        }
4430    }
4431
4432    #[test]
4433    fn rest_tether_one_substep_is_the_exact_oscillator() {
4434        // m = 2, ω = 6, ζ = 0.5, h = 1/60 (ω h = 0.1): from rest at e0 = 1,
4435        // e = e^(−ζωh) (cos ω_d h + ζω/ω_d sin ω_d h), ω_d = ω sqrt(3)/2
4436        let mut c = lone_particle();
4437        c.set_rest_tether(Fix128::from_int(72), Fix128::from_int(12));
4438        assert_eq!(
4439            c.rest_tether(),
4440            Some((Fix128::from_int(72), Fix128::from_int(12)))
4441        );
4442        assert_eq!(c.rest_positions(), &[Vec3Fix::ZERO]);
4443        c.positions[0] = Vec3Fix::from_int(1, 0, 0);
4444        c.step(Fix128::from_ratio(1, 60));
4445        let (zw, wd) = (0.05_f64, 0.05 * 3.0_f64.sqrt());
4446        let (s, co) = sin_cos_ref(wd);
4447        let d = crate::det_math::exp64(-zw);
4448        let want = d * (co + zw / wd * s);
4449        let want_v = -d * (36.0 / 60.0 / 60.0) / wd * s * 60.0;
4450        assert!((c.positions[0].x.to_f64() - want).abs() < 1e-15);
4451        assert!((c.velocities[0].x.to_f64() - want_v).abs() < 1e-13);
4452    }
4453
4454    #[test]
4455    fn rest_tether_explicit_rest_clear_and_rejects() {
4456        let mut c = lone_particle();
4457        let err = c.set_rest_positions(&[]).unwrap_err();
4458        assert_eq!(
4459            err,
4460            RestPositionsMismatch {
4461                expected: 1,
4462                got: 0
4463            }
4464        );
4465        c.set_rest_positions(&[Vec3Fix::from_int(0, 4, 0)]).unwrap();
4466        c.set_rest_tether(Fix128::from_int(-1), Fix128::from_int(-1));
4467        assert_eq!(c.rest_tether(), Some((Fix128::ZERO, Fix128::ZERO)));
4468        assert_eq!(c.rest_positions(), &[Vec3Fix::from_int(0, 4, 0)]);
4469        // critically damped and far stiffer than the step: lands on rest within the frame
4470        c.set_rest_tether(
4471            Fix128::from_int(1_000_000_000_000),
4472            Fix128::from_int(4_000_000),
4473        );
4474        c.step(Fix128::from_ratio(1, 60));
4475        assert!((c.positions[0].y.to_f64() - 4.0).abs() < 1e-12);
4476        c.clear_rest_tether();
4477        assert_eq!(c.rest_tether(), None);
4478        // a pinned particle is not pulled
4479        let mut p = lone_particle();
4480        p.pin(0);
4481        p.set_rest_positions(&[Vec3Fix::from_int(1, 1, 1)]).unwrap();
4482        p.set_rest_tether(Fix128::from_int(50), Fix128::ONE);
4483        p.step(Fix128::from_ratio(1, 60));
4484        assert_eq!(p.positions[0], Vec3Fix::ZERO);
4485        assert!(format!("{p:?}").contains("rest_tether"));
4486    }
4487}