Skip to main content

phyz_model/
material.rs

1//! Contact material properties.
2//!
3//! A [`ContactMaterial`] is attached to a [`crate::Body`] via
4//! [`crate::Body::material`], and [`crate::Model::contact_materials`] turns
5//! those into the per-body slice the contact solver assembles against. Bodies
6//! that carry `None` take the scene material, so a model that sets nothing is
7//! unchanged.
8//!
9//! Read [`ContactMaterial::combine`] before choosing which body gets a
10//! material: friction combines by `max`, so a grippy body grips everything it
11//! touches, not only the surface you had in mind.
12
13/// MuJoCo-style `solref`: the reference response of a violated constraint.
14///
15/// The pair `(timeconst, dampratio)` describes the mass-normalized damped
16/// spring the solver *pretends* the constraint is, rather than a raw
17/// stiffness/damping pair. Parameterizing by a time constant is what makes the
18/// behaviour independent of the contacting bodies' masses: a 20 ms recovery is
19/// 20 ms whether the box weighs a gram or a tonne.
20#[derive(Debug, Clone, Copy, PartialEq)]
21pub struct SolRef {
22    /// Time constant of the constraint's reference response, in seconds.
23    ///
24    /// MuJoCo's default is `0.02`, and its guidance — that it should be at
25    /// least twice the timestep — applies here for the same reason: the
26    /// discrete correction below reduces to a per-step gain of
27    /// `dt / (2*timeconst*dampratio^2 + dt)`, which saturates at 1 (a full,
28    /// popping correction in one step) as `timeconst -> 0`.
29    pub timeconst: f64,
30    /// Damping ratio. `1` is critically damped, which is what a contact
31    /// wants: penetration decays without overshooting into a bounce.
32    pub dampratio: f64,
33}
34
35impl Default for SolRef {
36    fn default() -> Self {
37        // MuJoCo's defaults.
38        Self {
39            timeconst: 0.02,
40            dampratio: 1.0,
41        }
42    }
43}
44
45impl SolRef {
46    /// Fraction of the current penetration to remove in a step of `dt`.
47    ///
48    /// Derived from the reference spring rather than picked: a critically
49    /// damped spring with time constant `tau` and damping ratio `zeta` has
50    /// mass-normalized `k = 1/(tau^2 zeta^2)` and `b = 2/(tau zeta^2)`. One
51    /// implicit step of that spring removes
52    ///
53    /// ```text
54    ///   dt*k / (b + dt*k)  =  dt / (2*tau*zeta^2 + dt)
55    /// ```
56    ///
57    /// of the violation. Taking the implicit (rather than the explicit
58    /// `dt^2 k`) gain is what keeps the correction bounded by 1 for every
59    /// `dt`, so a large step can never over-correct and launch the body.
60    pub fn error_reduction(&self, dt: f64) -> f64 {
61        if dt <= 0.0 {
62            return 0.0;
63        }
64        let tau = self.timeconst.max(0.0);
65        let zeta = self.dampratio.max(1e-6);
66        let denom = 2.0 * tau * zeta * zeta + dt;
67        if denom <= 0.0 { 1.0 } else { dt / denom }
68    }
69}
70
71/// MuJoCo-style `solimp`: constraint impedance as a function of the violation.
72///
73/// Impedance `d` in `(0, 1)` blends between a fully soft constraint (`d -> 0`,
74/// no force at all) and a fully rigid one (`d -> 1`). Making it depend on
75/// penetration depth is the point: a contact that has just been made is soft,
76/// so making and breaking contact stays smooth (and differentiable), while a
77/// deeply penetrating contact stiffens up and pushes hard.
78#[derive(Debug, Clone, Copy, PartialEq)]
79pub struct SolImp {
80    /// Impedance at zero violation.
81    pub dmin: f64,
82    /// Impedance at and beyond `width` of violation.
83    pub dmax: f64,
84    /// Violation (in metres, for contacts) over which `dmin -> dmax`.
85    pub width: f64,
86    /// Where in `[0, 1]` the sigmoid's inflection sits.
87    pub midpoint: f64,
88    /// Sigmoid power. `1` is linear; larger is flatter at both ends.
89    pub power: f64,
90}
91
92impl Default for SolImp {
93    fn default() -> Self {
94        // MuJoCo's defaults: (dmin, dmax, width, midpoint, power).
95        Self {
96            dmin: 0.9,
97            dmax: 0.95,
98            width: 0.001,
99            midpoint: 0.5,
100            power: 2.0,
101        }
102    }
103}
104
105impl SolImp {
106    /// Impedance at a constraint violation of `r` metres.
107    ///
108    /// The interpolant is MuJoCo's polynomial sigmoid: `a x^p` below the
109    /// midpoint and `1 - b (1-x)^p` above it, with `a` and `b` chosen so the
110    /// halves meet at the midpoint. It is `C^1` in `r` everywhere except the
111    /// clamp at `x = 1`.
112    pub fn impedance(&self, r: f64) -> f64 {
113        let dmin = self.dmin.clamp(1e-4, 1.0 - 1e-9);
114        let dmax = self.dmax.clamp(1e-4, 1.0 - 1e-9);
115        if self.width <= 0.0 {
116            return dmax;
117        }
118        let x = (r.abs() / self.width).clamp(0.0, 1.0);
119        let mid = self.midpoint.clamp(1e-6, 1.0 - 1e-6);
120        let p = self.power.max(1.0);
121        let y = if x <= mid {
122            x.powf(p) / mid.powf(p - 1.0)
123        } else {
124            1.0 - (1.0 - x).powf(p) / (1.0 - mid).powf(p - 1.0)
125        };
126        dmin + y * (dmax - dmin)
127    }
128
129    /// `d(impedance)/dr` at a **non-negative** violation `r`.
130    ///
131    /// Mirrors [`Self::impedance`] branch for branch, including its clamps: past
132    /// `width` the impedance is pinned at `dmax`, so the derivative is zero, and
133    /// a non-positive `width` makes it constant everywhere.
134    ///
135    /// Defined for `r >= 0` only. `impedance` takes `r.abs()`, so the function
136    /// has a kink at the origin whenever `power == 1`; for `power > 1` (the
137    /// default) both one-sided derivatives are zero there and this returns the
138    /// value that is correct from either side.
139    pub fn impedance_derivative(&self, r: f64) -> f64 {
140        let dmin = self.dmin.clamp(1e-4, 1.0 - 1e-9);
141        let dmax = self.dmax.clamp(1e-4, 1.0 - 1e-9);
142        if self.width <= 0.0 || r < 0.0 {
143            return 0.0;
144        }
145        let x = r / self.width;
146        if x >= 1.0 {
147            // Clamped at dmax: the sigmoid has stopped moving.
148            return 0.0;
149        }
150        let mid = self.midpoint.clamp(1e-6, 1.0 - 1e-6);
151        let p = self.power.max(1.0);
152        let dy_dx = if x <= mid {
153            p * x.powf(p - 1.0) / mid.powf(p - 1.0)
154        } else {
155            p * (1.0 - x).powf(p - 1.0) / (1.0 - mid).powf(p - 1.0)
156        };
157        dy_dx * (dmax - dmin) / self.width
158    }
159}
160
161/// Material properties for contact interactions.
162#[derive(Debug, Clone)]
163pub struct ContactMaterial {
164    /// Contact stiffness (N/m).
165    pub stiffness: f64,
166    /// Contact damping (N·s/m).
167    pub damping: f64,
168    /// Coefficient of friction (dimensionless).
169    pub friction: f64,
170    /// Coefficient of restitution (0 = inelastic, 1 = elastic).
171    ///
172    /// Was `bounce`, a field no solver ever read — `ContactMaterial::bouncy()`
173    /// did nothing. It is now honoured by the convex solve, entering as a
174    /// target normal velocity rather than a post-solve velocity reset. See
175    /// `docs/design/differentiable-contact.md` §4.3.
176    pub restitution: f64,
177    /// Constraint force mixing (for numerical stability).
178    pub soft_cfm: f64,
179    /// Error reduction parameter (for constraint drift correction).
180    pub soft_erp: f64,
181    /// Reference response of the contact constraint (MuJoCo `solref`).
182    ///
183    /// This is what gives the convex solve a penetration-recovery bias.
184    /// Without it `ContactRow::depth` was carried around and never read, so
185    /// penetration was never paid back and a stack crept forever.
186    pub solref: SolRef,
187    /// Depth-dependent constraint impedance (MuJoCo `solimp`).
188    ///
189    /// Supersedes a single scalar `ContactSolverConfig::regularization` as the
190    /// softness knob: the regularizer is now `R = (1-d)/d * diag(A)` per row,
191    /// with the config value acting as a floor.
192    pub solimp: SolImp,
193    /// Contact margin, in metres: how far apart surfaces may be and still be
194    /// included in the solve.
195    ///
196    /// This used to be inert ("the narrow phase owns detection"). It is not:
197    /// with *soft* contact a candidate at exactly zero depth still carries the
198    /// full `solimp.dmin` share of the load, so a detection predicate that cuts
199    /// off at zero gap cuts off a contact that was carrying real force. Measured
200    /// on a humanoid stance, the least-loaded foot corner was carrying 22.3 N —
201    /// 11% of body weight — on the step before it left the contact set, and went
202    /// to 0 N the moment it rose a nanometre above the plane.
203    ///
204    /// The margin is the band above the surface over which a contact is still
205    /// generated, with a *negative* `penetration_depth`, and its impedance
206    /// tapers to zero — see [`ContactMaterial::impedance_at`]. That turns the
207    /// step into a ramp.
208    ///
209    /// The default is [`SolImp::default`]'s `width`, i.e. the same 1 mm scale
210    /// over which solimp already varies the impedance on the penetrating side.
211    /// Picking the same scale makes impedance one continuous, `C^1` function of
212    /// signed gap across the whole band `[-margin, +width]` rather than two
213    /// curves glued at zero.
214    ///
215    /// Combines with `max` (MuJoCo's rule): the larger margin is the one asking
216    /// for earlier detection, and `min` would silently defeat it.
217    pub margin: f64,
218}
219
220impl Default for ContactMaterial {
221    fn default() -> Self {
222        Self {
223            stiffness: 10000.0,
224            damping: 100.0,
225            friction: 0.5,
226            restitution: 0.0,
227            soft_cfm: 0.0001,
228            soft_erp: 0.2,
229            solref: SolRef::default(),
230            solimp: SolImp::default(),
231            // Same scale as `SolImp::default().width`; see the field docs.
232            margin: 0.001,
233        }
234    }
235}
236
237impl ContactMaterial {
238    /// Create a new contact material with custom parameters.
239    pub fn new(stiffness: f64, damping: f64, friction: f64, restitution: f64) -> Self {
240        Self {
241            stiffness,
242            damping,
243            friction,
244            restitution,
245            soft_cfm: 0.0001,
246            soft_erp: 0.2,
247            ..Default::default()
248        }
249    }
250
251    /// Constraint impedance for a contact at *signed* penetration `depth`,
252    /// where negative means the surfaces are separated by `-depth` metres.
253    ///
254    /// On the penetrating side (`depth >= 0`) this is exactly
255    /// [`SolImp::impedance`] and nothing has changed. Inside the margin band
256    /// (`-margin < depth < 0`) it is `dmin` scaled by a smoothstep that reaches
257    /// zero at `depth = -margin`.
258    ///
259    /// **Why impedance, and why this makes the force continuous.** The
260    /// regularizer is `R = (1-d)/d * A_nn` (see
261    /// `phyz_contact::convex::regularization_diag`), so a single contact resolving a
262    /// free approach velocity `v` carries a normal impulse of about
263    /// `v / (A_nn + R) = d * v / A_nn` — *linear in `d`*. Taking `d` smoothly to
264    /// zero therefore takes the normal force smoothly to zero, and the contact
265    /// leaves the set carrying nothing instead of carrying 11% of body weight.
266    ///
267    /// It also cannot pull: `d -> 0` means "infinitely soft", not "attractive".
268    /// The normal impulse stays in the friction cone's `f_n >= 0` half-line, and
269    /// `phyz_contact`'s `ContactRow::from_material` clamps the stabilization bias at
270    /// `depth.max(0.0)`, so a separated contact has no bias to push *or* pull
271    /// with. Friction rides on `mu * f_n` and vanishes with it.
272    ///
273    /// The join at `depth = 0` is `C^1`: smoothstep has zero slope at its top
274    /// end, and the solimp sigmoid has zero slope at `x = 0` for `power > 1`.
275    pub fn impedance_at(&self, depth: f64) -> f64 {
276        if depth >= 0.0 {
277            return self.solimp.impedance(depth);
278        }
279        let gap = -depth;
280        if self.margin <= 0.0 || gap >= self.margin {
281            return 0.0;
282        }
283        let s = 1.0 - gap / self.margin;
284        let ramp = s * s * (3.0 - 2.0 * s);
285        ramp * self.solimp.impedance(0.0)
286    }
287
288    /// `d(impedance)/d(depth)`, the derivative of [`Self::impedance_at`].
289    ///
290    /// This is not a nicety. Inside the margin band the stabilization bias is
291    /// identically zero, so impedance is the *only* channel by which depth
292    /// reaches the impulses — and it is a strong one: the measured force ramps
293    /// from 17.66 N to 0 across a 1 mm band, roughly `1.8e4 N/m`. A gradient
294    /// that dropped this term would report exactly zero sensitivity across the
295    /// whole region where contact activation is differentiable, which is
296    /// precisely the region a contact-timing gradient lives in.
297    ///
298    /// On the penetrating side it is the solimp sigmoid's own slope, worth up
299    /// to `(dmax-dmin)/dmin` of the primary term. That used to be dropped as a
300    /// "second-order path"; it is included now, so there is one expression
301    /// rather than two regimes with different honesty.
302    ///
303    /// Both branches are exact, and they agree at `depth = 0` (both zero for
304    /// `power > 1`), which is what makes [`Self::impedance_at`] `C^1` there.
305    pub fn dimpedance_ddepth(&self, depth: f64) -> f64 {
306        if depth >= 0.0 {
307            return self.solimp.impedance_derivative(depth);
308        }
309        let gap = -depth;
310        if self.margin <= 0.0 || gap >= self.margin {
311            return 0.0;
312        }
313        // d/d(depth) of `smoothstep(s) * dmin` with `s = 1 + depth/margin`.
314        let s = 1.0 - gap / self.margin;
315        6.0 * s * (1.0 - s) / self.margin * self.solimp.impedance(0.0)
316    }
317
318    /// Create a bouncy material (high restitution).
319    pub fn bouncy() -> Self {
320        Self {
321            stiffness: 10000.0,
322            damping: 50.0,
323            friction: 0.3,
324            restitution: 0.8,
325            soft_cfm: 0.0001,
326            soft_erp: 0.2,
327            ..Default::default()
328        }
329    }
330
331    /// Create a soft material (low stiffness).
332    pub fn soft() -> Self {
333        Self {
334            stiffness: 1000.0,
335            damping: 200.0,
336            friction: 0.7,
337            restitution: 0.1,
338            soft_cfm: 0.001,
339            soft_erp: 0.2,
340            ..Default::default()
341        }
342    }
343
344    /// Create a rigid material (high stiffness).
345    pub fn rigid() -> Self {
346        Self {
347            stiffness: 50000.0,
348            damping: 100.0,
349            friction: 0.5,
350            restitution: 0.0,
351            soft_cfm: 0.00001,
352            soft_erp: 0.2,
353            ..Default::default()
354        }
355    }
356
357    /// Combine the two materials of a contacting pair into the one material
358    /// the contact is actually solved with.
359    ///
360    /// A contact belongs to *two* surfaces, so using one side's material — as
361    /// `assemble` did, always taking `body_i`'s — makes the physics depend on
362    /// which body the narrow phase happened to list first. Rubber on ice slid
363    /// or gripped depending on collision-pair ordering.
364    ///
365    /// The rules, and why each one:
366    ///
367    /// - **Friction: `max`.** This is MuJoCo's rule (`mj_contactParam` takes
368    ///   the elementwise maximum of the two geoms' friction when neither has
369    ///   priority), and it is the conservative choice for a simulator: a
370    ///   sticky surface stays sticky against a slippery one, so a foot does
371    ///   not silently lose grip because the floor was authored with a low
372    ///   default. Note this is `max`, not `min` — the mixing rule matters
373    ///   more than any single material's number, so it follows MuJoCo rather
374    ///   than the intuition that the slipperier surface should win.
375    ///
376    ///   **This is the rule that decides which body you attach a material
377    ///   to**, and it is asymmetric in a way that bites. A material lives on
378    ///   a [`crate::Body`] and applies to *every* contact that body is part
379    ///   of, so `max` means a grippy body grips everything it touches.
380    ///   Measured on a skateboarding robot: grip tape (`mu = 1.5`) put on the
381    ///   *deck* correctly grips the shoes — but the deck's bare underside then
382    ///   also grips the asphalt during the tail scrape, which braked the board
383    ///   from 0.6 to 0.39 m/s and killed the trick. The same 1.5 on the
384    ///   *shoes* raises only the foot-on-deck contacts and leaves
385    ///   deck-on-ground at wood's 0.6. Put the material on the part whose
386    ///   surface it describes, then check what else that part touches.
387    /// - **Margin: `max`.** Also MuJoCo's. The larger margin is the one that
388    ///   wants contacts generated earlier; taking the min would silently
389    ///   defeat a surface that asked for early detection.
390    /// - **Restitution: `max`.** Same argument: a bouncy ball stays bouncy on
391    ///   an inelastic floor.
392    /// - **Everything stiffness-like — `stiffness`, `damping`, `soft_cfm`,
393    ///   `soft_erp`, and every `solref`/`solimp` scalar: geometric mean.**
394    ///   These span orders of magnitude (`soft_cfm` from `1e-5` to `1e-3`,
395    ///   stiffness from `1e3` to `5e4`), and the arithmetic mean of two such
396    ///   numbers is just the larger one — soft rubber against a rigid floor
397    ///   would come out rigid. The geometric mean is the mean that respects a
398    ///   log-scaled quantity, and it also makes the combination invariant to
399    ///   the units the quantity is expressed in. MuJoCo uses a `solmix`-
400    ///   weighted arithmetic average here; with no per-material mix weight to
401    ///   honour, the geometric mean is the better-behaved default.
402    ///
403    /// The rule is commutative in both arguments, which is the property that
404    /// makes the result independent of narrow-phase ordering.
405    pub fn combine(a: &Self, b: &Self) -> Self {
406        // Geometric mean, guarding the sign/zero cases: `sqrt` of a negative
407        // is NaN, and a zero on either side must stay zero.
408        fn gmean(x: f64, y: f64) -> f64 {
409            if x <= 0.0 || y <= 0.0 {
410                0.5 * (x + y)
411            } else {
412                (x * y).sqrt()
413            }
414        }
415
416        Self {
417            stiffness: gmean(a.stiffness, b.stiffness),
418            damping: gmean(a.damping, b.damping),
419            friction: a.friction.max(b.friction),
420            restitution: a.restitution.max(b.restitution),
421            soft_cfm: gmean(a.soft_cfm, b.soft_cfm),
422            soft_erp: gmean(a.soft_erp, b.soft_erp),
423            solref: SolRef {
424                timeconst: gmean(a.solref.timeconst, b.solref.timeconst),
425                dampratio: gmean(a.solref.dampratio, b.solref.dampratio),
426            },
427            solimp: SolImp {
428                dmin: gmean(a.solimp.dmin, b.solimp.dmin),
429                dmax: gmean(a.solimp.dmax, b.solimp.dmax),
430                width: gmean(a.solimp.width, b.solimp.width),
431                midpoint: gmean(a.solimp.midpoint, b.solimp.midpoint),
432                power: gmean(a.solimp.power, b.solimp.power),
433            },
434            margin: a.margin.max(b.margin),
435        }
436    }
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442
443    #[test]
444    fn impedance_spans_dmin_to_dmax() {
445        let s = SolImp::default();
446        assert!((s.impedance(0.0) - s.dmin).abs() < 1e-12);
447        assert!((s.impedance(s.width) - s.dmax).abs() < 1e-12);
448        assert!((s.impedance(10.0 * s.width) - s.dmax).abs() < 1e-12);
449        // Monotone in between, which is what makes deeper penetration stiffer.
450        let mut prev = s.impedance(0.0);
451        for k in 1..=50 {
452            let d = s.impedance(s.width * k as f64 / 50.0);
453            assert!(
454                d >= prev - 1e-15,
455                "impedance must be monotone: {prev} -> {d}"
456            );
457            prev = d;
458        }
459    }
460
461    #[test]
462    fn impedance_sigmoid_halves_meet_at_the_midpoint() {
463        let s = SolImp::default();
464        let d = s.impedance(s.width * s.midpoint);
465        let want = s.dmin + s.midpoint * (s.dmax - s.dmin);
466        assert!((d - want).abs() < 1e-12, "{d} vs {want}");
467    }
468
469    #[test]
470    fn error_reduction_is_bounded_and_monotone_in_dt() {
471        let r = SolRef::default();
472        for dt in [1e-4, 1e-3, 2e-3, 1e-2, 1.0, 100.0] {
473            let e = r.error_reduction(dt);
474            assert!((0.0..=1.0).contains(&e), "dt={dt} gave {e}");
475        }
476        assert!(r.error_reduction(2e-3) < r.error_reduction(1e-2));
477        // A zero time constant means "fix it this step".
478        let instant = SolRef {
479            timeconst: 0.0,
480            dampratio: 1.0,
481        };
482        assert!((instant.error_reduction(2e-3) - 1.0).abs() < 1e-12);
483    }
484
485    #[test]
486    fn impedance_at_ramps_to_zero_across_the_margin() {
487        let m = ContactMaterial::default();
488        assert!(m.margin > 0.0);
489
490        // Penetrating side is untouched.
491        for r in [0.0, 1e-5, 1e-4, 1e-3, 1e-2] {
492            assert_eq!(m.impedance_at(r), m.solimp.impedance(r));
493        }
494
495        // Monotone down to exactly zero at the band edge, and zero past it.
496        let mut prev = m.impedance_at(0.0);
497        for k in 1..=100 {
498            let d = m.impedance_at(-m.margin * k as f64 / 100.0);
499            assert!(d <= prev + 1e-15, "must be monotone: {prev} -> {d}");
500            assert!(d >= 0.0, "impedance must never go negative: {d}");
501            prev = d;
502        }
503        assert_eq!(m.impedance_at(-m.margin), 0.0);
504        assert_eq!(m.impedance_at(-2.0 * m.margin), 0.0);
505
506        // A zero margin is the old hard cutoff, exactly.
507        let hard = ContactMaterial {
508            margin: 0.0,
509            ..Default::default()
510        };
511        assert_eq!(hard.impedance_at(-1e-12), 0.0);
512        assert_eq!(hard.impedance_at(0.0), hard.solimp.dmin);
513    }
514
515    /// The join at zero depth is `C^1`, which is the property that makes the
516    /// contact-activation event differentiable rather than merely continuous.
517    /// Both one-sided derivatives are zero there: smoothstep is flat at its top
518    /// end, and the solimp sigmoid is flat at `x = 0` for `power > 1`.
519    #[test]
520    fn impedance_at_is_c1_across_zero_depth() {
521        let m = ContactMaterial::default();
522        let h = 1e-9;
523        let below = (m.impedance_at(0.0) - m.impedance_at(-h)) / h;
524        let above = (m.impedance_at(h) - m.impedance_at(0.0)) / h;
525        // Compared against the ramp's own characteristic slope — smoothstep
526        // peaks at `1.5 * dmin / margin` — since an absolute tolerance would
527        // just be measuring the choice of `margin`.
528        let scale = 1.5 * m.solimp.dmin / m.margin;
529        assert!(
530            below.abs() < 1e-4 * scale,
531            "left slope at 0 is {below}, vs ramp slope scale {scale}"
532        );
533        assert!(
534            above.abs() < 1e-4 * scale,
535            "right slope at 0 is {above}, vs ramp slope scale {scale}"
536        );
537        // And they agree with each other, which is what "C^1" means here.
538        assert!((below - above).abs() < 1e-4 * scale);
539
540        // Continuous everywhere in the band: no sample-to-sample cliff.
541        let n = 20_000;
542        let mut prev = m.impedance_at(-m.margin);
543        for k in 1..=n {
544            let d = m.impedance_at(-m.margin + 2.0 * m.margin * k as f64 / n as f64);
545            assert!(
546                (d - prev).abs() < 1e-3,
547                "impedance jumped by {} at step {k}",
548                (d - prev).abs()
549            );
550            prev = d;
551        }
552    }
553
554    /// `dimpedance_ddepth` against a central difference of `impedance_at`,
555    /// across both branches and the join between them.
556    #[test]
557    fn impedance_derivative_matches_finite_difference() {
558        let m = ContactMaterial::default();
559        let h = 1e-9;
560        let mut worst = 0.0f64;
561        // Inside the margin band and through the solimp sigmoid, but not at
562        // the two clamp kinks (-margin and +width) where the derivative is
563        // genuinely one-sided, and not at the join itself: a central difference
564        // at zero straddles both branches, so it measures the curvature there
565        // rather than the slope. The join is covered by
566        // `impedance_at_is_c1_across_zero_depth`, which is the assertion that
567        // actually belongs there — both one-sided slopes are zero.
568        for k in (-95i32..=95).filter(|k| *k != 0) {
569            let depth = if k < 0 {
570                m.margin * k as f64 / 100.0
571            } else {
572                m.solimp.width * k as f64 / 100.0
573            };
574            let fd = (m.impedance_at(depth + h) - m.impedance_at(depth - h)) / (2.0 * h);
575            let got = m.dimpedance_ddepth(depth);
576            let rel = (got - fd).abs() / fd.abs().max(1.0);
577            worst = worst.max(rel);
578            assert!(rel < 1e-5, "depth {depth}: analytic {got} vs FD {fd}");
579        }
580        assert!(worst < 1e-5, "worst relative error {worst}");
581
582        // Past the clamps the impedance is pinned and the derivative is zero.
583        assert_eq!(m.dimpedance_ddepth(-2.0 * m.margin), 0.0);
584        assert_eq!(m.dimpedance_ddepth(2.0 * m.solimp.width), 0.0);
585    }
586
587    #[test]
588    fn margin_combines_by_max() {
589        let a = ContactMaterial {
590            margin: 5e-3,
591            ..Default::default()
592        };
593        let b = ContactMaterial {
594            margin: 1e-4,
595            ..Default::default()
596        };
597        assert_eq!(ContactMaterial::combine(&a, &b).margin, 5e-3);
598        assert_eq!(ContactMaterial::combine(&b, &a).margin, 5e-3);
599    }
600
601    #[test]
602    fn combination_is_commutative_and_picks_the_documented_extremes() {
603        let a = ContactMaterial::soft();
604        let b = ContactMaterial::rigid();
605        let ab = ContactMaterial::combine(&a, &b);
606        let ba = ContactMaterial::combine(&b, &a);
607        assert_eq!(ab.friction, ba.friction);
608        assert_eq!(ab.stiffness, ba.stiffness);
609        assert_eq!(ab.soft_cfm, ba.soft_cfm);
610
611        assert_eq!(ab.friction, a.friction.max(b.friction));
612        // Geometric, not arithmetic: soft rubber on a rigid floor must not
613        // come out rigid.
614        assert!((ab.stiffness - (a.stiffness * b.stiffness).sqrt()).abs() < 1e-9);
615        assert!(ab.stiffness < 0.5 * (a.stiffness + b.stiffness));
616    }
617
618    #[test]
619    fn combining_a_material_with_itself_is_the_identity() {
620        let m = ContactMaterial::bouncy();
621        let c = ContactMaterial::combine(&m, &m);
622        assert!((c.stiffness - m.stiffness).abs() < 1e-9);
623        assert!((c.friction - m.friction).abs() < 1e-12);
624        assert!((c.restitution - m.restitution).abs() < 1e-12);
625        assert!((c.solref.timeconst - m.solref.timeconst).abs() < 1e-12);
626        assert!((c.solimp.width - m.solimp.width).abs() < 1e-12);
627    }
628}