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}