phyz_contact/convex.rs
1//! Convex soft-contact solve.
2//!
3//! Per timestep the contact impulses solve the strictly convex problem
4//!
5//! ```text
6//! minimize_f 1/2 f^T (A + R) f + f^T b subject to f_c in K_mu(c)
7//! ```
8//!
9//! where `A = J M^-1 J^T` is the Delassus operator (inverse inertia seen in
10//! contact space), `R > 0` is the regularizer that MuJoCo calls constraint
11//! impedance, `b` is the free (unconstrained) contact-space velocity plus the
12//! reference-acceleration bias, and `K_mu` is the Coulomb cone of
13//! [`crate::cone`].
14//!
15//! # Why this form
16//!
17//! Dropping strict complementarity in favour of a regularized convex program
18//! is what makes contact differentiable, and is why MuJoCo contact is soft
19//! (Todorov 2011, 2014). With `R > 0` the objective is *strongly* convex, so
20//! the solution is unique and single-valued in the parameters — there is
21//! exactly one thing to differentiate, and the implicit function theorem
22//! applies to it. An LCP's complementarity condition, by contrast, makes the
23//! solution piecewise-smooth over a combinatorial active set.
24//!
25//! # Method
26//!
27//! An **active-set Newton** solve, globalized by projected Gauss-Seidel.
28//!
29//! The PGS half is a sweep with a per-contact **staged Coulomb update** —
30//! normal impulse first, then the tangential impulse clamped into the friction
31//! disc that normal admits. It is cheap, monotone, and good at discovering
32//! *which* contacts carry load and which way they slide. It is also, on its
33//! own, hopeless on a redundant contact set, and for a structural reason:
34//! its rate is set by the conditioning of `A + R`, and where `A` is singular
35//! — eight coplanar contacts under one foot supply 24 impulse components to
36//! constrain 6 body DoF — all that is left in the null space is `R`, which on
37//! the tangential rows is deliberately tiny (see [`regularization_diag`]).
38//! A null-space impulse decays over `A_tt / R_t ~ 1e5` sweeps. Acceleration
39//! schemes move that constant; they do not move the exponent.
40//!
41//! The Newton half fixes the active set read off the current iterate, which
42//! makes the KKT conditions *linear*, and solves that `3n x 3n` system
43//! directly. Redundancy stops mattering: the null-space directions are
44//! resolved exactly rather than asymptotically, and because `b = J qd_free`
45//! lies in the range of `J` — orthogonal to `null(J^T)` — the minimizer has no
46//! null-space component to remove in the first place. Measured on a symmetric
47//! coplanar ring, this took thirty-two contacts from "never converges inside
48//! 1000 sweeps" to 18 iterations, the same as four.
49//!
50//! The two alternate, with each Newton proposal accepted only if a real PGS
51//! sweep from it reduces the residual. Convergence is still reported against
52//! a residual tolerance rather than an iteration count, and the residual still
53//! means exactly what it did — "a full staged sweep no longer moves anything"
54//! — because a sweep is what measures it. Running to tolerance is what makes
55//! the converged point a valid IFT anchor; differentiating a truncated iterate
56//! would differentiate the algorithm, not the physics.
57//!
58//! The linear system Newton solves is `gradient::kkt_matrix` (crate-private),
59//! and the regime is read with the same classifier the gradient uses. The
60//! gradient's [`crate::gradient::FixedPointSensitivity`] starts from that same
61//! matrix and adds the sliding slip-rotation rows (which the solver does not
62//! need — its sweeps re-derive the slip direction, while a derivative must
63//! linearize it). Sharing the base object is deliberate: this crate has twice
64//! shipped a solver and a gradient that disagreed about the system being
65//! solved, and both times the symptom was a confident wrong number rather
66//! than a failure.
67//!
68//! The design doc specifies a primal-dual interior-point SOCP for the final
69//! form, whose central-path parameter doubles as the gradient-smoothing knob.
70//! This module solves the *same convex problem* by a different inner method;
71//! `regularization` plays the smoothing role for now. Swapping the inner solver
72//! does not change the problem being solved, the KKT conditions, or the IFT
73//! derivation built on them.
74
75use crate::material::ContactMaterial;
76use phyz_math::Vec3;
77
78/// How much of the Delassus operator the sweep is allowed to see.
79///
80/// This is the one knob that separates the CPU and GPU instantiations of the
81/// contact model, and it exists so that the difference between them is a
82/// *named, measurable approximation* rather than two divergent codebases.
83///
84/// The problem being solved — the objective, the friction cone, the solref
85/// bias, the impedance regularizer, the staged Coulomb update — is identical
86/// under both settings. Only the operator changes.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum ContactCoupling {
89 /// The full `3n x 3n` Delassus operator: every contact sees the velocity
90 /// that every other contact's impulse induces on it, through the
91 /// articulated chain. This is the physically correct operator, and what
92 /// the CPU simulation path uses.
93 Full,
94 /// Only each contact's own `3x3` diagonal block. Contacts are blind to
95 /// one another within a sweep.
96 ///
97 /// # Why this mode exists
98 ///
99 /// The off-diagonal blocks `A_ck` are what make the assembled problem
100 /// *coupled*, and assembling them requires `M^-1 J^T` for every contact
101 /// row — an articulated-body solve per row, per step. On the GPU that is
102 /// the difference between a pass that reads each body's own spatial
103 /// inertia and a pass that runs `3n` backsubstitutions through the whole
104 /// kinematic tree, per environment, per step. The block-diagonal operator
105 /// is available for free: it is the isolated-body effective mass the
106 /// contact shader already computes.
107 ///
108 /// # What it costs
109 ///
110 /// The approximation is *not* uniform. It is exact when contacts share
111 /// neither a body nor a chain — two boxes far apart. It is worst for a
112 /// redundant manifold on one body (a foot's corner contacts all pushing
113 /// the same rigid plate) and for two contacts on opposite ends of a light
114 /// chain (two feet coupled through a floating base), where the true `A`
115 /// is strongly off-diagonal and the sweep, blind to it, over-assigns load
116 /// to every contact at once.
117 ///
118 /// Running the CPU solver in this mode is how that cost gets measured
119 /// without a GPU in the loop: `Full` versus `BlockDiagonal` in `f64` is
120 /// the *approximation*, and `BlockDiagonal` versus the GPU is the
121 /// *implementation gap*. Confounding those two is what made the earlier
122 /// GPU-vs-CPU numbers uninterpretable.
123 BlockDiagonal,
124 /// Every off-diagonal block between contacts that **share a body**, and
125 /// nothing across bodies.
126 ///
127 /// This is the GPU's operator. It is exact for a contact manifold on one
128 /// rigid body — a foot's four corners, a box's face — because those blocks
129 /// are `J_c M_i^-1 J_k^T` with `M_i` the single body's own spatial
130 /// inertia, which needs no articulated solve and no factorization: it is
131 /// the same isolated-body quantity the contact shader already forms for
132 /// the diagonal, evaluated at a second contact's lever arm.
133 ///
134 /// What it still drops is coupling *through the chain* — two feet talking
135 /// to each other via a floating base. That term genuinely does require
136 /// `M^-1 J^T` and is what the GPU does not pay for.
137 ///
138 /// Measured (`examples/contact_parity`), the split is lopsided in a way
139 /// that decided this design: on a single box landing on its face, going
140 /// from `Full` to `BlockDiagonal` moved the trajectory by up to 78 mm,
141 /// while `PerBody` is exact there by construction. Nearly all of the
142 /// block-diagonal error on a contact manifold is *within-body* coupling,
143 /// and within-body coupling is the part that is cheap.
144 PerBody,
145}
146
147/// Tuning for the contact solve.
148#[derive(Debug, Clone, Copy)]
149pub struct ContactSolverConfig {
150 /// Which Delassus operator the sweep iterates on. See [`ContactCoupling`].
151 pub coupling: ContactCoupling,
152 /// Allow the active-set Newton stage.
153 ///
154 /// Off in the GPU-equivalent preset: a dense `3n x 3n` factorization is
155 /// not something a contact shader can do, so leaving it on would put the
156 /// CPU reference out of the GPU's reach by construction.
157 pub newton: bool,
158 /// Diagonal regularization `R` added to the Delassus operator.
159 ///
160 /// Larger values soften contact: more penetration, but a better
161 /// conditioned solve and smoother sensitivities. Smaller values approach
162 /// rigid contact and sharpen both the physics and the gradients.
163 pub regularization: f64,
164 /// Residual tolerance for the fixed point (units of velocity).
165 pub tolerance: f64,
166 /// Iteration cap, counted in units of solver work: one PGS sweep or one
167 /// dense KKT solve each. Reaching it means the solve did *not* converge;
168 /// the result is still feasible but is not a valid point to differentiate.
169 ///
170 /// The solve may also stop short of the cap when the residual has stopped
171 /// falling fast enough to reach [`Self::tolerance`] within it. That is
172 /// reported identically — `converged: false` — because it means the same
173 /// thing: this is not a KKT point.
174 pub max_iterations: usize,
175 /// Successive over-relaxation factor in `(0, 2)`.
176 pub relaxation: f64,
177 /// Below this approach speed, restitution ramps smoothly to zero.
178 ///
179 /// Without it a resting body micro-bounces forever and a stack never
180 /// settles. The ramp is `smoothstep`, not a hard cutoff, so restitution
181 /// stays differentiable in both `e` and the approach speed.
182 pub restitution_threshold: f64,
183 /// Reproduce MuJoCo's regularization on the **tangential** rows as well as
184 /// the normal one. Off by default, and it should stay off outside tests.
185 ///
186 /// [`regularization_diag`] documents the divergence in full: MuJoCo applies
187 /// the impedance-derived `R = (1-d)/d * A_nn` to all three rows of a
188 /// contact frame, which on a friction row means a fraction `1-d` of the
189 /// slip velocity survives every step. This crate applies it to the normal
190 /// row only, so a block below the friction angle holds *exactly*.
191 ///
192 /// Turning this on re-introduces MuJoCo's steady-state creep —
193 /// `g sin(alpha) dt (1-d)/d`, roughly a millimetre per second on a
194 /// 25-degree slope at `dt = 2 ms` — for a block Coulomb's law says is
195 /// stuck. That is the whole reason it is not the default.
196 ///
197 /// It exists so MuJoCo trajectories remain usable as a cross-validation
198 /// oracle: a divergence in the tangential regularizer makes a step-by-step
199 /// comparison against MuJoCo meaningless, and without a way to match its
200 /// behaviour there is no way to tell "phyz is right and MuJoCo creeps"
201 /// apart from a real bug. `mujoco_creep_matches_the_analytic_rate` in
202 /// `tests/stabilization.rs` measures both modes side by side.
203 pub mujoco_compat: bool,
204}
205
206impl Default for ContactSolverConfig {
207 fn default() -> Self {
208 Self::simulation()
209 }
210}
211
212impl ContactSolverConfig {
213 /// Fidelity-biased preset: crisp contact, minimal penetration.
214 pub fn simulation() -> Self {
215 Self {
216 coupling: ContactCoupling::Full,
217 newton: true,
218 regularization: 1e-6,
219 tolerance: 1e-10,
220 // Generous, and it should stay generous, because it is now almost
221 // never approached. With the active-set Newton in the loop a
222 // coplanar ring converges in 18 iterations whether it has four
223 // contacts or thirty-two, and the worst measured case — a tipped
224 // foot sliding into a 2.5cm penetration at 6 m/s with dt = 5ms —
225 // gives up at 100 on the stagnation check rather than by running
226 // out of budget. The cap only costs anything when the solve is not
227 // going to converge, and that case now exits on its own.
228 //
229 // It used to be the binding constraint: pure Gauss-Seidel wanted
230 // ~180 sweeps for eight coplanar contacts, ~530 for sixteen, and
231 // more than 1000 for thirty-two, so raising the cap was the only
232 // thing keeping the gradient path from refusing points that were
233 // in fact fine.
234 max_iterations: 1000,
235 relaxation: 1.0,
236 restitution_threshold: 0.05,
237 mujoco_compat: false,
238 }
239 }
240
241 /// Gradient-biased preset: softer contact, better conditioned, smoother
242 /// sensitivities. Documented in `docs/design/differentiable-contact.md`
243 /// §2.5 — these differ on purpose, and a caller optimizing through the
244 /// simulator should validate the result under [`Self::simulation`].
245 pub fn gradients() -> Self {
246 Self {
247 coupling: ContactCoupling::Full,
248 newton: true,
249 regularization: 1e-3,
250 tolerance: 1e-12,
251 max_iterations: 2000,
252 relaxation: 1.0,
253 restitution_threshold: 0.05,
254 mujoco_compat: false,
255 }
256 }
257
258 /// [`Self::simulation`] with [`Self::mujoco_compat`] set — a test-only
259 /// oracle preset for cross-validating trajectories against MuJoCo.
260 ///
261 /// Sited alongside [`Self::gradients`] because it is the same kind of
262 /// thing: a named point in config space that trades one property away on
263 /// purpose. `gradients` trades fidelity for smoothness; this one trades
264 /// exact stiction for bit-comparability with a reference implementation.
265 /// Unlike `gradients`, it is not a preset anyone should ship with — see
266 /// the field docs for the creep it buys back.
267 pub fn mujoco_compat() -> Self {
268 Self {
269 mujoco_compat: true,
270 ..Self::simulation()
271 }
272 }
273
274 /// The exact problem, operator and schedule the GPU contact pass runs.
275 ///
276 /// This is the CPU's *model* of the GPU: same convex problem, same staged
277 /// Coulomb update, same solref bias and impedance regularizer, but the
278 /// block-diagonal Delassus operator, no Newton stage, and a fixed sweep
279 /// budget instead of a residual tolerance. Nothing here is a different
280 /// contact model — it is the same model under a documented restriction.
281 ///
282 /// It is the referee for GPU parity. A gap between this and the GPU is an
283 /// implementation bug; a gap between this and [`Self::simulation`] is the
284 /// price of the restriction. The two must be measured separately or
285 /// neither number means anything.
286 ///
287 /// `tolerance` is zero so the sweep budget always runs to completion: the
288 /// shader has no early exit (a workgroup cannot cheaply agree that every
289 /// contact has converged), so an early-exiting reference would drift from
290 /// it on exactly the easy steps where the GPU keeps sweeping. `converged`
291 /// is therefore `false` here by construction, which is honest — a
292 /// fixed-budget iterate is not a KKT point and must not anchor an IFT
293 /// gradient.
294 pub fn gpu_equivalent() -> Self {
295 Self {
296 coupling: ContactCoupling::BlockDiagonal,
297 newton: false,
298 tolerance: 0.0,
299 max_iterations: GPU_SWEEPS,
300 ..Self::simulation()
301 }
302 }
303}
304
305/// Sweeps the GPU contact pass performs per step, and therefore the budget
306/// [`ContactSolverConfig::gpu_equivalent`] matches.
307///
308/// The shader runs a fixed count with no early exit, so this number is part of
309/// the model rather than a tuning detail: change it in one place and the other
310/// stops being a reference for it.
311pub const GPU_SWEEPS: usize = 16;
312
313/// One contact's entry in the convex problem.
314#[derive(Debug, Clone, Copy)]
315pub struct ContactRow {
316 /// Coefficient of friction.
317 pub mu: f64,
318 /// Coefficient of restitution, already effective (threshold applied).
319 pub restitution: f64,
320 /// Penetration depth (positive = overlapping).
321 pub depth: f64,
322 /// Target *separating* normal velocity, in m/s, from the solref reference
323 /// response (see [`crate::material::SolRef::error_reduction`]).
324 ///
325 /// This is the position-stabilization term. The normal solve drives the
326 /// post-step normal velocity to `bias` rather than to zero, so a contact
327 /// that is `d` deep is pushed apart at `d * erp / dt` and the penetration
328 /// is actually repaid. With `bias = 0` — the default, and what every
329 /// hand-built problem in the tests uses — the solve is exactly the
330 /// non-penetration constraint it always was, and a stack creeps down by
331 /// one integration error per step forever.
332 ///
333 /// It is kept out of `free_velocity` on purpose: `b = J qd_free` stays a
334 /// pure function of the state, and the bias enters as its own parameter,
335 /// so `df/db` from [`crate::gradient`] needs no reinterpretation and
336 /// `df/dbias = -df/db` falls out for free.
337 pub bias: f64,
338 /// Constraint impedance `d` in `(0, 1]` from solimp at this contact's
339 /// depth. The diagonal regularizer for the contact is
340 /// `R = (1-d)/d * diag(A)`, floored by
341 /// [`ContactSolverConfig::regularization`]. `1` means "as rigid as the
342 /// config floor allows".
343 pub impedance: f64,
344 /// `d(impedance)/d(depth)` at this contact's depth, from
345 /// [`ContactMaterial::dimpedance_ddepth`].
346 ///
347 /// Carried on the row rather than recomputed because the material is only
348 /// in scope at assembly time, and because [`crate::gradient`] must
349 /// differentiate *the impedance this row was actually built with*. It is
350 /// the derivative of the same `impedance` field directly above it; the two
351 /// are set together by [`Self::from_material`] and should never be set
352 /// apart.
353 ///
354 /// Zero on a hand-built row, which is the right default: a row whose
355 /// impedance was pinned by hand does not vary with depth.
356 pub dimpedance_ddepth: f64,
357 /// How much of an impact this contact is, in `[0, 1]`: the restitution
358 /// ramp's `smoothstep` of the approach speed, on its own.
359 ///
360 /// Soft contact is a resting-contact model. Its impedance `d` lets a
361 /// fraction `1 - d` of the approach velocity through each step, its
362 /// margin band tapers `d` towards zero over the last millimetre, and its
363 /// stabilization bias repays penetration with a small separating push.
364 /// All three are right for a foot bearing weight and wrong for a ball
365 /// arriving at five metres a second: the target `-e * v_n` is delivered
366 /// scaled by `d` (0.95 on the penetrating side, as low as you like inside
367 /// the margin, so an impact that happens to be detected 0.5 mm above the
368 /// floor loses most of its bounce), and the bias adds `erp` to the
369 /// effective coefficient of restitution on top. Measured, phyz delivered
370 /// 77 % of nominal `e` on a hard impact and swallowed a 3.5 m/s one whole.
371 ///
372 /// So an impacting row is rigid — to a part in a thousand,
373 /// [`IMPACT_IMPEDANCE`], not exactly: a box landing flat has four
374 /// impacting corners and a rank-deficient Delassus block, and with the
375 /// regularizer down at the config floor that solve amplifies roundoff by
376 /// a million and the determinism calibration in `phyz/tests/determinism.rs`
377 /// stops holding. At `0.999` the rebound is short by a tenth of a percent
378 /// of `1 + e` and the manifold stays conditioned — and its bias is zero:
379 /// the rebound itself clears the penetration. This weight blends between
380 /// the two on the same `smoothstep` as
381 /// [`ContactProblem::effective_restitution`], so a settled body (`0`) is
382 /// exactly the contact it always was and the transition is `C^1` in the
383 /// approach speed. `0` on a hand-built row.
384 pub impact: f64,
385}
386
387/// The impedance an impacting row is driven to; see [`ContactRow::impact`].
388pub const IMPACT_IMPEDANCE: f64 = 0.999;
389
390impl ContactRow {
391 /// Build a row from a (already pair-combined) material.
392 ///
393 /// `depth` is the penetration in metres (negative or zero means the
394 /// surfaces are not overlapping, and no stabilization is applied),
395 /// `dt` the step the impulses will be applied over, and `restitution` the
396 /// effective restitution after the low-speed ramp.
397 ///
398 /// The bias is `d * erp * depth / dt`: `erp` is the fraction of the
399 /// violation the reference spring removes in one step, dividing by `dt`
400 /// turns that displacement into the separating velocity the impulse solve
401 /// speaks in, and scaling by the impedance `d` is what makes a
402 /// just-touching contact push back gently instead of snapping. Since
403 /// `erp <= 1` and `d < 1`, the bias can never exceed `depth/dt`, i.e. the
404 /// solve can never push the bodies further apart than they overlap — the
405 /// failure mode that makes naive Baumgarte stabilization explode.
406 pub fn from_material(
407 material: &ContactMaterial,
408 depth: f64,
409 dt: f64,
410 restitution: f64,
411 ) -> Self {
412 let violation = depth.max(0.0);
413 // `impedance_at` is `solimp.impedance` exactly on the penetrating side
414 // and tapers to zero across `material.margin` below it, so a contact
415 // detected within the margin sheds its force continuously instead of
416 // being cut off while still carrying load. See
417 // [`ContactMaterial::impedance_at`].
418 let d = material.impedance_at(depth);
419 let bias = if dt > 0.0 {
420 d * material.solref.error_reduction(dt) * violation / dt
421 } else {
422 0.0
423 };
424 Self {
425 mu: material.friction,
426 restitution,
427 depth,
428 bias,
429 impedance: d,
430 dimpedance_ddepth: material.dimpedance_ddepth(depth),
431 impact: 0.0,
432 }
433 }
434
435 /// Mark this row as `impact` of an impact (see [`Self::impact`]): the
436 /// stabilization bias is scaled down by `1 - impact`, and the regularizer
437 /// reads the field through [`Self::effective_impedance`].
438 pub fn with_impact(mut self, impact: f64) -> Self {
439 let impact = impact.clamp(0.0, 1.0);
440 self.bias *= 1.0 - impact;
441 self.impact = impact;
442 self
443 }
444
445 /// The impedance the regularizer sees: `d` for a resting row,
446 /// [`IMPACT_IMPEDANCE`] for an impacting one, and the linear blend between.
447 pub fn effective_impedance(&self) -> f64 {
448 self.impedance + self.impact * (IMPACT_IMPEDANCE - self.impedance).max(0.0)
449 }
450}
451
452impl Default for ContactRow {
453 fn default() -> Self {
454 Self {
455 mu: 0.0,
456 restitution: 0.0,
457 depth: 0.0,
458 bias: 0.0,
459 // Fully rigid: the regularizer falls back to the config floor.
460 impedance: 1.0,
461 dimpedance_ddepth: 0.0,
462 impact: 0.0,
463 }
464 }
465}
466
467/// The assembled convex contact problem.
468///
469/// `delassus` is row-major `3n x 3n`, ordered `[n, u, w]` per contact.
470#[derive(Debug, Clone)]
471pub struct ContactProblem {
472 /// Number of contacts.
473 pub n: usize,
474 /// `A = J M^-1 J^T`, row-major `3n x 3n`.
475 pub delassus: Vec<f64>,
476 /// Free contact-space velocity `b`, length `3n`.
477 pub free_velocity: Vec<f64>,
478 /// Per-contact parameters.
479 pub rows: Vec<ContactRow>,
480 /// The body pair each contact acts between, `(body_i, body_j)`, with
481 /// `usize::MAX` for the static world — the same sentinel
482 /// [`phyz_collision::Collision`] uses.
483 ///
484 /// Only [`ContactCoupling::PerBody`] reads this; it is what decides
485 /// whether two contacts share a body and therefore whether their
486 /// off-diagonal block is one the GPU can afford.
487 ///
488 /// A map whose length is not `n` means "body structure unknown", and
489 /// `PerBody` then degrades to [`ContactCoupling::Full`] rather than
490 /// dropping blocks it cannot justify dropping. Failing toward *more*
491 /// coupling is the safe direction: it costs time, where the other
492 /// direction silently changes the physics. Hand-built problems in the
493 /// tests take this path.
494 pub bodies: Vec<(usize, usize)>,
495}
496
497/// The outcome of a contact solve.
498#[derive(Debug, Clone)]
499pub struct ContactSolution {
500 /// Impulse per contact in its own frame, `[normal, u, w]`.
501 pub impulses: Vec<Vec3>,
502 /// Iterations actually used.
503 pub iterations: usize,
504 /// Final residual (max over contacts of the fixed-point movement).
505 pub residual: f64,
506 /// Whether the residual reached `config.tolerance`.
507 ///
508 /// A `false` here means the impulses are feasible but the point is not a
509 /// converged KKT point, so it must not be used as an IFT anchor.
510 pub converged: bool,
511}
512
513impl ContactProblem {
514 /// Effective restitution after the smooth low-speed ramp.
515 ///
516 /// `smoothstep` between `v_rest` and `2*v_rest` keeps this `C^1` in the
517 /// approach speed; a hard `if |v_n| < eps { 0 }` would not be, and a
518 /// gradient through it would be wrong exactly at rest.
519 pub fn effective_restitution(e: f64, approach_speed: f64, v_rest: f64) -> f64 {
520 if v_rest <= 0.0 {
521 return e;
522 }
523 let s = approach_speed.abs();
524 if s <= v_rest {
525 return 0.0;
526 }
527 if s >= 2.0 * v_rest {
528 return e;
529 }
530 let t = (s - v_rest) / v_rest;
531 e * t * t * (3.0 - 2.0 * t)
532 }
533
534 /// The restitution ramp alone: how much of an impact an approach at this
535 /// speed is, `0` at and below `v_rest`, `1` at and above `2 * v_rest`.
536 /// This is what [`ContactRow::impact`] carries.
537 pub fn impact_weight(approach_speed: f64, v_rest: f64) -> f64 {
538 Self::effective_restitution(1.0, approach_speed, v_rest)
539 }
540}
541
542/// Diagonal regularizer `R` for contact `c`, one entry per row of its frame.
543///
544/// On the **normal** row, `R = (1-d)/d * A_nn` — MuJoCo's impedance form.
545/// Scaling by the Delassus diagonal is what makes it dimensionally consistent
546/// and mass-independent, so the same `solimp` behaves the same on a gram and
547/// on a tonne. A flat scalar (what `ContactSolverConfig::regularization` used
548/// to be on its own) does not have that property: it softens light bodies into
549/// mush and leaves heavy ones rigid.
550///
551/// On the **tangential** rows it is only the config floor, and that departure
552/// from MuJoCo is deliberate. Impedance `d` means "cancel a fraction `d` of
553/// the violation", and on a friction row the violation is the slip velocity,
554/// so `d = 0.9` leaves a tenth of the slip in place *every step*. That is a
555/// steady-state creep of `g sin(alpha) dt (1-d)/d` — about a millimetre per
556/// second on a 25-degree slope at 2 ms, forever, for a block that Coulomb's
557/// law says is stuck. Stiction is not a soft constraint that a reference
558/// response has to reach gradually; it either holds exactly or the contact is
559/// sliding, and the staged solve already decides which. MuJoCo pays this creep
560/// (it is a known characteristic of its solver); this crate's analytic
561/// benchmarks assert exact stiction, so it does not.
562///
563/// The config value is a floor on every row, which is what the
564/// [`ContactSolverConfig::gradients`] preset uses to buy extra smoothing.
565///
566/// [`ContactSolverConfig::mujoco_compat`] opts back into MuJoCo's form on the
567/// tangential rows, creep and all, for trajectory cross-validation. It is off
568/// by default and the paragraph above is why.
569pub fn regularization_diag(
570 problem: &ContactProblem,
571 c: usize,
572 config: &ContactSolverConfig,
573) -> [f64; 3] {
574 let dim = 3 * problem.n;
575 let base = 3 * c;
576 let d = problem.rows[c].effective_impedance().clamp(1e-6, 1.0);
577 let scale = (1.0 - d) / d;
578 let a_nn = problem.delassus[base * dim + base];
579 let normal = (scale * a_nn).max(config.regularization);
580 let tangent = if config.mujoco_compat {
581 normal
582 } else {
583 config.regularization
584 };
585 [normal, tangent, tangent]
586}
587
588/// `d(R)/d(depth)` for contact `c`, one entry per row of its frame.
589///
590/// The exact derivative of [`regularization_diag`], and it must stay exact
591/// against *that function* rather than against the formula in its docs — every
592/// clamp and floor in the regularizer is a place where this channel is shut
593/// off, and a derivative that ignored them would be reporting motion in a
594/// quantity that is pinned:
595///
596/// - The impedance is clamped to `[1e-6, 1]` before use. Outside that range `R`
597/// does not respond to `d` at all, so the derivative is zero.
598/// - The normal entry is floored at `config.regularization`. Where the floor
599/// binds, `R` is constant and the derivative is zero.
600/// - The tangential entries are the config floor unless
601/// [`ContactSolverConfig::mujoco_compat`], so they are zero unless that flag
602/// puts the impedance-derived value on them too.
603///
604/// Inside the live region `R = (1-d)/d * A_nn`, so `dR/dd = -A_nn/d^2` and the
605/// chain rule closes with `dd/ddepth` from the row.
606///
607/// This exists because [`crate::gradient::depth_sensitivity`] needs it: with a
608/// contact margin, a separated-but-detected contact has zero stabilization
609/// bias, which makes the regularizer the *only* route from depth to force.
610pub fn regularization_depth_derivative(
611 problem: &ContactProblem,
612 c: usize,
613 config: &ContactSolverConfig,
614) -> [f64; 3] {
615 let dim = 3 * problem.n;
616 let base = 3 * c;
617 let row = &problem.rows[c];
618 let d_raw = row.effective_impedance();
619 let d = d_raw.clamp(1e-6, 1.0);
620 // Where the clamp binds, `R` is frozen with respect to the impedance.
621 // An impacting row's effective impedance moves with depth only through
622 // its resting share, `(1 - impact) * d'`.
623 let dd = if d_raw > 1e-6 && d_raw < 1.0 {
624 (1.0 - row.impact) * row.dimpedance_ddepth
625 } else {
626 0.0
627 };
628 let a_nn = problem.delassus[base * dim + base];
629 let normal = if (1.0 - d) / d * a_nn > config.regularization {
630 -a_nn * dd / (d * d)
631 } else {
632 // The floor binds: `R` is the constant `config.regularization`.
633 0.0
634 };
635 let tangent = if config.mujoco_compat { normal } else { 0.0 };
636 [normal, tangent, tangent]
637}
638
639/// Solve the convex contact problem from a cold start (zero impulses).
640///
641/// [`solve_contacts_warm`] is the same solve seeded with a previous step's
642/// impulses; prefer it inside a stepper.
643// The loop indices here drive stride arithmetic into flat, row-major arrays
644// (base = 3*c, k*dim + ...). Iterator form would hide the linear algebra, so
645// the explicit ranges stay.
646#[allow(clippy::needless_range_loop)]
647pub fn solve_contacts(problem: &ContactProblem, config: &ContactSolverConfig) -> ContactSolution {
648 solve_contacts_warm(problem, config, &[])
649}
650
651/// Solve the convex contact problem, seeded with `initial`.
652///
653/// Warm starting is worth a module-level comment because it is not merely an
654/// optimization here. The stance contacts of a standing or walking body are
655/// solving *almost the same problem* every step, and the seed carries both the
656/// impulses and — more valuably — the active set, which is the input Newton
657/// cannot derive for itself. Seeded with last step's impulses a resting
658/// manifold terminates in two sweeps without ever building a KKT matrix.
659/// Since the solver only reports `converged` when it reaches
660/// `config.tolerance` — and [`crate::gradient`] refuses to differentiate
661/// anything else — cheaper convergence is directly more usable gradients.
662///
663/// The seed cannot change the answer: the problem is strongly convex, so the
664/// minimizer is unique and every start converges to it. `initial` may be
665/// shorter than `problem.n` (or empty, for a cold start); missing entries
666/// start at zero. Entries outside the friction cone are harmless — the first
667/// sweep projects them back.
668// The loop indices here drive stride arithmetic into flat, row-major arrays
669// (base = 3*c, k*dim + ...). Iterator form would hide the linear algebra, so
670// the explicit ranges stay.
671#[allow(clippy::needless_range_loop)]
672pub fn solve_contacts_warm(
673 problem: &ContactProblem,
674 config: &ContactSolverConfig,
675 initial: &[Vec3],
676) -> ContactSolution {
677 let n = problem.n;
678 let mut f = vec![Vec3::zeros(); n];
679 for (slot, seed) in f.iter_mut().zip(initial) {
680 *slot = *seed;
681 }
682 if n == 0 {
683 return ContactSolution {
684 impulses: f,
685 iterations: 0,
686 residual: 0.0,
687 converged: true,
688 };
689 }
690
691 let dim = 3 * n;
692 debug_assert_eq!(problem.delassus.len(), dim * dim);
693 debug_assert_eq!(problem.free_velocity.len(), dim);
694
695 let a = &problem.delassus;
696 let at = |i: usize, j: usize| a[i * dim + j];
697
698 // Per-contact 3x3 diagonal block, regularized and inverted once.
699 let blocks: Vec<[[f64; 3]; 3]> = (0..n)
700 .map(|c| {
701 let base = 3 * c;
702 let reg = regularization_diag(problem, c, config);
703 let mut m = [[0.0; 3]; 3];
704 for (r, row) in m.iter_mut().enumerate() {
705 for (col, e) in row.iter_mut().enumerate() {
706 *e = at(base + r, base + col);
707 if r == col {
708 *e += reg[r];
709 }
710 }
711 }
712 m
713 })
714 .collect();
715
716 let mut iterations = 0;
717
718 // ---------------------------------------------------------------- stage 1
719 //
720 // Projected Gauss-Seidel warm-up. Two things come out of it that the
721 // Newton stage below cannot get for itself: a feasible iterate, and an
722 // *active set* — which contacts carry load, which slide, and in which
723 // direction. Newton takes the active set as given and solves the resulting
724 // linear system exactly; PGS is what discovers it, and it is good at that
725 // even where it is hopeless at the last digits.
726 //
727 // Phase 1a holds the tangential impulses fixed and equilibrates the
728 // normals alone; phase 1b is the full staged sweep. This is a
729 // solver-internal schedule, not a change of problem — the same KKT system
730 // is solved either way.
731 //
732 // It exists because of what redundancy does to the *transient*. On a
733 // rank-deficient manifold — eight coplanar contacts under one foot, say —
734 // the first sweeps hand almost the whole load to whichever contact is
735 // swept first, which tips the body, which is real tangential velocity, so
736 // friction engages hard. By the time the normals have levelled out that
737 // slip is gone, but the friction impulses it created live in the null
738 // space of `J^T`: they cancel in the net wrench, so `A` exerts no
739 // restoring force on them at all. Equilibrating the normals first means
740 // the slip that seeds them never happens, which leaves the active set
741 // handed to Newton clean. Tangential impulses are held, not zeroed, so a
742 // warm start keeps its friction seed.
743 let normal_warmup = WARMUP_SWEEPS.min(config.max_iterations);
744 for _ in 0..normal_warmup {
745 iterations += 1;
746 if sweep(problem, config, &blocks, &mut f, true) < config.tolerance {
747 break;
748 }
749 }
750
751 let mut residual = f64::INFINITY;
752 while iterations < config.max_iterations.min(2 * WARMUP_SWEEPS) {
753 iterations += 1;
754 residual = sweep(problem, config, &blocks, &mut f, false);
755 if residual < config.tolerance {
756 return ContactSolution {
757 impulses: f,
758 iterations,
759 residual,
760 converged: true,
761 };
762 }
763 }
764
765 // ---------------------------------------------------------------- stage 2
766 //
767 // Active-set Newton. This is the part that makes a redundant contact set
768 // tractable, and the reason is structural rather than a matter of tuning.
769 //
770 // PGS iterates on `A + R`, so its rate is set by that operator's
771 // conditioning. On a redundant manifold `A` is singular — thirty-two
772 // coplanar contacts supply 96 impulse components to constrain 6 body DoF —
773 // and in the null space the only thing left is `R`, which on the
774 // tangential rows is deliberately tiny (`regularization_diag` explains at
775 // length why it has to stay tiny for stiction to be exact). A null-space
776 // impulse therefore decays with a time constant of `A_tt / R_t ~ 1e5`
777 // sweeps. That is not a mode you accelerate your way out of: over-
778 // relaxation, Chebyshev and momentum all move the constant, not the
779 // exponent.
780 //
781 // A direct solve does not care. Given the active set, the KKT system is
782 // *linear*, `3n x 3n`, and dense-factorizable in `O(n^3)` — 96 unknowns
783 // for the thirty-two-contact case, which is nothing next to a thousand
784 // sweeps of an `O(n^2)` kernel. The null-space directions are handled
785 // exactly instead of asymptotically, and since `b = J qd_free` lies in the
786 // range of `J`, which is orthogonal to `null(J^T)`, the minimizer's
787 // null-space component is zero: the direct solve simply returns it.
788 //
789 // The matrix is [`crate::gradient::kkt_matrix`] — the same function
790 // [`crate::gradient::impulse_sensitivity`] differentiates through. Solving
791 // and differentiating the same object is the invariant that keeps the two
792 // from drifting apart.
793 //
794 // Newton alternates with blocks of PGS rather than running to exhaustion.
795 // The two failure modes are complementary: PGS is slow when the *linear
796 // algebra* is ill-conditioned (redundancy), Newton is unreliable when the
797 // *active set* is wrong (a sliding contact whose slip direction is still
798 // rotating during an impact, where the semi-smooth system's linearization
799 // point moves under it). A block of sweeps between attempts re-establishes
800 // the slip directions cheaply and hands Newton a better linearization
801 // point, and a damped step keeps a bad proposal from undoing progress.
802 let mut newton_solves = 0;
803 let mut stalls = 0;
804 while iterations < config.max_iterations {
805 let entry_residual = residual;
806 if config.newton
807 && newton_solves < NEWTON_ATTEMPTS
808 && let Some(candidate) = newton_step(problem, config, &f)
809 {
810 newton_solves += 1;
811 iterations += 1;
812 // A Newton iterate is only a *proposal*: the active set it was
813 // built from may be wrong, and nothing in the linear solve
814 // enforces the cone. Verifying it with a real PGS sweep costs one
815 // iteration and settles both questions at once — the sweep
816 // re-projects into the cone, and the movement it reports is the
817 // same residual the pure-PGS solver reported, so `converged` keeps
818 // its exact former meaning: "a full staged sweep no longer moves
819 // anything".
820 //
821 // Backtracking on that residual is the globalization. The friction
822 // cone is convex, so every point on the segment from `f` to the
823 // proposal is feasible and the halved steps need no re-projection;
824 // and because the accepted iterate is the *swept* one, a rejected
825 // Newton step costs sweeps, never progress.
826 let mut alpha = 1.0;
827 for _ in 0..LINE_SEARCH_STEPS {
828 let mut trial: Vec<Vec3> = f
829 .iter()
830 .zip(&candidate)
831 .map(|(cur, cand)| *cur + (*cand - *cur) * alpha)
832 .collect();
833 iterations += 1;
834 let trial_residual = sweep(problem, config, &blocks, &mut trial, false);
835 if trial_residual < residual {
836 residual = trial_residual;
837 f = trial;
838 break;
839 }
840 alpha *= 0.5;
841 }
842 if residual < config.tolerance {
843 break;
844 }
845 }
846
847 // PGS block: monotone coordinate descent on a strongly convex
848 // objective, so this always makes progress even where Newton will not.
849 // It is also what refines the active set for the next attempt.
850 for _ in 0..PGS_BLOCK {
851 if iterations >= config.max_iterations {
852 break;
853 }
854 iterations += 1;
855 residual = sweep(problem, config, &blocks, &mut f, false);
856 if residual < config.tolerance {
857 break;
858 }
859 }
860 if residual < config.tolerance {
861 break;
862 }
863
864 // Stagnation exit. A block that removed less than `STALL_RATIO` of the
865 // residual is on a linear rate that cannot reach the tolerance inside
866 // the cap — `0.99` per block over the whole remaining budget is barely
867 // a factor of two — so the sweeps that follow are pure cost. This
868 // matters for throughput rather than correctness: a batched trainer's
869 // step time is set by its *worst* contact solve, and burning the full
870 // cap on a solve that was never going to converge is the difference
871 // between a bounded step and a stall.
872 //
873 // The result is reported honestly as `converged: false`, exactly as an
874 // exhausted cap would be, and [`crate::gradient`] refuses it on the
875 // same grounds. Nothing downstream can mistake an early exit for
876 // success.
877 if residual > entry_residual * STALL_RATIO {
878 stalls += 1;
879 if stalls >= STALL_BLOCKS {
880 break;
881 }
882 } else {
883 stalls = 0;
884 }
885 }
886
887 ContactSolution {
888 impulses: f,
889 iterations,
890 residual,
891 converged: residual < config.tolerance,
892 }
893}
894
895/// PGS sweeps spent establishing the active set before Newton takes over.
896///
897/// Small on purpose. Its job is to decide which contacts are loaded and which
898/// way they slide, not to converge; a determined contact set converges inside
899/// it anyway and never pays for the factorization.
900const WARMUP_SWEEPS: usize = 8;
901
902/// Cap on active-set changes. Each one is a dense `3n x 3n` solve, and an
903/// active set that has not settled in this many revisions is oscillating
904/// between two nearly-degenerate assignments — a case for the PGS fallback,
905/// not for more Newton steps.
906const NEWTON_ATTEMPTS: usize = 24;
907
908/// Backtracking steps allowed per Newton proposal, halving each time.
909const LINE_SEARCH_STEPS: usize = 3;
910
911/// PGS sweeps between Newton attempts. Enough to move the slip directions to
912/// where the next linearization wants them, few enough that a problem Newton
913/// can finish does not pay for a long tail of sweeps first.
914const PGS_BLOCK: usize = 4;
915
916/// Residual reduction a Newton-plus-PGS block must achieve to count as
917/// progress. Anything above this is a linear rate too slow to reach tolerance
918/// inside any sane iteration cap.
919const STALL_RATIO: f64 = 0.99;
920
921/// Consecutive stalled blocks before the solve gives up and reports
922/// `converged: false`. More than one so a single unlucky block — a Newton
923/// proposal rejected by the line search, say — does not end the solve.
924const STALL_BLOCKS: usize = 3;
925
926/// One projected Gauss-Seidel sweep with the staged Coulomb update.
927///
928/// Returns the largest per-contact movement, which is the fixed-point residual
929/// the solve terminates on. With `normals_only`, the tangential impulses are
930/// held at their current values rather than re-solved.
931#[allow(clippy::needless_range_loop)]
932/// Do two contacts act on a common body?
933///
934/// `usize::MAX` is the static world. Every ground contact names it, and it
935/// couples nothing — it is immovable, so an impulse on one ground contact
936/// induces no velocity at another through the world. Counting it as shared
937/// would collapse `PerBody` back into `Full` for the most common case there
938/// is, so it is excluded explicitly.
939fn shares_body(a: (usize, usize), b: (usize, usize)) -> bool {
940 let real = |x: usize| x != usize::MAX;
941 (real(a.0) && (a.0 == b.0 || a.0 == b.1)) || (real(a.1) && (a.1 == b.0 || a.1 == b.1))
942}
943
944fn sweep(
945 problem: &ContactProblem,
946 config: &ContactSolverConfig,
947 blocks: &[[[f64; 3]; 3]],
948 f: &mut [Vec3],
949 normals_only: bool,
950) -> f64 {
951 let n = problem.n;
952 let dim = 3 * n;
953 let a = &problem.delassus;
954 let at = |i: usize, j: usize| a[i * dim + j];
955 let mut max_move: f64 = 0.0;
956
957 for c in 0..n {
958 let base = 3 * c;
959
960 // r = b_c + sum_{k != c} A_ck f_k (Gauss-Seidel: uses updated f)
961 //
962 // Under `ContactCoupling::BlockDiagonal` the sum is skipped entirely
963 // and `r` is just the free velocity: that *is* the approximation, and
964 // it is the whole of it. Everything below — the staged normal solve,
965 // the tangential 2x2, the isotropic disc clamp — is untouched, so the
966 // restricted mode remains the same contact model rather than becoming
967 // a second one.
968 let mut r = [0.0f64; 3];
969 for (row, r_row) in r.iter_mut().enumerate() {
970 let mut acc = problem.free_velocity[base + row];
971 if config.coupling != ContactCoupling::BlockDiagonal {
972 for (k, f_k) in f.iter().enumerate().take(n) {
973 if k == c {
974 continue;
975 }
976 // PerBody keeps only the blocks the GPU can form without
977 // an articulated solve: those between contacts on a
978 // shared body.
979 if config.coupling == ContactCoupling::PerBody
980 && problem.bodies.len() == n
981 && !shares_body(problem.bodies[c], problem.bodies[k])
982 {
983 continue;
984 }
985 let kb = 3 * k;
986 acc += at(base + row, kb) * f_k.x
987 + at(base + row, kb + 1) * f_k.y
988 + at(base + row, kb + 2) * f_k.z;
989 }
990 }
991 *r_row = acc;
992 }
993
994 // Restitution is already folded into `free_velocity` as a target
995 // normal velocity (see `point_mass_problem`) rather than applied
996 // as a post-solve velocity reset. That keeps it differentiable in
997 // `e` and in the approach speed, and stops it fighting the solver.
998 let row = problem.rows[c];
999
1000 // Coulomb's law is *staged*, not a Euclidean cone projection of
1001 // the unconstrained impulse. Projecting `(f_n, f_t)` onto the cone
1002 // moves the normal component too: for a fast-sliding contact the
1003 // boundary projection sets `f_n = (mu||f_t|| + f_n)/(mu^2+1)`,
1004 // which inflates the normal impulse far above the value that
1005 // actually cancels the approach velocity, and the block then
1006 // decelerates too hard. (Measured: 2.04 m/s^2 instead of the
1007 // analytic 2.55 on a 40-degree incline.)
1008 //
1009 // The physical statement is conditional: the normal impulse is
1010 // whatever enforces non-penetration, and *given* that, friction is
1011 // bounded by `mu * f_n`. So solve the normal first, then clamp the
1012 // tangential to the disc it admits. Stiction is the case where the
1013 // unconstrained tangential impulse already lies inside that disc.
1014 // Non-penetration *plus* position stabilization: drive the
1015 // post-step normal velocity to `row.bias` (a separating velocity
1016 // proportional to the current penetration) rather than to zero.
1017 // Driving it to zero freezes whatever penetration the previous
1018 // steps accumulated, which is exactly how a stack creeps.
1019 // The residual above deliberately excludes the whole of contact
1020 // `c`'s own block, so the *intra*-block coupling has to be put
1021 // back by hand — and it has to be, or this stops being coordinate
1022 // descent on the objective and its fixed point stops being the
1023 // KKT point. `A_nu`/`A_nw` are the lever-arm coupling between this
1024 // contact's own normal and tangential rows; they are non-zero
1025 // whenever the contact is off the body's centre of mass, which is
1026 // every contact in a real support polygon. Dropping them made the
1027 // normal solve ignore the slip its own friction causes and the
1028 // tangential solve ignore the load its own normal carries, so the
1029 // sweep converged to a point where friction saturated at `mu*f_n`
1030 // with no slip to oppose. On a redundant (rank-deficient) manifold
1031 // those spurious tangential impulses feed back through the
1032 // off-diagonal blocks and the sweep walks off along the null
1033 // space instead of terminating.
1034 let a_nn = blocks[c][0][0];
1035 let (a_nu, a_nw) = (blocks[c][0][1], blocks[c][0][2]);
1036 let f_n = if a_nn > 0.0 {
1037 ((row.bias - r[0] - a_nu * f[c].y - a_nw * f[c].z) / a_nn).max(0.0)
1038 } else {
1039 0.0
1040 };
1041
1042 // Tangential 2x2 solve at the fixed normal impulse — including the
1043 // velocity that normal impulse itself induces on the tangent rows.
1044 let (a_un, a_wn) = (blocks[c][1][0], blocks[c][2][0]);
1045 let r_u = r[1] + a_un * f_n;
1046 let r_w = r[2] + a_wn * f_n;
1047 let (m00, m01) = (blocks[c][1][1], blocks[c][1][2]);
1048 let (m10, m11) = (blocks[c][2][1], blocks[c][2][2]);
1049 let det = m00 * m11 - m01 * m10;
1050 let (mut t_u, mut t_w) = if !normals_only {
1051 if det.abs() > 1e-18 {
1052 (
1053 -(m11 * r_u - m01 * r_w) / det,
1054 -(m00 * r_w - m10 * r_u) / det,
1055 )
1056 } else {
1057 (0.0, 0.0)
1058 }
1059 } else {
1060 (f[c].y, f[c].z)
1061 };
1062
1063 // Clamp into the friction disc of radius mu*f_n. The clamp is
1064 // isotropic, so a block sliding at any heading loses speed
1065 // identically — the property a pyramidal cone gives up.
1066 let limit = row.mu * f_n;
1067 let t_norm = (t_u * t_u + t_w * t_w).sqrt();
1068 if t_norm > limit {
1069 if t_norm > 0.0 {
1070 let s = limit / t_norm;
1071 t_u *= s;
1072 t_w *= s;
1073 } else {
1074 t_u = 0.0;
1075 t_w = 0.0;
1076 }
1077 }
1078
1079 let target = Vec3::new(f_n, t_u, t_w);
1080 // Relative tolerance, not absolute. The clamp above is exact in
1081 // exact arithmetic, so the only slack needed is rounding in
1082 // `limit / t_norm`, which scales with `|f|`. A fixed `1e-9` is
1083 // fine at impulses of order one and spuriously fires at order 1e6
1084 // — turning a numerical problem into an aborted process in any
1085 // debug build, which is the least useful moment to lose the state.
1086 debug_assert!(
1087 crate::cone::in_cone(target, row.mu, 1e-9 * (1.0 + target.norm())),
1088 "staged solve must land in the friction cone"
1089 );
1090 let next = f[c] + (target - f[c]) * config.relaxation;
1091 max_move = max_move.max((next - f[c]).norm());
1092 f[c] = next;
1093 }
1094
1095 max_move
1096}
1097
1098/// One active-set Newton step: classify the current iterate, then solve the
1099/// resulting linear KKT system exactly.
1100///
1101/// The regime is read with [`crate::gradient::classify_impulses`] and the
1102/// matrix built with [`crate::gradient::kkt_matrix`], so the system solved here
1103/// is the same one the sensitivity differentiates. The right-hand side is the
1104/// constant part of the same equations:
1105///
1106/// - separating: `f_c = 0`;
1107/// - sticking: `(A + R) f + b = bias` on the normal row, `= 0` on the
1108/// tangential rows;
1109/// - sliding: the normal row as above, tangential rows the homogeneous
1110/// `f_t - mu t_hat f_n = 0`.
1111///
1112/// Returns `None` if the KKT matrix is singular at this active set — the
1113/// caller falls back to PGS, which needs no such assumption.
1114// `c` indexes three flat arrays at stride 3 as well as `regimes`; enumerating
1115// one of them would hide the correspondence the others depend on.
1116#[allow(clippy::needless_range_loop)]
1117fn newton_step(
1118 problem: &ContactProblem,
1119 config: &ContactSolverConfig,
1120 f: &[Vec3],
1121) -> Option<Vec<Vec3>> {
1122 use crate::gradient::ContactRegime;
1123
1124 let n = problem.n;
1125 let dim = 3 * n;
1126 // The classification tolerance is relative to the normal impulse and
1127 // matches `impulse_sensitivity`'s, so a solve that lands here lands in the
1128 // regime the gradient will assume.
1129 let regimes = crate::gradient::classify_impulses(problem, f, 1e-7);
1130 let mut k = crate::gradient::kkt_matrix(problem, config, ®imes, f);
1131
1132 let mut rhs = vec![0.0; dim];
1133 for c in 0..n {
1134 let base = 3 * c;
1135 match regimes[c] {
1136 ContactRegime::Separating => {}
1137 ContactRegime::Sticking => {
1138 rhs[base] = problem.rows[c].bias - problem.free_velocity[base];
1139 rhs[base + 1] = -problem.free_velocity[base + 1];
1140 rhs[base + 2] = -problem.free_velocity[base + 2];
1141 }
1142 ContactRegime::Sliding => {
1143 rhs[base] = problem.rows[c].bias - problem.free_velocity[base];
1144 }
1145 }
1146 }
1147
1148 crate::gradient::solve_dense(&mut k, &mut rhs, dim, 1)?;
1149 if rhs.iter().any(|v| !v.is_finite()) {
1150 return None;
1151 }
1152
1153 // Project back into the cone with the *staged* clamp, not a Euclidean cone
1154 // projection: an infeasible proposal must not be allowed to inflate a
1155 // normal impulse (see the long note in `sweep`).
1156 Some(
1157 (0..n)
1158 .map(|c| {
1159 let base = 3 * c;
1160 let f_n = rhs[base].max(0.0);
1161 let (mut t_u, mut t_w) = (rhs[base + 1], rhs[base + 2]);
1162 let limit = problem.rows[c].mu * f_n;
1163 let t_norm = (t_u * t_u + t_w * t_w).sqrt();
1164 if t_norm > limit {
1165 let s = if t_norm > 0.0 { limit / t_norm } else { 0.0 };
1166 t_u *= s;
1167 t_w *= s;
1168 }
1169 Vec3::new(f_n, t_u, t_w)
1170 })
1171 .collect(),
1172 )
1173}
1174
1175/// Convenience: build a single-contact problem for a point mass of effective
1176/// mass `m` against an immovable surface.
1177pub fn point_mass_problem(
1178 mass: f64,
1179 free_vel: Vec3,
1180 material: &ContactMaterial,
1181 depth: f64,
1182 dt: f64,
1183 restitution_threshold: f64,
1184) -> ContactProblem {
1185 let inv_m = 1.0 / mass;
1186 let mut delassus = vec![0.0; 9];
1187 for i in 0..3 {
1188 delassus[i * 3 + i] = inv_m;
1189 }
1190 let approach = free_vel.x.min(0.0).abs();
1191 let e = ContactProblem::effective_restitution(
1192 material.restitution,
1193 approach,
1194 restitution_threshold,
1195 );
1196 let impact = ContactProblem::impact_weight(approach, restitution_threshold);
1197 ContactProblem {
1198 n: 1,
1199 delassus,
1200 // Restitution: the target normal velocity is `-e * v_approach`
1201 // rather than 0, folded into b.
1202 free_velocity: vec![free_vel.x * (1.0 + e), free_vel.y, free_vel.z],
1203 rows: vec![ContactRow::from_material(material, depth, dt, e).with_impact(impact)],
1204 // A single point mass against the static world: nothing to couple to.
1205 bodies: vec![(0, usize::MAX)],
1206 }
1207}