Phase 1 / Forge / Narrowphase: EPA + contact manifold#56
Merged
Conversation
Pure relocation, zero GJK logic change: pipeline/narrowphase.zig becomes the package pipeline/narrowphase/ (support.zig + gjk.zig + root.zig facade). The support shapes, relative pose, and minkowskiSupport (now pub) move to support.zig; Simplex/gjk/GjkResult and the descent helpers stay in gjk.zig. Consumers change only their import path; the relocated GJK acceptance suite (tests/gjk_test.zig) passes byte-identical in behavior (debug + ReleaseSafe + -Dphysics_f64=true).
E1 additions on the relocated narrowphase package: - support.zig: Face(T) + SupportShape.supportingFace(dir) (point -> 1 vertex, segment -> 1 endpoint end-on else the 2-vertex segment, box -> the winning quad wound CCW) + RelativePose.supportingFaceB(dir) (mirror of supportB at the feature grain). supportingFace(dir) returns the feature maximizing p.dir (contains support(dir)); it is what the E3 clipper takes as supportingFace(+n) on A and supportingFace(-n) on B. - manifold.zig: FROZEN ContactManifold(T)/ContactPoint(T) with the world-space A->B normal, per-point penetration >= 0, and the M1.1.6 feature_id convention. Types + convention only; the generator lands at E3. - root.zig facade + forge_3d re-exports extended; both new files pinned. Inline acceptance tests (feature/count matrix; manifold round-trip incl. f64). Suite 47/47 across debug/ReleaseSafe x f32/f64; lint/fmt/zig build clean.
Authorized frozen-section amendment (Guy, E1 GO): the supporting feature and the reference face are 'most parallel' to the query direction, not 'anti-parallel' — a drafting error (the feature maximizes dir.p and contains support(dir); the E3 supportingFace(+n)/(-n) clipping requires it). Recorded as RD-1 (drafting-error correction, M1.1.2 precedent). No code change.
epa.zig: expanding-polytope over the Minkowski difference of the cores in A's frame, seeded off the GJK terminal .deep simplex, mapped to world via rot_a/pos_a (mirror of gjk.zig). Fixed-capacity (allocation-free), deterministic (no hash, fixed face order, max_epa_iterations=32, all divisions guarded). Faces are oriented outward from the polytope centroid (interior point), not from the origin: a .deep seed can be a triangle coplanar with the origin (a point inside a box), where origin-side orientation stalls on a zero-distance base face whose only expansion support is the already-added apex. A genuinely <3-D difference (crossing capsules, point-on-segment) yields a unit separation normal and depth 0 (the deep<->shallow boundary). Signature carries pos_a/rot_a beyond the frozen relpose (brief RD-2): EpaResult is world-space per the frozen prose + E2 kickoff, not expressible otherwise. Tests: analytic axis+depth (sphere/box, box/box axis-aligned + oriented, box/capsule), order-independence, low-dim seed expansion, determinism+bounded. Suite 51/51 across debug/ReleaseSafe x f32/f64; lint/fmt/zig build clean.
Adversarial read-only review of epa.zig found four robustness gaps (no wrong answer on any tested case; all in eccentric/degenerate configs). Fixed in-gate: - F1: fan faces inherit the horizon winding (makeWoundFace) instead of reorienting against the fixed interior centroid, whose sign test can flip for a face near-tangent to the interior. interior now orients only the 4 initial tetra faces (volumetric -> robust). Standard EPA construction. - F2: a sliver fan face bails cleanly (keeps best) rather than leaving a topological hole that corrupts the manifold invariant twinAmongVisible relies on. - F3: on the iteration-cap exit best is refreshed against the final grown polytope (was one expansion stale), guarded by expanded_ok so a bailed break keeps its valid pre-expansion face. - F4: loop tolerances recomputed from the expanded polytope scale (the seed scale can be 0 for an origin-coincident low-dim seed). New eccentric thin-box deep-overlap test exercises F1. Suite 52/52 across debug/ReleaseSafe x f32/f64; lint/fmt/zig build clean. F5 (degenerate-touch normal not A->B sign-tied) resolved at E3 where the manifold enforces order-independence.
Ratified frozen-section amendment (Guy, E2 GO): the E2 Scope epa signature carries pos_a/rot_a in addition to relpose, so EpaResult can be world-space (normal + closest points mapped via rot_a/pos_a) as the frozen prose requires. RD-2 confirmed. Title shortened from Guy's wording to fit the 72-char lint limit. No code change.
manifold.zig: collide(T, shapes, poses) ?ContactManifold — GJK then a single supporting-face clipping generator for both regimes. Runs in A's frame: the world contact normal (shallow = normalize(closest_b − closest_a), guarded at dist≈0; deep = EpaResult.normal) is converted once via conj(rot_a), supportingFace(+n_a) on A / supportingFace(−n_a) on B clipped by Sutherland-Hodgman, points reduced to ≤4, inflation applied, normal+points mapped to world once. Reference = the face most aligned with the contact axis (non-polygon → 0; tie A). Per-point penetration r_sum − s, continuous shallow↔deep. Point-core (sphere / end-on capsule) → 1 point from the closest points. Order-independence (hard requirement + F5): collide computes the pair in a caller-independent canonical order (poseAfter, a strict total order on pose+shape) and negates the normal for the swapped caller — same points/depths, negated normal, even for degenerate depth-0 configs. Adversarial read-only review found three real bugs (untested regimes), all fixed in-gate: (A) poseAfter box key used |half_extents| → distinct equal-magnitude boxes tied → canonical order ill-defined; (F) reduceToFour 4th point used distance-from-triangle-plane, ≈0 for the coplanar contact set → duplicated corner on rotated box/box; (E) 2-vertex segment through closed-polygon SH double-emitted the crossing → now an open Liang-Barsky segment clip. Tests cover all three. tests/manifold_test.zig (pinned): box/box face→4, rotated box/box→4 distinct, feature matrix, staggered capsule→2 distinct, order-independence (incl. degenerate + equal-|he| boxes), shallow↔deep continuity. Suite 58/58 across debug/ReleaseSafe x f32/f64; lint/fmt/zig build clean.
body_manager.zig: collidePair(store, a, b) ?ContactManifold(Real) — the BodyId- level manifold adapter, exact mirror of gjkPair (resolves poses + support shapes, calls collide, null on a stale handle or a separated pair). forge_3d/root.zig: Real-bound re-exports — EpaResult, max_epa_iterations, ContactManifold, ContactPoint, epa (wrapper), collide (wrapper) — mirroring gjk. Integration test: a mixed scene through BodyManager + Broadphase(Real); computePairs → unpack user_data as BodyId → collidePair per candidate. A fat-AABB false positive → null, a genuine overlap → a valid manifold (per-point depth checked), a stale-handle pair → null. Suite 59/59 across debug/ReleaseSafe x f32/f64; lint/fmt/zig build clean.
External pre-closure review (Codex), verified against source: 4 real defects (2 P1, 2 P2), all fixed with trigger tests. P1a edge-edge depth: penetration was measured along the reference face normal rn (correct in face-face, wrong in edge-edge where the contact axis n_a is an edge-cross no box quad aligns with). Dispatch on |rn·n_a|: face-face keeps the quad clip, edge/vertex routes to the witness-point contact with depth along n_a. P1b frame-stable feature_id: the clip-buffer index reordered under a tiny pose change. Face now carries stable local ids (box vertex = sign pattern, box face = axis*2+sign); the clipper threads (ref-feature<<16 | incident-feature) per point, surviving reduceToFour. P2a order-independence on identical geometry: poseAfter cannot order bit-identical shape+pose. collidePair now drives a canonical body-id order (min first) and negates for the swapped caller; poseAfter's degenerate exception is documented. P2b reduction in A's frame: reduceToFour candidates were stored/dedup'd in world space, collapsing tiny features far from the origin; now kept in A's frame, reduced there, mapped to world only for the chosen <=4 points. Trigger tests: edge-edge depth ~ EPA depth; feature_id stable under +-1e-4 shift; collidePair body-id order on identical bodies; 4-point face manifold at 1e5 in f32. Suite 63/63 across debug/ReleaseSafe x f32/f64; lint/fmt/zig build clean.
Second external review (Codex), verified against source: 2 P1 feature_id / order-independence defects, fixed with trigger tests. P1a unique incident-edge id: intersectionFid keyed the incident edge by @min(cur,nxt), which aliases distinct box edges (a vertex is on 3 edges) — two simultaneous contacts collided at 0x800e8002. Now the edge is the sorted vertex pair lo*8+hi (unique among the 12 box edges), and an endpoint that is already an intersection carries its original incident-edge provenance through the multi-plane Sutherland-Hodgman passes. P1b body-id order must reach the generator: collidePairOrdered called collide, which re-canonicalizes by pose, so feature_id ref/inc ownership flipped across a lexicographic pose boundary. collideOrdered (the fixed-order generator) is now pub and re-exported at Real; collidePairOrdered calls it in the received body-id order, keeping feature_id frame-stable; collidePair's normal negation still gives order-independence. Public collide keeps pose canonicalization unchanged. poseAfter's degenerate-exception doc now cites the ratified RD-4 contract. Trigger tests: the P1a repro -> four distinct feature_ids; collidePair across the pose-order boundary -> unchanged feature_ids. Suite 65/65 across debug/ReleaseSafe x f32/f64; lint/fmt/zig build clean.
Ratified FROZEN-section amendment (Guy, second E4-close review, RD-4): the order-independence note carries an exception for two shapes with bit-identical pose AND geometry — the A→B axis is geometrically undefined (measure-zero), so collide returns the same deterministic normal in both orders; BodyManager's collidePair restores full order-independence for real bodies via a canonical body-id order. Recorded deviation RD-4 added; the collide/poseAfter code now cite a ratified exception rather than a unilateral note. Title shortened to fit the 72-char lint limit. No behavior change.
TEST-STAGGER (Guy): axis-aligned staggered box/box whose overlap-rectangle corners include a REFERENCE corner (a point on two reference side planes — an intersection whose endpoint is already an intersection), exercising incidentEdgeId's provenance-inheritance branch. Asserts count == 4, all four feature_ids pairwise distinct (the reference corner does not alias an edge×plane contact), and frame-stable under a ±0.0001 lateral shift. Green ⇒ provenance inheritance validated; no fallback needed. Drift audit (§3.6.1 b): patched a stale 'narrowphase.zig' file reference in a gjk_test.zig comment to 'gjk.zig' (the file is now the narrowphase package). Suite 66/66 across debug/ReleaseSafe x f32/f64; lint/fmt/zig build clean.
Third external review (Codex): reference-corner feature_id aliasing.
incidentEdgeId blindly inherited a neighbour's incident-edge id for a clip point
on TWO reference side planes, but such a point is a REFERENCE feature (a corner
of the reference face inside the incident face), not an incident edge — distinct
contacts aliased (Codex: 4792 cases, 0x800b8025 shared).
Re-encoded feature_id as a class-tagged (reference_feature, incident_feature)
pair: reference class in {face, side-plane, corner}, incident class in {vertex,
edge, face}, each kind in a disjoint id range (top two bits). The clipper detects
a reference cut-edge (both endpoints share a reference plane, via commonRefPlane
over the plane set each point lies on) and emits a REFERENCE CORNER
(ref_vertex(P_prev,P_cur), inc_face) instead of inheriting an incident edge; a
genuine crossing stays (ref_edge, inc_edge); a kept vertex stays (ref_face,
inc_vertex). incidentEdgeId defensively self-pairs a stray non-vertex endpoint so
it can never alias a real edge.
TEST POSE-SWEEP: a grid of deep box/box contacts (yaw x tilt x offsets x overlaps
+ the two Codex repros) asserting (a) pairwise-distinct feature_ids and (b) a
frame-stable id set under a +-1e-4 offset via collidePair's body-id order; (b) is
gated on a stable normal (a tied min-penetration axis legitimately flips the
contact under a 1e-4 nudge).
DOC (Codex P2.2/P2.3): the manifold header + public collide doc carry the RD-4
exception and state that a frame-stable feature_id is via collidePair (body-id
order), not bare collide (pose-canonicalized, flips at a pose boundary).
Suite 67/67 across debug/ReleaseSafe x f32/f64; lint/fmt/zig build clean.
Execution-log entry for the third external review round: FIX-9 (reference-corner feature_id class-tagged (ref,inc) re-encoding), the pose-sweep verification, and the RD-4 / collidePair frame-stability doc alignment.
Fourth external review (Codex): the FIX-9 reference-corner feature_id encoded its incident half as a constant class_c with no inc.face_id, so two corners at the same reference vertex on DIFFERENT incident faces (a supporting-face flip inter-frame) shared a feature_id (Codex: 0x82328000 for incident faces 5 and 0). Zero same-frame duplicates (sweep of 131,751 manifolds), but a warm-start (M1.1.6) inter-frame mis-match. inc.face_id is now threaded clipIncident -> clipPolygon/clipSegment -> clipAgainstPlane -> intersectionFid; a reference corner emits (ref_vertex(P_prev,P_cur), class_c | inc_face_id), so its incident half carries which incident face and a face flip changes the id. Verification: a flip test (same staggered overlap, B upright vs rolled 90 about X -> same reference vertex, different incident face -> different corner feature_id) + a pose-sweep assertion that every reference corner's low half is class_c + a real face id (0..5). Suite 68/68 across debug/ReleaseSafe x f32/f64; lint/fmt/zig build clean.
Execution-log entry for the fourth external review round: FIX-10 threads inc.face_id into the reference-corner feature_id so a supporting-face flip changes the id (frame-stable inter-frame matching for M1.1.6 warm-starting).
Fifth external review (Codex): the three pointCoreContact paths (point core, edge/vertex fallback, degenerate empty clip) emitted feature_id via the untagged packFeatureId, landing in the same class_a range as a clip kept vertex — so a single-contact fallback id could equal a clip id for the same body pair across the face_face_min transition (Codex: 0x00050004 as (ref face 5, inc vertex 4) clipped AND (ref face 5, inc face 4) fallback, 0.001 rad apart). A single witness contact's incident feature is the incident FACE, so it now emits (class_a | ref_face_id, class_c | inc_face_id) via singleContactFid; the (class_a, class_c) class pair is used by no clip id (kept vertex (a,a), edge (edge,edge), corner (c,c)), so a fallback id can never alias a clip id for a body pair. packFeatureId (the last untagged packer) is removed. Tests: a threshold test scanning a tilt range to find, at the build precision (face_face_min 0.999 f32 / 0.9999 f64), both a clipped and a fallback box/box at one base pose, asserting the fallback id equals none of the clip ids; plus a pose-sweep assertion that no multi-point manifold carries the (class_a, class_c) fallback pair. Suite 69/69 across debug/ReleaseSafe x f32/f64; lint/fmt/zig build clean.
Execution-log entry for the fifth external review round: FIX-11 class-tags the single-contact fallback feature_id (class_a, class_c) — a class pair disjoint from every clip id — and removes the last untagged packer.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
M1.1.3 — Forge 3D narrowphase: EPA + contact manifold
Fourth M1.1 (Forge 3D) sub-milestone. Delivers the narrowphase back-half — EPA penetration + the supporting-face clipping contact-manifold generator — seeded off the M1.1.2 GJK.
Brief (source of truth):
briefs/M1.1.3-narrowphase-epa-manifold.mdWhat landed
pipeline/narrowphase.zig→pipeline/narrowphase/{support,gjk,epa,manifold,root}.zig(pure relocation;supportingFace+ FROZENContactManifold/ContactPointtypes).epa.zig) — expanding-polytope penetration over the Minkowski difference of the cores in A's frame, seeded off the GJK.deepterminal simplex; worldEpaResult{normal A→B, core depth, closest_a/b}. Faces oriented outward from the polytope centroid (the depth-0 seed fix).manifold.zig) —collide()/collideOrdered(), one generator for both regimes (shallow ← GJK closest points, deep ← EPA), entirely in A's frame: face-face by supporting-face clipping (Sutherland-Hodgman), segment by open-segment clip (Liang-Barsky), edge/vertex/point-core by a single witness contact along the EPA axis; per-pointpenetration = r_sum − s; reduce-to-4 in A's frame.BodyManager.collidePair/collidePairOrdered+Real-bound re-exports.feature_id— hardened across an extended external-review cycle (FIX-1..12)Final form: five class-tagged producers in disjoint class-pair ranges (16-bit halves, top-2-bit class), each half encoding its real sub-feature —
(class_a face, class_a vertex)(class_edge side-plane, class_edge edge)(class_c ref-vertex, class_c inc-face)(class_a, class_c)(a pair used by no multi-point clip producer)Uniqueness proven by a 131,751-manifold pose-sweep (zero same-frame duplicates) + a
face_face_minthreshold test + capsule-endpoint / capsule-pair tests. Frame-stable only viaBodyManager.collidePair(fixed body-id order); the barecollideis pose-canonical (RD-4).Validation — every gate, f32 AND
-Dphysics_f64=truezig build lint✓ ·zig fmt --check✓ ·zig build✓ ·zig build test(full suite) exit 0 ✓§3.6.1 close audits
921d4558..HEAD); the lone accent hitfaçade→facadenormalized.packFeatureIdfully removed (0 live refs; 1 historical brief mention);narrowphase.zigappears only in historical relocation-note header comments; all named symbols (singleContactFid/refVertexId/commonRefPlane/collideOrdered/intersectionFid) defined + referenced consistently; build-clean.Deviations (see brief § Recorded deviations)
RD-1 (
supportingFace"parallel" wording), RD-2 (epacarriespos_a/rot_afor world output), RD-3 (spec patches KB-only), RD-4 (collidebit-identical-pose order exception, ratified).Out of scope (later, not debt)
Pair-type fast paths + the extreme-aspect (>~30:1) radius-0 box
.deepGJK-f32 limit → M1.1.4;CollisionConfigobject-layer wiring,PhysicsModule/stepping instantiation → M1.1.15.🤖 Generated with Claude Code