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 ×[..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}