Skip to content

Phase 1 / Forge / Narrowphase: EPA + contact manifold#56

Merged
guysenpai merged 25 commits into
mainfrom
phase-1/forge/narrowphase-epa-manifold
Jul 20, 2026
Merged

Phase 1 / Forge / Narrowphase: EPA + contact manifold#56
guysenpai merged 25 commits into
mainfrom
phase-1/forge/narrowphase-epa-manifold

Conversation

@guysenpai

Copy link
Copy Markdown
Contributor

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.md

What landed

  • E1 package splitpipeline/narrowphase.zigpipeline/narrowphase/{support,gjk,epa,manifold,root}.zig (pure relocation; supportingFace + FROZEN ContactManifold/ContactPoint types).
  • E2 EPA (epa.zig) — expanding-polytope penetration over the Minkowski difference of the cores in A's frame, seeded off the GJK .deep terminal simplex; world EpaResult{normal A→B, core depth, closest_a/b}. Faces oriented outward from the polytope centroid (the depth-0 seed fix).
  • E3 manifold (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-point penetration = r_sum − s; reduce-to-4 in A's frame.
  • E4 integrationBodyManager.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 —

  • kept incident vertex → (class_a face, class_a vertex)
  • edge×plane crossing → (class_edge side-plane, class_edge edge)
  • reference corner → (class_c ref-vertex, class_c inc-face)
  • point/segment-core witness & single-contact fallback → (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_min threshold test + capsule-endpoint / capsule-pair tests. Frame-stable only via BodyManager.collidePair (fixed body-id order); the bare collide is pose-canonical (RD-4).

Validation — every gate, f32 AND -Dphysics_f64=true

Config test-forge-3d
debug f32 / f64 71/71 · 71/71
ReleaseSafe f32 / f64 71/71 · 71/71

zig build lint ✓ · zig fmt --check ✓ · zig build ✓ · zig build test (full suite) exit 0 ✓

§3.6.1 close audits

  • Language (FR): empty — no French prose in added lines (diff 921d4558..HEAD); the lone accent hit façadefacade normalized.
  • Drift: no orphans. packFeatureId fully removed (0 live refs; 1 historical brief mention); narrowphase.zig appears 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 (epa carries pos_a/rot_a for world output), RD-3 (spec patches KB-only), RD-4 (collide bit-identical-pose order exception, ratified).

Out of scope (later, not debt)

Pair-type fast paths + the extreme-aspect (>~30:1) radius-0 box .deep GJK-f32 limit → M1.1.4; CollisionConfig object-layer wiring, PhysicsModule/stepping instantiation → M1.1.15.

🤖 Generated with Claude Code

guysenpai and others added 25 commits July 19, 2026 02:22
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>
@guysenpai
guysenpai merged commit 57d64ea into main Jul 20, 2026
10 checks passed
@guysenpai
guysenpai deleted the phase-1/forge/narrowphase-epa-manifold branch July 20, 2026 09:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant