Skip to main content

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}