Skip to main content

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, &regimes, 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}