phyz_contact/solver.rs
1//! Contact detection and force computation.
2
3use crate::material::ContactMaterial;
4use phyz_collision::{AABB, Collision, sweep_and_prune};
5use phyz_math::{SpatialTransformExt, SpatialVec, Vec3};
6use phyz_model::{Geometry as ModelGeometry, Heightfield, Model, State};
7
8/// Convert phyz_model::Geometry to phyz_collision::Geometry.
9fn convert_geometry(g: &ModelGeometry) -> phyz_collision::Geometry {
10 match g {
11 ModelGeometry::Sphere { radius } => phyz_collision::Geometry::Sphere { radius: *radius },
12 ModelGeometry::Capsule { radius, length } => phyz_collision::Geometry::Capsule {
13 radius: *radius,
14 length: *length,
15 },
16 ModelGeometry::Box { half_extents } => phyz_collision::Geometry::Box {
17 half_extents: *half_extents,
18 },
19 ModelGeometry::Cylinder { radius, height } => phyz_collision::Geometry::Cylinder {
20 radius: *radius,
21 height: *height,
22 },
23 ModelGeometry::Mesh { vertices, faces } => phyz_collision::Geometry::Mesh {
24 vertices: vertices.clone(),
25 faces: faces.clone(),
26 },
27 ModelGeometry::Plane { normal } => phyz_collision::Geometry::Plane { normal: *normal },
28 }
29}
30
31/// Find all body-body contacts in the current state.
32///
33/// Walks every collision shape of every body — [`phyz_model::Body::collisions`]
34/// at its own offset and orientation, falling back to the centred
35/// [`phyz_model::Body::geometry`] for bodies with an empty collision list — so
36/// a humanoid whose URDF hangs a capsule off the shoulder can have that capsule
37/// hit something. The `geometries` argument is gone: the shapes come from
38/// `model`, which is the only place the offsets live.
39///
40/// # Which pairs are considered
41///
42/// Structural exclusions come from [`phyz_model::Model::may_collide`]: a body
43/// never collides with itself, with its parent or child, or with anything a
44/// fixed-joint chain has welded to it. Those pairs overlap in *every*
45/// configuration — a joint's two links intersect at the joint by construction —
46/// so reporting them would bury every genuine self-touch under one permanent
47/// contact per joint. Model-specific exclusions live in
48/// [`phyz_model::Model::contact_exclude`].
49///
50/// # Margin
51///
52/// `margin` (metres) is the band outside which a separated pair is ignored.
53/// Inside it a pair still produces a manifold, reported with a **negative**
54/// `penetration_depth` equal to minus its gap. Pass
55/// [`ContactMaterial::margin`] — the same knob the ground path takes.
56///
57/// This path had no margin for a long time, and the reason it now does is
58/// worth stating: contact is *soft*, so a pair at zero distance carries nearly
59/// its full share of the load and then vanishes, and the contact set jumps
60/// underneath the solver. On the ground path that cost a balancing controller
61/// a 22 N discontinuity; here it cost stability outright. A limb pinched
62/// between two bodies chattered rather than settling, which put hard bounds on
63/// what a humanoid simulation could do — measured on a Booster K1 side-fall:
64/// tucked-arm poses diverged, drops above 0.5 m diverged, and `dt = 2 ms`
65/// diverged where 1 ms survived, non-monotonically in drop height, which is
66/// the signature of a contact set flickering rather than of a stiffness
67/// problem.
68///
69/// [`ContactMaterial::margin`]: crate::material::ContactMaterial::margin
70pub fn find_contacts(model: &Model, state: &State, margin: f64) -> Vec<Collision> {
71 let shapes = placed_shapes(model, state);
72 if shapes.len() < 2 {
73 return Vec::new();
74 }
75
76 let margin = if margin.is_finite() {
77 margin.max(0.0)
78 } else {
79 0.0
80 };
81
82 // Broad phase over *shapes*, not bodies: a body with two far-apart
83 // primitives would otherwise present one AABB spanning both and pair with
84 // everything in between.
85 //
86 // Each box is padded by half the margin, because the broad phase decides
87 // what the narrow phase is even allowed to see: an AABB gap never exceeds
88 // the true surface gap, so a pair inside the margin has AABBs at most
89 // `margin` apart, and half each closes exactly that. Culling on unpadded
90 // boxes caps the effective margin at zero for any pair whose AABBs do not
91 // already touch — which is most of them, and is why the first version of
92 // this reported band contacts on only 4 of 158 body-body contacts through
93 // a humanoid fall.
94 let aabbs: Vec<AABB> = shapes
95 .iter()
96 .map(|s| AABB::from_geometry(&s.geometry, &s.pos, &s.rot).expanded(0.5 * margin))
97 .collect();
98 let pairs = sweep_and_prune(&aabbs);
99
100 let welds = model.weld_groups();
101 let mut contacts = Vec::new();
102
103 for (si, sj) in pairs {
104 let (a, b) = (&shapes[si], &shapes[sj]);
105 if !model.may_collide(a.body, b.body, &welds) {
106 continue;
107 }
108
109 // Full manifold from the narrow phase, penetrating or within the
110 // margin band: an EPA normal when overlapping and the GJK closest-point
111 // direction when separated (not the body-centre difference, which is
112 // not a contact normal at all), contact points on the surfaces (not the
113 // midpoint of the two body centres, which lies inside both bodies), and
114 // up to four points per pair so face contacts resist tipping.
115 let Some(manifold) = phyz_collision::contact_manifold_within(
116 &a.geometry,
117 &b.geometry,
118 &a.pos,
119 &a.rot,
120 &b.pos,
121 &b.rot,
122 margin,
123 ) else {
124 continue;
125 };
126
127 for point in &manifold.points {
128 contacts.push(Collision {
129 body_i: a.body,
130 body_j: b.body,
131 contact_point: point.position,
132 // `Manifold::normal` points from shape `a` toward shape `b`;
133 // `Collision::contact_normal` is the direction `body_i` must
134 // move to *separate*. Opposite senses — see the field docs for
135 // what passing it through unnegated did.
136 contact_normal: -manifold.normal,
137 penetration_depth: point.depth,
138 });
139 }
140 }
141
142 contacts
143}
144
145/// One collision shape, resolved to world coordinates.
146struct PlacedShape {
147 body: usize,
148 geometry: phyz_collision::Geometry,
149 pos: Vec3,
150 /// **Shape→world** rotation — the convention `phyz_collision` wants, which
151 /// is the *transpose* of the one `State::body_xform` carries.
152 ///
153 /// `Geometry::support` computes a shape's world axis as `rot * ẑ`, and
154 /// `box_support_face` takes a world direction into shape coordinates with
155 /// `rot.transpose() * dir`; both therefore read `rot` as local→world. FK
156 /// hands out the opposite (`SpatialTransform::rot` is world→body), and
157 /// this pipeline used to pass it straight through to `AABB::from_geometry`,
158 /// GJK and EPA.
159 ///
160 /// That is an error of `2θ` in every shape's orientation, and it is
161 /// invisible at the identity — which is why it survived: body-body
162 /// contacts only ever ran on upright test fixtures. The observable
163 /// contradiction is that it made contact *non-equivariant*: the K1 lying
164 /// on its side reported a 13 mm trunk/upper-arm overlap that vanished when
165 /// the same joint angles were evaluated with the base upright, even though
166 /// a body-body overlap depends only on the relative configuration.
167 rot: phyz_math::Mat3,
168}
169
170/// Every collision shape in the model, in world coordinates.
171///
172/// Bodies with a non-empty `collisions` list contribute all of them at their
173/// own offsets; the rest fall back to the centred legacy `geometry` field.
174/// Shapes on a body with a non-finite transform are dropped rather than
175/// emitted with a NaN pose — the broad phase would skip them anyway, and a
176/// contact with a NaN normal is worse than a missing one.
177fn placed_shapes(model: &Model, state: &State) -> Vec<PlacedShape> {
178 let mut out = Vec::new();
179 for (i, body) in model.bodies.iter().enumerate() {
180 let Some(xform) = state.body_xform.get(i) else {
181 continue;
182 };
183 if !pos_is_finite(&xform.pos) || !rot_is_finite(&xform.rot) {
184 continue;
185 }
186
187 let mut push = |geom: &ModelGeometry, origin: &phyz_math::SpatialTransform| {
188 let sx = shape_world_xform(xform, origin);
189 if !pos_is_finite(&sx.pos) || !rot_is_finite(&sx.rot) {
190 return;
191 }
192 out.push(PlacedShape {
193 body: i,
194 geometry: convert_geometry(geom),
195 pos: sx.pos,
196 // `shape_world_xform` returns world→shape, matching FK; the
197 // collision crate wants its inverse. See `PlacedShape::rot`.
198 rot: sx.rot.transpose(),
199 });
200 };
201
202 if body.collisions.is_empty() {
203 if let Some(g) = &body.geometry {
204 push(g, &phyz_math::SpatialTransform::identity());
205 }
206 } else {
207 for inst in &body.collisions {
208 push(&inst.geometry, &inst.origin);
209 }
210 }
211 }
212 out
213}
214
215fn pos_is_finite(v: &Vec3) -> bool {
216 v.x.is_finite() && v.y.is_finite() && v.z.is_finite()
217}
218
219fn rot_is_finite(m: &phyz_math::Mat3) -> bool {
220 for i in 0..3 {
221 for j in 0..3 {
222 if !m[(i, j)].is_finite() {
223 return false;
224 }
225 }
226 }
227 true
228}
229
230/// Compute contact forces for all contacts using body spatial velocities.
231///
232/// `body_velocities` should come from forward_kinematics (linear part of spatial velocity).
233/// Returns spatial forces for each body in body frame.
234#[deprecated(
235 note = "penalty contact is superseded by the convex solve (`assemble` + \
236 `solve_contacts`), which couples contacts through the Delassus \
237 operator instead of treating each in isolation"
238)]
239pub fn contact_forces(
240 contacts: &[Collision],
241 state: &State,
242 materials: &[ContactMaterial],
243 body_velocities: Option<&[SpatialVec]>,
244) -> Vec<SpatialVec> {
245 let nbodies = state.body_xform.len();
246 let mut forces = vec![SpatialVec::zero(); nbodies];
247
248 for contact in contacts {
249 let i = contact.body_i;
250 let j = contact.body_j;
251
252 // Get material (use first body's material, or default)
253 let material = if materials.is_empty() {
254 &ContactMaterial::default()
255 } else {
256 &materials[i.min(materials.len() - 1)]
257 };
258
259 // Extract linear velocities from spatial velocities
260 let vel_i = body_velocities
261 .and_then(|vels| vels.get(i))
262 .map(|v| v.linear)
263 .unwrap_or(Vec3::zeros());
264
265 let vel_j = if j == Collision::WORLD {
266 // Ground — zero velocity
267 Vec3::zeros()
268 } else {
269 body_velocities
270 .and_then(|vels| vels.get(j))
271 .map(|v| v.linear)
272 .unwrap_or(Vec3::zeros())
273 };
274
275 // Same convention adaptation as `contact_forces_implicit`:
276 // `compute_contact_force` reads `contact_normal` as pointing from `i`
277 // toward `j` and returns the force on `j`, while
278 // `Collision::contact_normal` is now the direction `i` separates
279 // along. The ground branch already agreed and is untouched; the body
280 // pair flips the normal back for the call.
281 let adapted;
282 let (query, on_i) = if j == Collision::WORLD {
283 (contact, true)
284 } else {
285 adapted = Collision {
286 contact_normal: -contact.contact_normal,
287 ..contact.clone()
288 };
289 (&adapted, false)
290 };
291 #[allow(deprecated)]
292 let force = crate::compute_contact_force(query, material, &vel_i, &vel_j);
293 let f_linear = force.linear;
294
295 // Apply equal and opposite forces AT THE CONTACT POINT; the torque
296 // component is `τ = r × F` with `r` from the body frame origin to the
297 // contact point in world frame. For ground contacts only body i is
298 // updated.
299 let r_i = contact.contact_point - state.body_xform[i].pos;
300 if on_i {
301 forces[i] = forces[i] + SpatialVec::new(r_i.cross(f_linear), f_linear);
302 } else {
303 let r_j = contact.contact_point - state.body_xform[j].pos;
304 forces[i] = forces[i] + SpatialVec::new(r_i.cross(-f_linear), -f_linear);
305 forces[j] = forces[j] + SpatialVec::new(r_j.cross(f_linear), f_linear);
306 }
307 }
308
309 forces
310}
311
312/// Compute contact forces with implicit damping for low-mass-body stability.
313///
314/// Like [`contact_forces`], but the per-contact wrench is computed by
315/// [`crate::compute_contact_force_implicit`] which uses the body velocity at
316/// the END of the step. This makes the contact solve unconditionally stable
317/// for any choice of `dt`, contact stiffness, and damping — preventing the
318/// "low-mass cube launches off the plate" failure mode of the explicit form.
319///
320/// `masses[i]` is the effective contact mass of body `i`. Use
321/// `f64::INFINITY` for fixed/world bodies. For ground contacts
322/// ([`Collision::WORLD`]) the ground is treated as having infinite mass.
323#[deprecated(
324 note = "penalty contact is superseded by the convex solve (`assemble` + \
325 `solve_contacts`), which couples contacts through the Delassus \
326 operator instead of treating each in isolation"
327)]
328pub fn contact_forces_implicit(
329 contacts: &[Collision],
330 state: &State,
331 materials: &[ContactMaterial],
332 body_velocities: Option<&[SpatialVec]>,
333 masses: &[f64],
334 dt: f64,
335) -> Vec<SpatialVec> {
336 let nbodies = state.body_xform.len();
337 let mut forces = vec![SpatialVec::zero(); nbodies];
338
339 for contact in contacts {
340 let i = contact.body_i;
341 let j = contact.body_j;
342
343 let material = if materials.is_empty() {
344 &ContactMaterial::default()
345 } else {
346 &materials[i.min(materials.len() - 1)]
347 };
348
349 let vel_i = body_velocities
350 .and_then(|vels| vels.get(i))
351 .map(|v| v.linear)
352 .unwrap_or(Vec3::zeros());
353
354 let vel_j = if j == Collision::WORLD {
355 Vec3::zeros()
356 } else {
357 body_velocities
358 .and_then(|vels| vels.get(j))
359 .map(|v| v.linear)
360 .unwrap_or(Vec3::zeros())
361 };
362
363 let mass_i = masses.get(i).copied().unwrap_or(f64::INFINITY);
364 let mass_j = if j == Collision::WORLD {
365 f64::INFINITY
366 } else {
367 masses.get(j).copied().unwrap_or(f64::INFINITY)
368 };
369
370 // `compute_contact_force_implicit` predates the unified normal
371 // convention: it reads `contact_normal` as pointing from `i` toward
372 // `j` and returns the force on `j`. For a ground contact `j` is the
373 // world and the caller has always applied the result to `i`, which
374 // happens to agree with today's convention — so that path is left
375 // exactly as it was. For a body pair the two senses are opposite, so
376 // the normal is flipped back for the call and the result applied to
377 // `j` with its reaction on `i`.
378 let adapted;
379 let (query, on_i) = if j == Collision::WORLD {
380 (contact, true)
381 } else {
382 adapted = Collision {
383 contact_normal: -contact.contact_normal,
384 ..contact.clone()
385 };
386 (&adapted, false)
387 };
388 #[allow(deprecated)]
389 let force = crate::compute_contact_force_implicit(
390 query, material, &vel_i, &vel_j, mass_i, mass_j, dt,
391 );
392 let f_linear = force.linear;
393
394 let r_i = contact.contact_point - state.body_xform[i].pos;
395 if on_i {
396 forces[i] = forces[i] + SpatialVec::new(r_i.cross(f_linear), f_linear);
397 } else {
398 let r_j = contact.contact_point - state.body_xform[j].pos;
399 forces[j] = forces[j] + SpatialVec::new(r_j.cross(f_linear), f_linear);
400 forces[i] = forces[i] + SpatialVec::new(r_i.cross(-f_linear), -f_linear);
401 }
402 }
403
404 forces
405}
406
407/// Find contacts against a horizontal ground plane at `z = ground_height`.
408///
409/// Reads one **centred** shape per body. For bodies whose collision shapes
410/// live at offsets ([`phyz_model::Body::collisions`]) use
411/// [`find_ground_contacts_model`], which walks the full collision set and is
412/// what `phyz::Simulator` runs.
413///
414/// Shapes with a flat underside (boxes) report a contact per supporting
415/// corner, so a resting box has a real support polygon. Curved shapes report
416/// the single point they actually touch at.
417///
418/// The old implementation ignored body orientation entirely — it used the
419/// axis-aligned half-extent for depth and put one contact at the body centre's
420/// `(x, y)`. A tilted box therefore reported the wrong depth at a point that
421/// was not on the box, and no box could resist tipping.
422///
423/// # Margin
424///
425/// `margin` (metres) is the band *above* the plane in which a candidate still
426/// produces a contact, reported with a **negative** `penetration_depth` equal to
427/// minus its gap. Pass [`ContactMaterial::margin`] — that is the knob, and it is
428/// what [`ContactMaterial::combine`] already mixes with `max`. Pass `0.0` to get
429/// the old hard `p.z < ground_height` predicate back.
430///
431/// The predicate used to be exactly that hard cutoff, and that was a real bug
432/// rather than a cosmetic one. Contact here is *soft*: a candidate at exactly
433/// zero depth still has impedance `solimp.dmin` (0.9 by default), so it carries
434/// nearly its full share of the load right up to the instant it stops existing.
435/// On a K1 humanoid stance the least-loaded foot corner was measured carrying
436/// **22.3 N — 11% of body weight — on the step before it vanished**, and 0 N on
437/// the next. Total vertical force was conserved (the load redistributed to the
438/// remaining corners), so nothing leaked; but the contact *set* jumped, and it
439/// jumped underneath a balancing controller.
440///
441/// With a margin the candidate survives past the surface with a negative depth,
442/// and [`ContactMaterial::impedance_at`] tapers its impedance — and hence the
443/// normal force, which is linear in impedance — smoothly to zero at the band
444/// edge. Separated contacts get no stabilization bias and cannot pull, so this
445/// buys continuity without buying adhesion.
446///
447/// Candidates are still ranked deepest-first before the manifold cap, so the
448/// margin never lets a barely-separated point displace a genuinely loaded one.
449///
450/// [`ContactMaterial::margin`]: crate::material::ContactMaterial::margin
451/// [`ContactMaterial::combine`]: crate::material::ContactMaterial::combine
452/// [`ContactMaterial::impedance_at`]: crate::material::ContactMaterial::impedance_at
453pub fn find_ground_contacts(
454 state: &State,
455 geometries: &[Option<ModelGeometry>],
456 ground_height: f64,
457 margin: f64,
458) -> Vec<Collision> {
459 let mut contacts = Vec::new();
460 let margin = sanitize_margin(margin);
461 let cutoff = ground_height + margin;
462
463 for (i, geom_opt) in geometries.iter().enumerate() {
464 let Some(geom) = geom_opt else { continue };
465 let xform = &state.body_xform[i];
466 if !pos_is_finite(&xform.pos) || !rot_is_finite(&xform.rot) {
467 continue;
468 }
469 let Some(candidates) = ground_candidates(geom, xform) else {
470 continue;
471 };
472 emit_ground_hits(&mut contacts, i, candidates, ground_height, cutoff);
473 }
474
475 contacts
476}
477
478/// A negative margin would mean "ignore contacts that are already
479/// penetrating", which is never what a caller wants.
480fn sanitize_margin(margin: f64) -> f64 {
481 if margin.is_finite() {
482 margin.max(0.0)
483 } else {
484 0.0
485 }
486}
487
488/// How a ground support point is attached to the body that owns it.
489///
490/// Detection reports this per contact because the *gradient* of a contact
491/// depends on it: a support point that rides the body rigidly moves with
492/// `Rᵀ(q)`, one that hangs below a curved surface does not, and one on a
493/// cylinder's rim does neither — it moves with the axis. The convex adjoint
494/// (`phyz_diff::contact_adjoint`) reconstructs the support point from this,
495/// and getting it wrong is a silently wrong gradient rather than a failure.
496#[derive(Clone, Copy, Debug, PartialEq)]
497pub enum GroundSupport {
498 /// The support point is a material point of the body: a box corner, a mesh
499 /// vertex, or a cylinder rim point in the degenerate upright case.
500 Material,
501 /// The support hangs a *constant world vector* below a material point:
502 /// `(0, 0, −r)` for a sphere or capsule dropping onto a z-plane. Spinning
503 /// the body does not move it.
504 WorldOffset(Vec3),
505 /// A point on a cylinder's cap rim: `cap centre + radius · dir`, where
506 /// `dir` is the barrel's steepest-downhill direction `û` turned
507 /// `quarter_turns × 90°` about the axis (see [`cylinder_rim_dir`]).
508 ///
509 /// `axis` is the cylinder's axis in *world* coordinates at detection time.
510 /// It is a body-fixed direction, so an adjoint that wants to stay exact
511 /// under a perturbation freezes it in the body frame and recomputes `dir`
512 /// from the perturbed axis — `û` is a function of the configuration, and
513 /// holding it fixed drops the whole "the wheel banked, so its contact
514 /// moved around the rim" channel.
515 CylinderRim {
516 /// The cylinder's axis in world coordinates at detection time.
517 axis: Vec3,
518 /// The cylinder's radius.
519 radius: f64,
520 /// Which rim direction this contact sits on, in 90° steps from `û`.
521 quarter_turns: u8,
522 /// `radius · dir` as detection resolved it — the world offset, kept so
523 /// that callers who only want that do not have to recompute it, and so
524 /// that an adjoint has something total to fall back on if a perturbed
525 /// axis lands inside the degeneracy.
526 offset: Vec3,
527 },
528}
529
530impl GroundSupport {
531 /// The vector from the material point that generated the contact to the
532 /// support point, in world coordinates, at the configuration it was
533 /// detected in.
534 pub fn world_offset(&self) -> Vec3 {
535 match self {
536 Self::Material => Vec3::zeros(),
537 Self::WorldOffset(o) => *o,
538 Self::CylinderRim { offset, .. } => *offset,
539 }
540 }
541}
542
543/// One candidate ground-support point of a shape: its world position and how
544/// it is attached to the body.
545#[derive(Clone, Copy, Debug)]
546struct GroundCandidate {
547 point: Vec3,
548 support: GroundSupport,
549}
550
551impl GroundCandidate {
552 /// A support point that rides the body rigidly (box corner, mesh vertex).
553 fn material(point: Vec3) -> Self {
554 Self {
555 point,
556 support: GroundSupport::Material,
557 }
558 }
559}
560
561/// Below this `sin` of the axis-to-vertical angle (equivalently, this
562/// horizontal extent of the unit axis) the barrel's lowest line is numerically
563/// undefined and the cylinder is standing on a cap.
564///
565/// The two branches agree to `radius * CYL_AXIS_EPS` at the threshold — a
566/// cylinder within `1e-9` rad of upright — so the switch is a rounding event,
567/// not a step.
568pub const CYL_AXIS_EPS: f64 = 1e-9;
569
570/// The orthonormal rim basis of a cylinder whose world axis is `axis`:
571/// `(û, ŵ)`, both perpendicular to the axis, with `û` the steepest *downhill*
572/// direction on the barrel and `ŵ = â × û`.
573///
574/// `None` when the axis is within [`CYL_AXIS_EPS`] of vertical, where the
575/// barrel has no lowest line and the cylinder is standing on a cap instead.
576///
577/// `û` is `−(ẑ − (ẑ·â)â)` normalized, written componentwise as
578/// `(â_z â_x, â_z â_y, −(â_x² + â_y²))`. Not as `ẑ − (ẑ·â)â`: that form
579/// computes the vertical component as `1 − â_z²`, which for a nearly-upright
580/// cylinder subtracts two numbers that agree to every bit it has. At 1e-9 rad
581/// from vertical `cos` already rounds to exactly 1, the term evaluates to 0
582/// instead of 1e-18, and the support point loses its whole `r sin φ` drop — a
583/// 27 nm step in the depth of a 27 mm wheel. `â_x² + â_y²` is the same
584/// quantity with no cancellation in it.
585pub fn cylinder_rim_basis(axis: Vec3) -> Option<(Vec3, Vec3)> {
586 let rho2 = axis.x * axis.x + axis.y * axis.y;
587 if rho2.sqrt() <= CYL_AXIS_EPS {
588 return None;
589 }
590 let u = Vec3::new(axis.z * axis.x, axis.z * axis.y, -rho2).normalize();
591 Some((u, axis.cross(u)))
592}
593
594/// The rim direction `quarter_turns × 90°` around from `û`, for a cylinder
595/// whose world axis is `axis`. See [`cylinder_rim_basis`].
596///
597/// This is the one definition of where a cylinder's ground contacts sit;
598/// detection and the convex adjoint both go through it, so an anchor cannot
599/// drift from the detector that produced it.
600pub fn cylinder_rim_dir(axis: Vec3, quarter_turns: u8) -> Option<Vec3> {
601 cylinder_rim_basis(axis).map(|(u, w)| match quarter_turns & 3 {
602 0 => u,
603 1 => w,
604 2 => -u,
605 _ => -w,
606 })
607}
608
609/// Candidate ground-support points for one shape, in world coordinates.
610///
611/// `xform` follows the body-transform convention (`rot` is world→shape, `pos`
612/// is the shape origin in world) — for a shape mounted at an offset inside a
613/// body, compose the body transform with the shape's `GeomInstance::origin`
614/// first; [`shape_world_xform`] does exactly that.
615///
616/// Returns `None` for planes, which cannot rest on the ground plane.
617///
618/// # Cylinders contact along a line
619///
620/// A cylinder's ground contact is its lowest **generator line**, not a set of
621/// sampled rim points. With axis `â` (the shape's local `z` in world), centre
622/// `c`, radius `r` and half-height `h`, the steepest downhill direction on the
623/// barrel is
624///
625/// ```text
626/// û = −normalize(ẑ − (ẑ·â)â)
627/// ```
628///
629/// and the line's two ends are `c ± hâ + r·û`, each carrying its own depth:
630/// two equal depths when the axis is level, one end deeper when it tilts.
631///
632/// The previous implementation sampled each end cap's rim at four *body-frame*
633/// angles, which rotate with the wheel and never include the barrel point that
634/// actually touches. A rolling cylinder's effective radius then ripples by
635/// `r(1 − cos 45°) = 0.293 r` once per quarter turn — 7.9 mm on a 27 mm
636/// skateboard wheel, which is to say the wheel was a square (ipse #233,
637/// `GAP_CYLINDER_GROUND`). The analytic line has no ripple at all: the support
638/// point sits exactly `r` below the axis for every spin angle.
639///
640/// The three remaining rim directions of each cap are still emitted, at
641/// **90°, 180° and 270° from `û`** rather than at fixed body angles. Those are
642/// the cap's own support polygon, and they matter for exactly one
643/// configuration: a cylinder standing on end, where the whole rim touches and
644/// a single point could not resist tipping. In every other pose they are
645/// `r sin φ (1 − cos θ)` above the plane and the margin filter drops them, so
646/// they cost eight transforms and change nothing. Sampling them *from* `û`
647/// rather than from the body frame is what keeps the deepest candidate exact:
648/// direction 0 **is** the lowest line, so the polygon never displaces it.
649fn ground_candidates(
650 geom: &ModelGeometry,
651 xform: &phyz_math::SpatialTransform,
652) -> Option<Vec<GroundCandidate>> {
653 // `SpatialTransform::rot` is the *world→shape* rotation; the
654 // direction-carrying `SpatialTransformExt` methods used below exist so
655 // this file never has to hand-roll the transpose again. (Using `rot`
656 // directly here once rotated the offsets the wrong way — invisible at
657 // identity, inverted as soon as the body tilted.)
658 let pos = xform.pos;
659 let candidates: Vec<GroundCandidate> = match geom {
660 ModelGeometry::Box { half_extents } => {
661 let h = half_extents;
662 let mut v = Vec::with_capacity(8);
663 for sx in [-1.0, 1.0] {
664 for sy in [-1.0, 1.0] {
665 for sz in [-1.0, 1.0] {
666 v.push(GroundCandidate::material(
667 xform.body_to_world_point(Vec3::new(sx * h.x, sy * h.y, sz * h.z)),
668 ));
669 }
670 }
671 }
672 v
673 }
674 ModelGeometry::Sphere { radius } => {
675 let drop = Vec3::new(0.0, 0.0, -*radius);
676 vec![GroundCandidate {
677 point: pos + drop,
678 support: GroundSupport::WorldOffset(drop),
679 }]
680 }
681 ModelGeometry::Capsule { radius, length } => {
682 // The two hemisphere centres, each dropped by the radius.
683 let axis = xform.body_to_world_dir(Vec3::new(0.0, 0.0, length * 0.5));
684 let drop = Vec3::new(0.0, 0.0, -*radius);
685 vec![
686 GroundCandidate {
687 point: pos + axis + drop,
688 support: GroundSupport::WorldOffset(drop),
689 },
690 GroundCandidate {
691 point: pos - axis + drop,
692 support: GroundSupport::WorldOffset(drop),
693 },
694 ]
695 }
696 ModelGeometry::Cylinder { radius, height } => {
697 let axis = xform.body_to_world_dir(Vec3::z());
698 let half = axis * (height * 0.5);
699 let mut v = Vec::with_capacity(8);
700 match cylinder_rim_basis(axis) {
701 // The barrel has a lowest line: rim direction 0 is `û`, so
702 // candidate 0 of each cap is one end of that line, and 1/2/3
703 // are the rest of the cap's support polygon.
704 Some((u, w)) => {
705 for cap_sign in [1.0, -1.0] {
706 let cap = pos + half * cap_sign;
707 for (quarter_turns, dir) in [u, w, -u, -w].into_iter().enumerate() {
708 let offset = dir * *radius;
709 v.push(GroundCandidate {
710 point: cap + offset,
711 support: GroundSupport::CylinderRim {
712 axis,
713 radius: *radius,
714 quarter_turns: quarter_turns as u8,
715 offset,
716 },
717 });
718 }
719 }
720 }
721 // Standing on a cap. `û` is undefined, and it is also not
722 // needed: with the cap parallel to the ground every rim point
723 // is a contact, and a rim point taken along the shape's own
724 // x/y **is** a material point of the body — the exact anchor,
725 // not an approximation of one. This is the previous sampler,
726 // reproduced for the one pose it was right for.
727 None => {
728 let ex = xform.body_to_world_dir(Vec3::x()) * *radius;
729 let ey = xform.body_to_world_dir(Vec3::y()) * *radius;
730 for cap_sign in [1.0, -1.0] {
731 let cap = pos + half * cap_sign;
732 for dir in [ex, ey, -ex, -ey] {
733 v.push(GroundCandidate::material(cap + dir));
734 }
735 }
736 }
737 }
738 v
739 }
740 ModelGeometry::Mesh { vertices, .. } => vertices
741 .iter()
742 .map(|v| GroundCandidate::material(xform.body_to_world_point(*v)))
743 .collect(),
744 ModelGeometry::Plane { .. } => return None,
745 };
746 Some(candidates)
747}
748
749/// The world transform of a shape mounted at `origin` inside a body at
750/// `body_xform`, in the same convention as the body transform itself
751/// (`rot` world→shape, `pos` shape origin in world).
752///
753/// `origin` follows [`phyz_model::GeomInstance::origin`]: `origin.pos` is the
754/// shape origin in body coordinates and `origin.rot` is body→shape.
755fn shape_world_xform(
756 body_xform: &phyz_math::SpatialTransform,
757 origin: &phyz_math::SpatialTransform,
758) -> phyz_math::SpatialTransform {
759 phyz_math::SpatialTransform::new(
760 origin.rot.mul_mat(&body_xform.rot),
761 body_xform.body_to_world_point(origin.pos),
762 )
763}
764
765/// Filter, rank, cap and emit one body's candidate points as ground contacts.
766///
767/// Keep the points within `margin` of the plane, deepest first, capped at a
768/// manifold. `depth` is signed: positive is penetration, negative is a gap
769/// inside the margin band. The comparison is strict, so a point at exactly
770/// `margin` is already excluded — and by then its impedance has already
771/// ramped to zero, which is what makes the exclusion a no-op rather than a
772/// step.
773fn emit_ground_hits(
774 contacts: &mut Vec<Collision>,
775 body: usize,
776 candidates: Vec<GroundCandidate>,
777 ground_height: f64,
778 cutoff: f64,
779) {
780 let mut hits: Vec<(f64, Vec3)> = candidates
781 .into_iter()
782 .map(|c| c.point)
783 .filter(|p| p.z.is_finite() && p.z < cutoff)
784 .map(|p| (ground_height - p.z, p))
785 .collect();
786 hits.sort_by(|a, b| b.0.total_cmp(&a.0));
787 hits.truncate(phyz_collision::MAX_MANIFOLD_POINTS);
788
789 for (depth, p) in hits {
790 contacts.push(Collision {
791 body_i: body,
792 body_j: Collision::WORLD, // Ground is not a body
793 // On the midsurface between the vertex and the plane. This
794 // stays correct for a negative depth: the midpoint simply sits
795 // above the plane rather than below it.
796 contact_point: Vec3::new(p.x, p.y, ground_height - depth * 0.5),
797 contact_normal: Vec3::z(),
798 penetration_depth: depth,
799 });
800 }
801}
802
803/// [`find_ground_contacts`] over the model's *full* collision set.
804///
805/// Where `find_ground_contacts` reads one centred shape per body (the legacy
806/// [`phyz_model::Body::geometry`] field), this walks
807/// [`phyz_model::Body::collisions`] — every shape, each at its own offset and
808/// orientation inside the body — falling back to `geometry` only for bodies
809/// whose collision list is empty. A humanoid whose URDF places a trunk box
810/// 15 cm above the link origin finally *has* a trunk, instead of a phantom
811/// shape centred on the hip.
812///
813/// Candidates from all of a body's shapes are ranked together and capped at
814/// the same per-body manifold as before, so a single-shape body produces
815/// bit-identical contacts through either entry point.
816pub fn find_ground_contacts_model(
817 model: &Model,
818 state: &State,
819 ground_height: f64,
820 margin: f64,
821) -> Vec<Collision> {
822 find_ground_contacts_model_with_support(model, state, ground_height, margin)
823 .into_iter()
824 .map(|(c, _)| c)
825 .collect()
826}
827
828/// [`find_ground_contacts_model_with_support`], reporting only the *world-axis
829/// drop* — how far the support point hangs below its material point along
830/// world `−ẑ`, which is `−offset.z`.
831///
832/// Kept for callers that predate the richer form. It is exact for spheres and
833/// capsules, whose offset is vertical by construction, and for a level
834/// cylinder, whose lowest line hangs straight down; it loses the horizontal
835/// component of a *tilted* cylinder's rim offset, and it cannot express that a
836/// cylinder rim point moves with the axis at all. Prefer
837/// [`find_ground_contacts_model_with_support`].
838pub fn find_ground_contacts_model_with_drop(
839 model: &Model,
840 state: &State,
841 ground_height: f64,
842 margin: f64,
843) -> Vec<(Collision, f64)> {
844 find_ground_contacts_model_with_support(model, state, ground_height, margin)
845 .into_iter()
846 .map(|(c, s)| (c, -s.world_offset().z))
847 .collect()
848}
849
850/// [`find_ground_contacts_model_with_support`], reporting only the world
851/// offset at the detected configuration.
852pub fn find_ground_contacts_model_with_offset(
853 model: &Model,
854 state: &State,
855 ground_height: f64,
856 margin: f64,
857) -> Vec<(Collision, Vec3)> {
858 find_ground_contacts_model_with_support(model, state, ground_height, margin)
859 .into_iter()
860 .map(|(c, s)| (c, s.world_offset()))
861 .collect()
862}
863
864/// [`find_ground_contacts_model`], with each contact's [`GroundSupport`]: how
865/// the support point is attached to the body that owns it.
866///
867/// The convex adjoint uses it to pin a contact to the body-frame feature that
868/// produced it. The support point of a curved shape does not ride the body
869/// rigidly, and pretending it does is exactly the error that makes a rolling
870/// wheel square. With several shapes per body the producing shape is no longer
871/// recoverable from the body alone, so detection reports it.
872pub fn find_ground_contacts_model_with_support(
873 model: &Model,
874 state: &State,
875 ground_height: f64,
876 margin: f64,
877) -> Vec<(Collision, GroundSupport)> {
878 let margin = sanitize_margin(margin);
879 let cutoff = ground_height + margin;
880 let mut out = Vec::new();
881
882 for (i, body) in model.bodies.iter().enumerate() {
883 let Some(xform) = state.body_xform.get(i) else {
884 continue;
885 };
886 if !pos_is_finite(&xform.pos) || !rot_is_finite(&xform.rot) {
887 continue;
888 }
889
890 // One candidate pool per body: shapes compete for the same manifold
891 // slots, exactly as a single shape's own corners already did.
892 let mut pool: Vec<(f64, Vec3, GroundSupport)> = Vec::new();
893 let mut push_shape = |geom: &ModelGeometry, sx: &phyz_math::SpatialTransform| {
894 let Some(candidates) = ground_candidates(geom, sx) else {
895 return;
896 };
897 pool.extend(
898 candidates
899 .into_iter()
900 .filter(|c| c.point.z.is_finite() && c.point.z < cutoff)
901 .map(|c| (ground_height - c.point.z, c.point, c.support)),
902 );
903 };
904
905 if body.collisions.is_empty() {
906 if let Some(geom) = &body.geometry {
907 push_shape(geom, xform);
908 }
909 } else {
910 for inst in &body.collisions {
911 push_shape(&inst.geometry, &shape_world_xform(xform, &inst.origin));
912 }
913 }
914
915 pool.sort_by(|a, b| b.0.total_cmp(&a.0));
916 pool.truncate(phyz_collision::MAX_MANIFOLD_POINTS);
917
918 for (depth, p, support) in pool {
919 out.push((
920 Collision {
921 body_i: i,
922 body_j: Collision::WORLD, // Ground is not a body
923 contact_point: Vec3::new(p.x, p.y, ground_height - depth * 0.5),
924 contact_normal: Vec3::z(),
925 penetration_depth: depth,
926 },
927 support,
928 ));
929 }
930 }
931
932 out
933}
934
935/// [`find_ground_contacts_model`] against a [`Heightfield`] instead of a
936/// flat plane.
937///
938/// Each candidate support point `p` is tested against the terrain surface
939/// directly below it: with `h = hf.height(p.x, p.y)` and `n` the surface
940/// normal there, the signed depth along the normal is `n.z · (h − p.z)` —
941/// positive inside the terrain, negative in the margin band above it. The
942/// contact normal is the terrain normal, so a box on a uniform slope feels
943/// exactly the tilted plane it is standing on.
944///
945/// # Small-slope assumption
946///
947/// Support candidates are still selected with world `−ẑ` as the "down"
948/// direction (candidate selection is shared with the flat path), not the
949/// local terrain normal. For the shallow terrain a walking or skating robot
950/// trains on — a few degrees of ramp, centimetre bumps — the two directions
951/// select the same feature; on a steep wall a curved shape's reported
952/// support point would be off by the slope angle. Vertical-face terrain is
953/// out of scope for this detector.
954///
955/// On a [`Heightfield::flat`] field this reproduces
956/// [`find_ground_contacts_model`] bit for bit: the normal is `+ẑ`, the
957/// depth reduces to `h − p.z`, and the contact point to the same
958/// midsurface.
959pub fn find_heightfield_contacts_model(
960 model: &Model,
961 state: &State,
962 hf: &Heightfield,
963 margin: f64,
964) -> Vec<Collision> {
965 find_heightfield_contacts_model_with_support(model, state, hf, margin)
966 .into_iter()
967 .map(|(c, _)| c)
968 .collect()
969}
970
971/// [`find_heightfield_contacts_model`], with each contact's world-axis drop
972/// (see [`find_ground_contacts_model_with_drop`]).
973pub fn find_heightfield_contacts_model_with_drop(
974 model: &Model,
975 state: &State,
976 hf: &Heightfield,
977 margin: f64,
978) -> Vec<(Collision, f64)> {
979 find_heightfield_contacts_model_with_support(model, state, hf, margin)
980 .into_iter()
981 .map(|(c, s)| (c, -s.world_offset().z))
982 .collect()
983}
984
985/// [`find_heightfield_contacts_model`], with each contact's [`GroundSupport`]
986/// (see [`find_ground_contacts_model_with_support`]).
987pub fn find_heightfield_contacts_model_with_support(
988 model: &Model,
989 state: &State,
990 hf: &Heightfield,
991 margin: f64,
992) -> Vec<(Collision, GroundSupport)> {
993 let margin = sanitize_margin(margin);
994 let mut out = Vec::new();
995
996 for (i, body) in model.bodies.iter().enumerate() {
997 let Some(xform) = state.body_xform.get(i) else {
998 continue;
999 };
1000 if !pos_is_finite(&xform.pos) || !rot_is_finite(&xform.rot) {
1001 continue;
1002 }
1003
1004 // One candidate pool per body, exactly as the flat path: shapes
1005 // compete for the same manifold slots. Entries carry the terrain
1006 // normal alongside depth, point and drop.
1007 let mut pool: Vec<(f64, Vec3, Vec3, GroundSupport)> = Vec::new();
1008 let mut push_shape = |geom: &ModelGeometry, sx: &phyz_math::SpatialTransform| {
1009 let Some(candidates) = ground_candidates(geom, sx) else {
1010 return;
1011 };
1012 for c in candidates {
1013 let p = c.point;
1014 if !(p.z.is_finite() && p.x.is_finite() && p.y.is_finite()) {
1015 continue;
1016 }
1017 let n = hf.normal(p.x, p.y);
1018 let depth = n.z * (hf.height(p.x, p.y) - p.z);
1019 if depth > -margin {
1020 pool.push((depth, p, n, c.support));
1021 }
1022 }
1023 };
1024
1025 if body.collisions.is_empty() {
1026 if let Some(geom) = &body.geometry {
1027 push_shape(geom, xform);
1028 }
1029 } else {
1030 for inst in &body.collisions {
1031 push_shape(&inst.geometry, &shape_world_xform(xform, &inst.origin));
1032 }
1033 }
1034
1035 pool.sort_by(|a, b| b.0.total_cmp(&a.0));
1036 pool.truncate(phyz_collision::MAX_MANIFOLD_POINTS);
1037
1038 for (depth, p, n, support) in pool {
1039 out.push((
1040 Collision {
1041 body_i: i,
1042 body_j: Collision::WORLD, // Terrain is not a body
1043 // Midsurface between the support point and the terrain,
1044 // along the normal — the heightfield generalization of
1045 // the flat path's `ground_height - depth/2`.
1046 contact_point: p + n * (depth * 0.5),
1047 contact_normal: n,
1048 penetration_depth: depth,
1049 },
1050 support,
1051 ));
1052 }
1053 }
1054
1055 out
1056}