From 08ce705db1dcfc2706878e5192b4e09ef71a4002 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Mon, 20 Jul 2026 17:23:37 +0200 Subject: [PATCH 01/24] docs(forge): add M1.1.4 narrowphase fast paths brief --- briefs/M1.1.4-narrowphase-fast-paths.md | 141 ++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 briefs/M1.1.4-narrowphase-fast-paths.md diff --git a/briefs/M1.1.4-narrowphase-fast-paths.md b/briefs/M1.1.4-narrowphase-fast-paths.md new file mode 100644 index 0000000..4d31242 --- /dev/null +++ b/briefs/M1.1.4-narrowphase-fast-paths.md @@ -0,0 +1,141 @@ +# M1.1.4 — Forge 3D narrowphase fast paths + +> **Status:** PLANNED +> **Phase:** 1 +> **Branch:** `phase-1/forge/narrowphase-fast-paths` +> **Planned tag:** `v0.11.4-narrowphase-fast-paths` +> **Dependencies:** M1.1.3 (narrowphase EPA + contact manifold — `pipeline/narrowphase/` package, FROZEN `ContactManifold`/`ContactPoint`, class-tagged `feature_id` scheme) +> **Opened:** 2026-07-20 +> **Closed:** — + +--- + +# FROZEN SECTION + +*Produced by Claude.ai. Not modifiable by Claude Code outside a Claude.ai round-trip (cf. § Recorded deviations).* + +## Context + +M1.1.3 delivered the generic narrowphase contact path (GJK → shallow/deep → one supporting-face clipping generator, `generateManifold`) with a hardened class-tagged `feature_id` scheme. This milestone adds the four analytic per-pair fast paths the plan freezes at `engine-phase-1-plan.md` line 160 — sphere/sphere, sphere/box, capsule/capsule, box/box — each reproducing the generic manifold, faster, and each robust where generic GJK/EPA is not (the P1d extreme-aspect radius-0 box classification limit documented in `engine-physics-forge.md §315`, deferred to this milestone). It is the fifth core sub-milestone of the M1.1 rigid arc; the `feature_id`s produced here are the warm-start-grade identities the demo's fast-pathed pairs will carry into M1.1.6. + +## Scope + +The central architectural decision (Guy-approved): **a fast path never re-implements manifold assembly.** Each kernel computes only the seed the generic path's GJK/EPA block already computes — `ContactSeed { normal (world, A→B), closest_a, closest_b (world core witnesses), base_penetration }` — and feeds the UNCHANGED `generateManifold`. Consequence: the five `feature_id` producers, the clipping, the `≤4` reduction, the per-point penetration, order-independence, and frame-stability are all inherited by construction; the only fast-vs-generic difference is the seed, which the differential test targets. Fast paths are dispatched from `collideOrdered` (fixed order), so `collide`'s pose canonicalization + `collidePair`'s BodyId order wrap them identically to the generic path. + +- **E1 — Generator fix + `ContactSeed` foundation + dispatcher + generic oracle.** + - **(a) Generator fix (pre-existing M1.1.3 defect exposed by scoping, fixed here — the oracle must be valid before any differential).** In `generateManifold`, a `segment × segment` contact where the two segments are NOT parallel is an edge-edge contact and must yield a SINGLE witness contact (like FIX-1 for polygonal edge-edge), NOT a 2-point clip. Today `faceNormalA` returns the expected axis for a `count < 2`-or-`< 3` feature, so a 2-vertex segment reference forces `rn = n_a`; FIX-1's `|rn·n_a| < face_face_min` never trips, and `clipSegment` against a segment reference (whose two side planes constrain only along the reference's own axis) can retain both endpoints of a non-parallel incident segment → two geometrically wrong points. Add an explicit `segment × segment non-parallel → single witness` rule (a named parallelism threshold of the same `k·floatEps(T)·coordScale` noise family, symmetric under A/B swap). The 2-point manifold stays RESERVED to the parallel-projection-overlap regime (T138 preserved). Cover with a closed-form crossed-capsule test (written first, RED on the current generator), the nearly-parallel transition band, and `half_height == 0` (sphere-degenerate capsule). + - **(b) `ContactSeed` + generic oracle + dispatcher (no-op).** Introduce `ContactSeed` and a three-state dispatcher `fastSeed(T, shape_a, pos_a, rot_a, shape_b, pos_b, rot_b) → union(enum){ not_handled, separated, contact: ContactSeed }` in a new sibling `fast_paths.zig` (imports `foundation` math + `support.zig` ONLY — no cycle: `manifold.zig` imports `fast_paths.zig`, never the reverse). Make `generateManifold` file-`pub` (NOT re-exported by the public façade). Extract the existing GJK/EPA seed block of `collideOrdered` into a `collideOrderedGeneric` entry (the bypass-dispatcher oracle, re-exported at package `root.zig` and at `forge_3d/root.zig` `Real` for tests + bench). Wire `fastSeed` at the top of `collideOrdered`: `.not_handled` → fall through to `collideOrderedGeneric`; `.separated` → `null`; `.contact` → build `relpose`, call `generateManifold` with the seed. At E1 the dispatcher returns `.not_handled` for every pair (no kernel yet). Non-regression: the full existing suite is green (proves the extraction + the generator fix change nothing on non-crossed cases). A box core in any candidate fast pair must have `radius == 0` (forge_3d invariant); a rounded box (`radius > 0`, package-level `roundedBoxShape` only) returns `.not_handled` so the existing rounded-box cases stay on the generic path. + +- **E2 — sphere/sphere + sphere/box (point-core pairs).** Two analytic kernels producing `ContactSeed`, both with a `separated` short-circuit (no GJK). sphere/sphere: separation from center distance vs `r_sum`; normal `normalize(cb − ca)` (deterministic fallback axis at coincidence, matching the generic `fallbackNormal`). sphere/box: clamp the sphere center to the box core; outside → normal from center-to-closest; inside (deep) → least-penetration face axis (closed-form, robust at any aspect). Both route to `generateManifold` (point-core branch → 1 contact; inflation via `r_sum`). Handle BOTH orderings (sphere/box and box/sphere) with the seed's normal + witness ownership following the received A/B order (the feature_id halves swap accordingly — audited). Tests: differential pose-sweep vs `collideOrderedGeneric` in both orders (fast ≡ generic within a named tolerance on normal/points/depth, exact `count`, exact `feature_id` away from alignment ties + class-structure + uniqueness); touch-exact; sphere internal to the box; coincident centers; separated → `null`. **P1d point/box HERE:** a sphere deep inside a >50:1 box (up to the 212:1 `gjk_test.zig` regime) yields a correct manifold via the fast path, validated by a CLOSED-FORM oracle (the generic path is documented invalid at this aspect, so it cannot be the oracle there). + +- **E3 — box/box SAT.** Analytic separating-axis test over the 15 candidate axes (3 A-face normals + 3 B-face normals + 9 edge×edge cross products), degeneracy-guarded (near-zero cross → skip axis), producing the min-penetration axis + depth + witness → `ContactSeed`. `generateManifold` then does the identical supporting-face clip (a face axis → face-face clip = same 5 producers; an edge-edge axis → FIX-1 single witness). Gated on box `radius == 0` (rounded box → `.not_handled`). Tests: differential pose-sweep vs `collideOrderedGeneric` **bounded to ≤30:1** (the regime where the oracle is reliable) + a DEDICATED closed-form box/box extreme-aspect oracle (>50:1; the point/box P1d does not prove the box/box SAT). Face-face, edge-edge, and face-vertex regimes each exercised; both orders. + +- **E4 — capsule/capsule (segment/segment).** Closest-segment analytics (Ericson RTCD §5.1.9 lineage) → `ContactSeed`, across three regimes routed through the E1-corrected generator: end-on (a count-1 endpoint feature → single point-core contact); crossed / non-parallel (single witness along the contact axis, now correct via E1); parallel-projection overlap (2-point line contact via `clipSegment`). Tests: differential pose-sweep vs `collideOrderedGeneric` over all three regimes (reusing the E1 crossed oracle for the crossed regime), exact `count` per regime, feature_id class-structure + uniqueness + both orders; nearly-parallel band deterministic. + +- **E5 — Consolidation + close.** (1) A consolidated `feature_id` audit test: the producer × pair × A/B-order matrix — every fast pair, both orders, across the deep/shallow/edge/vertex/face-face regimes reachable per pair, asserting class-tagged disjoint ranges, unique-per-manifold, real-sub-feature encoding (box vertex sign-pattern 0..7, box face `axis·2+sign`, segment endpoint 0/1, point 0), and frame-stability of the id SET under a ±1e-4 shift via a fixed (BodyId-equivalent) order. (2) `bench/forge_narrowphase.zig`: per-pair ReleaseFast microbench, the REAL dispatched `collideOrdered` vs `collideOrderedGeneric` on the SAME pre-computed pose set, contact + separated inputs, an accumulated checksum to defeat dead-code elimination, results per pair. (3) `CLAUDE.md` §3.4 patch (content produced by Claude.ai at the E5 review, applied by CC on-branch). (4) Close. Full suite green at f32 AND `-Dphysics_f64=true`, debug + ReleaseSafe. + +## Out of scope + +- **capsule/box and sphere/capsule fast paths.** NOT in the plan's frozen four (line 160). They stay on the generic path (correct, if not the fastest). capsule/box is a candidate additive milestone AFTER real profiling of the demo — do not build it here even though it is a frequent demo contact. +- **Rounded-box fast paths.** Any box core with `radius > 0` returns `.not_handled`. The SAT/clamp kernels are radius-0-box only; the generic path keeps rounded boxes. +- **Any change to GJK or EPA classification/thresholds.** The fast paths BYPASS the P1d-limited GJK classification for their pairs; they do NOT fix GJK. The `gjk deep is reliable to moderate box aspect ratio` test stays at ≤30:1 unchanged; no GJK/EPA source is touched. +- **Warm-start consumption.** `feature_id`s are produced (and their contract proven) here; the Sequential-Impulses warm-start that reads them is M1.1.6. +- **Stepping / solver / island manager / `PhysicsModule` instantiation** (M1.1.5+); **raycast/shapecast/point queries** (M1.1.9–10); **shapes beyond sphere/box/capsule** — Plane/MeshShape (M1.1.11); **`forge_2d`** (M1.8). +- **SIMD / batched pair processing.** Scalar analytic kernels only. +- **Spec file edits on-branch.** Per `engine-development-workflow.md §3.5`, the spec lives only in the Claude.ai KB. The `engine-physics-forge.md §303` reformulation (the "au bit près" → "géométriquement équivalent à tolérances nommées / bandes de bascule" correction) is produced by Claude.ai as a complete re-uploadable file at the E5 review; CC edits NO spec file. +- **Absolute per-pair ns performance targets.** The bench proves *dispatched < generic oracle per pair* (relative); absolute C1.1 perf targets land with stepping (M1.1.5+), per the M1.1.3 precedent. + +## Specs to read first + +1. `engine-physics-forge.md` — §303 "Narrowphase — régimes GJK et contact manifold" (the fast-path clause, the `feature_id` definition, order-independence), §315 (the P1d extreme-aspect radius-0 box `.deep` deferral to M1.1.4), §1.5–1.6 (`Real` scalar / f32 default / f64 flag). +2. `engine-phase-1-plan.md` — the M1.1.4 row (line ~160) and the M1.1 rigid-arc context (~150: comptime core, `PhysicsModule` freeze at M1.1.15; M1.1.6 warm-start consumes `feature_id`). +3. `engine-zig-conventions.md` — Zig 0.16.x conventions (mandatory: no `@cImport` outside `*_c` modules, no `usingnamespace`, `unreachable` only on proven invariants, English doc comments on public API, naming). + +## Files to create or modify + +- `src/modules/forge/forge_3d/pipeline/narrowphase/fast_paths.zig` — **create** — `ContactSeed`, the three-state `fastSeed` dispatcher, and the four analytic kernels (sphere/sphere, sphere/box, capsule/capsule, box/box SAT). Imports `foundation` (math) + `support.zig` only. +- `src/modules/forge/forge_3d/pipeline/narrowphase/manifold.zig` — **modify** — (a) the `segment × segment non-parallel → single witness` generator fix; (b) `generateManifold` → file-`pub`; (c) extract `collideOrderedGeneric` (the GJK/EPA seed oracle, bypasses the dispatcher); (d) wire `fast_paths.fastSeed` at the top of `collideOrdered`. +- `src/modules/forge/forge_3d/pipeline/narrowphase/root.zig` — **modify** — pin `fast_paths.zig` in the comptime block; re-export `collideOrderedGeneric` (+ `ContactSeed` if a test needs the type). `generateManifold` stays package-internal (not re-exported). +- `src/modules/forge/forge_3d/root.zig` — **modify** — pin `tests/fast_paths_test.zig`; re-export `collideOrderedGeneric` at `Real` (for the bench + tests). +- `src/modules/forge/forge_3d/tests/fast_paths_test.zig` — **create** — the per-pair differential sweeps, the P1d point/box closed-form, the box/box extreme-aspect closed-form, the separated short-circuits, and the consolidated feature_id producer×pair×order matrix. +- `src/modules/forge/forge_3d/tests/manifold_test.zig` — **modify** — the E1 generic-generator crossed-capsule closed-form test (→ 1 point), the nearly-parallel band, and `half_height == 0`; T138 (parallel → 2 points) preserved. +- `bench/forge_narrowphase.zig` — **create** — per-pair dispatched-vs-generic-oracle ReleaseFast microbench (contact + separated, checksum anti-DCE, per-pair results). +- `build.zig` — **modify** — wire the `forge_narrowphase` bench (module + executable + `bench-forge-narrowphase` step), mirroring the existing bench-wiring pattern. +- `CLAUDE.md` — **modify** — §3.4 update (E5; patch content produced by Claude.ai at review). + +## Acceptance criteria + +### Tests + +Named tolerances: the fast-vs-generic differential tolerance is calibrated to the generic path's EPA convergence residual (looser than an analytic-vs-exact tolerance) and documented at the test site; it is NOT `1e-6`. `feature_id` equality is EXACT (integer) away from alignment/axis ties. + +- `tests/fast_paths_test.zig` — `test "sphere/sphere differential vs generic"` — pose-sweep, both orders: fast ≡ generic within tol (normal/points/depth), exact `count`, exact `feature_id` away from ties, class-structure valid, unique-per-manifold; separated → `null`. +- `tests/fast_paths_test.zig` — `test "sphere/box differential vs generic"` — as above, both orders; touch-exact; coincident centers. +- `tests/fast_paths_test.zig` — `test "sphere/box P1d deep extreme aspect (closed-form)"` — a sphere deep inside a >50:1 (up to ~212:1) radius-0 box → correct manifold via the fast path, asserted against a closed-form oracle (generic invalid at this aspect). +- `tests/fast_paths_test.zig` — `test "box/box SAT differential vs generic (<=30:1)"` — pose-sweep bounded ≤30:1, both orders, face-face/edge-edge/face-vertex regimes; fast ≡ generic as above. +- `tests/fast_paths_test.zig` — `test "box/box SAT extreme aspect (closed-form)"` — a >50:1 box/box deep overlap → correct manifold against a closed-form oracle. +- `tests/fast_paths_test.zig` — `test "capsule/capsule differential vs generic (three regimes)"` — end-on (1 pt), crossed/non-parallel (1 pt), parallel-overlap (2 pts); fast ≡ generic per regime, exact `count`, both orders. +- `tests/fast_paths_test.zig` — `test "rounded box falls through to generic"` — a `radius > 0` box in a fast pair → dispatcher `.not_handled`, manifold identical to `collideOrderedGeneric`. +- `tests/fast_paths_test.zig` — `test "feature_id producer x pair x order matrix"` — the consolidated audit: every fast pair × both orders × reachable regimes → class-tagged disjoint ranges, unique-per-manifold, real sub-feature encoding, id-set frame-stable under a ±1e-4 shift (fixed order). +- `tests/manifold_test.zig` — `test "crossed capsules yield a single witness contact"` — non-parallel segment/segment → `count == 1`, depth along the contact axis (closed-form); RED on the pre-fix generator. +- `tests/manifold_test.zig` — `test "nearly-parallel capsules transition deterministically"` — the parallel/crossed boundary band is deterministic (no flip under re-run); `test "degenerate capsule (half_height == 0) behaves as a sphere"`. +- All tests green in `debug` AND `ReleaseSafe`, at f32 AND `-Dphysics_f64=true`. + +### Benchmarks + +- `bench/forge_narrowphase.zig` — per-pair ns/pair, dispatched `collideOrdered` vs `collideOrderedGeneric`, contact + separated inputs — target: **dispatched strictly faster than the generic oracle for every fast pair** (ratio reported and archived). ReleaseFast; checksum accumulated to prevent dead-code elimination; results per pair. + +### Observable behavior + +- `zig build test-forge-3d` green (f32 + f64, debug + ReleaseSafe). +- `zig build bench-forge-narrowphase` runs and prints per-pair ns + the dispatched/generic ratio (and writes a results file per the bench convention). + +### CI + +- `zig build` clean, zero warnings, on the configured matrix +- `zig build test` green (debug + ReleaseSafe), including the `-Dphysics_f64=true` local sweep +- `zig fmt --check` green +- `zig build lint` green +- `commit-msg` hook green on every commit of the branch + +## Conventions + +- **Branch:** `phase-1/forge/narrowphase-fast-paths` +- **Final tag:** `v0.11.4-narrowphase-fast-paths` +- **PR title:** `Phase 1 / Forge / Forge 3D narrowphase fast paths` +- **Commit convention:** Conventional Commits (cf. `engine-development-workflow.md §4.3`) +- **Merge strategy:** squash-and-merge (cf. `engine-development-workflow.md §4.6`) + +## Notes + +- **Why the seed architecture.** The M1.1.3 `feature_id` whack-a-mole (FIX-1..12) came from bespoke manifold assembly. Routing every fast path through the unchanged `generateManifold` means the five producers, clipping, reduction, order-independence, and frame-stability are inherited verbatim — the fast path only supplies a better/faster/robuster `(normal, witnesses, depth)` than GJK/EPA. The speedup is the skipped GJK descent + EPA polytope expansion; `generateManifold`'s supporting-face clip is cheap and analytic (fine at any aspect once fed a good normal). +- **Equivalence, not bit-exactness.** An analytic path cannot bit-reproduce an iterative GJK/EPA; the fast path is in fact MORE accurate. The differential asserts geometric equivalence within a named tolerance on normal/points/depth, exact `count` and `feature_id` OUTSIDE documented topological-flip bands (SAT-axis ties, the face-face/fallback `face_face_min` seam, the capsule parallel/crossed transition, the reference/incident alignment tie), and — where the generic oracle is documented invalid (P1d extreme aspect) — a closed-form oracle instead. This is the `engine-physics-forge.md §303` reformulation Claude.ai delivers at close. +- **Dispatch is deterministic by shape-core tag**, so a fast pair always takes its fast path (never alternates fast/generic across frames). Inter-frame `feature_id` stability for warm-starting is therefore the fast path's own frame-to-frame stability (inherited from `generateManifold` + BodyId order), not a fast-vs-generic match. +- **P1d.** Two manifestations: point/box (a sphere/point core in a sharp box — the `gjk_test.zig` 212:1 case) and box/box. The former closes in E2 (sphere/box clamp), the latter in E3 (box/box SAT). Both bypass the GJK classification the P1d limit lives in. +- **The E1 generator fix is a pre-existing M1.1.3 defect** exposed by scoping (0 regression introduced), fixed here because (i) the capsule/capsule fast path routes through `generateManifold` and would inherit it, and (ii) the differential oracle must be geometrically valid before equivalence is asserted. Not empirically reproduced pre-brief (no Zig 0.16 toolchain in the authoring environment); the E1 crossed-capsule test is written RED-first to prove it. + +--- + +# LIVING SECTION + +*Maintained by Claude Code during the milestone. The log is not a marketing report: it serves review and post-mortem debugging.* + +## Specs read + +- [ ] `engine-physics-forge.md` (§303, §315, §1.5–1.6) — read +- [ ] `engine-phase-1-plan.md` (M1.1.4 row, M1.1 arc) — read +- [ ] `engine-zig-conventions.md` — read + +## Execution log + +## Recorded deviations + +## Blockers encountered + +## Closing notes + +- **What worked:** +- **What deviated from the original spec:** +- **What to flag explicitly in review:** +- **Final measurements** (per-pair dispatched/generic ratios, compile time, whatever is relevant): +- **Residual risks / tech debt left intentionally:** From dc5dd662d20034476bd79b7ab5b0f2e8f81be8a8 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Mon, 20 Jul 2026 17:38:57 +0200 Subject: [PATCH 02/24] fix(forge): single witness for non-parallel segment contacts --- .../pipeline/narrowphase/manifold.zig | 37 ++++++++++ .../forge/forge_3d/tests/manifold_test.zig | 73 +++++++++++++++++++ 2 files changed, 110 insertions(+) diff --git a/src/modules/forge/forge_3d/pipeline/narrowphase/manifold.zig b/src/modules/forge/forge_3d/pipeline/narrowphase/manifold.zig index ed637a4..8a07019 100644 --- a/src/modules/forge/forge_3d/pipeline/narrowphase/manifold.zig +++ b/src/modules/forge/forge_3d/pipeline/narrowphase/manifold.zig @@ -195,6 +195,20 @@ fn generateManifold( return pointCoreContact(T, n_world, closest_a, closest_b, r_a, r_b, base_penetration, singleContactFid(T, face_a, face_b)); } + // Segment × segment (M1.1.4): a NON-parallel (crossed) segment/segment pair + // is an edge-edge contact → a SINGLE witness contact along the EPA/GJK axis, + // NOT a 2-point clip. A segment reference forces `rn = n_a` (`faceNormalA` + // returns the axis for a count-2 feature), so FIX-1's `|rn·n_a|` test never + // trips, and `clipSegment` against a segment reference — whose two side + // planes constrain only along the reference's OWN axis — can retain both + // endpoints of a non-parallel incident segment (two geometrically wrong + // points). The 2-point manifold stays RESERVED to the parallel-projection + // overlap regime (two side-by-side capsules); a degenerate zero-length + // segment (a `half_height == 0` capsule) is a point, never that regime. + if (face_a.count == 2 and face_b.count == 2 and !segmentsParallel(T, face_a, face_b)) { + return pointCoreContact(T, n_world, closest_a, closest_b, r_a, r_b, base_penetration, singleContactFid(T, face_a, face_b)); + } + // Reference/incident by alignment with the contact axis (non-polygon → 0; tie A). const n_a_face = faceNormalA(T, face_a, n_a); // ≈ +n_a const n_b_face = faceNormalA(T, face_b, n_a.neg()); // ≈ −n_a @@ -310,6 +324,29 @@ fn singleContactFid(comptime T: type, ref_face: support.Face(T), inc_face: suppo return featureId(class_a | (feat_ref & id_mask), class_c | (feat_inc & id_mask)); } +/// Whether two count-2 segment features are PARALLEL, non-degenerate line +/// segments — the sole regime where a 2-point segment clip is a correct manifold +/// (parallel-projection overlap, e.g. two side-by-side capsules). A zero-length +/// segment (a `half_height == 0` capsule → a point) or a non-parallel (crossed) +/// pair returns false, so the caller takes the single-witness path (M1.1.4). +/// +/// Threshold: `sin²θ = |u×v|² / (|u|²·|v|²) ≤ parallel_rel`, the same relative +/// float-noise form as `support.supportingFace`'s end-on `aligned_rel` (not a +/// magic geometric angle). Symmetric under an A/B swap by construction — both +/// `|u×v|²` and `|u|²·|v|²` are invariant when `u` and `v` are exchanged. +fn segmentsParallel(comptime T: type, fa: support.Face(T), fb: support.Face(T)) bool { + const u = fa.verts[1].sub(fa.verts[0]); + const v = fb.verts[1].sub(fb.verts[0]); + const uu = u.dot(u); + const vv = v.dot(v); + // A degenerate (zero-length) segment is a point, never a parallel line pair + // (NaN-safe: a non-finite length also falls through to the single witness). + if (!(uu > 0) or !(vv > 0)) return false; + const cr = u.cross(v); + const parallel_rel: T = if (T == f32) 1.0e-6 else 1.0e-12; + return cr.dot(cr) <= parallel_rel * uu * vv; +} + /// Outward normal of a face in A's frame: the polygon normal oriented toward /// `expected_axis` for a real face (≥ 3 verts); the axis itself as a proxy for a /// point / segment feature (which has no face normal). diff --git a/src/modules/forge/forge_3d/tests/manifold_test.zig b/src/modules/forge/forge_3d/tests/manifold_test.zig index 5a71748..0cc209a 100644 --- a/src/modules/forge/forge_3d/tests/manifold_test.zig +++ b/src/modules/forge/forge_3d/tests/manifold_test.zig @@ -147,6 +147,79 @@ test "staggered capsule segment clips without a duplicate point" { for (0..m.count) |i| try testing.expectApproxEqAbs(@as(Real, 0.2), m.points[i].penetration, tol); // r_sum(1) − dist(0.8) } +test "crossed capsules yield a single witness contact" { + // M1.1.4/E1 (RED on the pre-fix generator): a segment × segment contact whose + // axes are NOT parallel is an edge-edge crossing → ONE witness contact along + // the contact axis, not a 2-point segment clip. Pre-fix the segment reference + // forces `rn = n_a`, so FIX-1's |rn·n_a| test never trips and `clipSegment` + // retains BOTH endpoints of the crossed incident segment (count == 2, wrong). + // + // Closed-form: A is a Y-axis capsule at the origin; B is an X-axis capsule + // (Y-capsule yawed 90° about Z) centred at (0,0,0.5). The cores' closest + // approach is (0,0,0)↔(0,0,0.5), distance 0.5 < r_sum 0.6 ⇒ shallow, normal + // +Z, penetration 0.6 − 0.5 = 0.1, contact at the surface midpoint (0,0,0.25). + const cap = capsuleShape(1, 0.3); + const zrot = Quatr.fromAxisAngle(Vec3r.unit_z, std.math.pi / 2.0); + const m = collide(cap, vr(0, 0, 0), Quatr.identity, cap, vr(0, 0, 0.5), zrot).?; + try testing.expectEqual(@as(u8, 1), m.count); + try testing.expect(m.normal.approxEql(vr(0, 0, 1), tol)); + try testing.expectApproxEqAbs(@as(Real, 0.1), m.points[0].penetration, tol); + try testing.expect(m.points[0].position.approxEql(vr(0, 0, 0.25), tol)); +} + +test "nearly-parallel capsules transition deterministically" { + // M1.1.4/E1: the parallel↔crossed classification is a pure function of the + // pose (no accumulated state), so re-running any config gives a byte-identical + // manifold — no flip under re-run. Sweep a small yaw band straddling the + // parallel threshold; every config is stable, and the point count only ever + // takes the two allowed values (2 = parallel line, 1 = crossed witness). + const cap = capsuleShape(1, 0.4); + var a: Real = 0; + while (a <= 0.20 + 1.0e-9) : (a += 0.01) { + const rot = Quatr.fromAxisAngle(Vec3r.unit_z, a); + // Cores 0.5 apart in Z, r_sum 0.8 ⇒ a genuine overlap at every angle. + const m1 = collide(cap, vr(0, 0, 0), Quatr.identity, cap, vr(0, 0, 0.5), rot).?; + const m2 = collide(cap, vr(0, 0, 0), Quatr.identity, cap, vr(0, 0, 0.5), rot).?; + // Deterministic: identical count, points, penetration, ids on re-run. + try testing.expectEqual(m1.count, m2.count); + try testing.expect(m1.count == 1 or m1.count == 2); + for (0..m1.count) |i| { + try testing.expect(m1.points[i].position.approxEql(m2.points[i].position, 1.0e-6)); + try testing.expectEqual(m1.points[i].penetration, m2.points[i].penetration); + try testing.expectEqual(m1.points[i].feature_id, m2.points[i].feature_id); + } + } + // Exactly parallel (yaw 0) is the 2-point regime; a clear 0.2 rad yaw is a + // crossed single witness. + try testing.expectEqual(@as(u8, 2), collide(cap, vr(0, 0, 0), Quatr.identity, cap, vr(0, 0, 0.5), Quatr.identity).?.count); + const crossed = Quatr.fromAxisAngle(Vec3r.unit_z, 0.2); + try testing.expectEqual(@as(u8, 1), collide(cap, vr(0, 0, 0), Quatr.identity, cap, vr(0, 0, 0.5), crossed).?.count); +} + +test "degenerate capsule (half_height == 0) behaves as a sphere" { + // M1.1.4/E1: a `half_height == 0` capsule has a zero-length segment core — + // geometrically a sphere. Its supporting feature is a count-2 segment with + // both verts coincident, which `segmentsParallel` treats as degenerate (a + // point), so the pair takes the single-witness path just like a sphere. + const degen = capsuleShape(0, 0.5); // sphere of radius 0.5 + const cap = capsuleShape(1, 0.5); // real Y-axis capsule + // Degenerate capsule beside the real capsule's side: cores 0.8 apart in X, + // r_sum 1.0 ⇒ 1 contact, normal +X, penetration 0.2, at midpoint (0.4,0,0). + const m = collide(degen, vr(0, 0, 0), Quatr.identity, cap, vr(0.8, 0, 0), Quatr.identity).?; + try testing.expectEqual(@as(u8, 1), m.count); + try testing.expect(m.normal.approxEql(vr(1, 0, 0), tol)); + try testing.expectApproxEqAbs(@as(Real, 0.2), m.points[0].penetration, tol); + try testing.expect(m.points[0].position.approxEql(vr(0.4, 0, 0), tol)); + + // Two degenerate capsules behave as sphere/sphere: cores 1.2 apart, r_sum 1.0 + // ⇒ separated → null (matches sphere/sphere at the same separation). + try testing.expect(collide(degen, vr(0, 0, 0), Quatr.identity, degen, vr(1.2, 0, 0), Quatr.identity) == null); + // Overlapping (0.6 apart, r_sum 1.0) ⇒ 1 contact, penetration 0.4. + const mo = collide(degen, vr(0, 0, 0), Quatr.identity, degen, vr(0.6, 0, 0), Quatr.identity).?; + try testing.expectEqual(@as(u8, 1), mo.count); + try testing.expectApproxEqAbs(@as(Real, 0.4), mo.points[0].penetration, tol); +} + test "intersection feature ids are unique among simultaneous contacts" { // Codex P1a repro: two unit boxes, B offset into a corner with a small yaw so // the manifold points are edge×plane intersections. The old `@min(edge)` key From 084aeafabffa0e7d2c90566df6a8749e42b93aa6 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Mon, 20 Jul 2026 17:44:08 +0200 Subject: [PATCH 03/24] refactor(forge): add fast-path dispatcher and generic oracle --- .../pipeline/narrowphase/fast_paths.zig | 122 ++++++++++++++++++ .../pipeline/narrowphase/manifold.zig | 42 +++++- .../forge_3d/pipeline/narrowphase/root.zig | 18 +++ src/modules/forge/forge_3d/root.zig | 14 +- .../forge/forge_3d/tests/fast_paths_test.zig | 94 ++++++++++++++ 5 files changed, 288 insertions(+), 2 deletions(-) create mode 100644 src/modules/forge/forge_3d/pipeline/narrowphase/fast_paths.zig create mode 100644 src/modules/forge/forge_3d/tests/fast_paths_test.zig diff --git a/src/modules/forge/forge_3d/pipeline/narrowphase/fast_paths.zig b/src/modules/forge/forge_3d/pipeline/narrowphase/fast_paths.zig new file mode 100644 index 0000000..ea6af0f --- /dev/null +++ b/src/modules/forge/forge_3d/pipeline/narrowphase/fast_paths.zig @@ -0,0 +1,122 @@ +//! `forge_3d/pipeline/narrowphase/fast_paths.zig` — the analytic per-pair +//! narrowphase fast paths (M1.1.4). +//! +//! **Seed architecture (Guy-approved, brief Scope).** A fast path NEVER +//! re-implements manifold assembly. Each kernel computes only the `ContactSeed` +//! the generic path's GJK/EPA block already computes — `(normal, closest_a, +//! closest_b, base_penetration)` — and feeds the UNCHANGED `generateManifold`. +//! The five `feature_id` producers, the supporting-face clipping, the ≤ 4 +//! reduction, the per-point penetration, order-independence, and frame-stability +//! are therefore all inherited by construction; the only fast-vs-generic +//! difference is the seed. The speedup is the skipped GJK descent + EPA polytope +//! expansion — `generateManifold`'s clip is cheap and analytic. +//! +//! **Dispatch.** `fastSeed` is a three-state dispatcher: `.not_handled` (no +//! kernel for this pair — the caller falls through to the generic oracle), +//! `.separated` (analytically disjoint — no contact), or `.contact` (a +//! `ContactSeed` for `generateManifold`). It is called from `collideOrdered` +//! (fixed order), so `collide`'s pose canonicalization and `collidePair`'s +//! BodyId order wrap the fast paths identically to the generic path. +//! +//! **E1 status.** The foundation only: `ContactSeed`, `FastResult`, and a +//! dispatcher that returns `.not_handled` for every pair (no kernel yet). The +//! sphere/sphere + sphere/box (E2), box/box SAT (E3), and capsule/capsule (E4) +//! kernels are added behind this same three-state contract. +//! +//! **Box radius invariant.** A box core in a fast pair must have `radius == 0` +//! (the forge_3d box invariant). A rounded box (`radius > 0`) returns +//! `.not_handled` so it stays on the generic path (the SAT/clamp kernels are +//! radius-0-box only). +//! +//! **Dependency discipline (brief Notes).** Imports `foundation` (math) and the +//! sibling `support.zig` ONLY — never `manifold.zig` (that would be a cycle: +//! `manifold.zig` imports THIS file, never the reverse), never `weld_forge`, +//! `body*.zig`, `config.zig`, or `broadphase.zig`. The scalar is the comptime +//! `T`; `forge_3d` instantiates it at `config.Real`. + +const std = @import("std"); +const math = @import("foundation").math; +const support = @import("support.zig"); + +/// The contact seed a fast path hands to `manifold.generateManifold` — exactly +/// the quantities the generic path's GJK/EPA block produces, so the generated +/// manifold is identical up to the analytic-vs-iterative accuracy of the seed. +/// +/// - `normal`: unit, world-space, A→B (the axis to translate B along to reduce +/// penetration), the same convention as `EpaResult.normal` and the shallow +/// `normalize(closest_b − closest_a)`. +/// - `closest_a` / `closest_b`: the world-space closest points on the two +/// CORES (radius excluded) — the witness points `generateManifold` maps to +/// the inflated surfaces. +/// - `base_penetration`: the point-core penetration along `normal` — shallow +/// `r_sum − dist`, deep `depth + r_sum` — continuous across the boundary. +pub fn ContactSeed(comptime T: type) type { + return struct { + normal: math.Vec(3, T), + closest_a: math.Vec(3, T), + closest_b: math.Vec(3, T), + base_penetration: T, + }; +} + +/// The three-state outcome of the fast-path dispatcher: +/// - `not_handled`: no analytic kernel for this shape pair (or a rounded box) — +/// the caller runs the generic GJK/EPA oracle. +/// - `separated`: the pair is analytically disjoint — no contact (`null`). +/// - `contact`: a `ContactSeed` to feed `generateManifold`. +pub fn FastResult(comptime T: type) type { + return union(enum) { + not_handled, + separated, + contact: ContactSeed(T), + }; +} + +/// Analytic per-pair narrowphase dispatcher: returns a `ContactSeed` (or +/// `separated`) for a supported shape pair, else `not_handled` so the caller +/// falls through to the generic GJK/EPA oracle. Runs on the world poses, in the +/// same fixed `(a, b)` order as `collideOrdered`. +/// +/// E1: no kernel is wired yet — every pair returns `.not_handled`. E2–E4 add the +/// four frozen fast paths (sphere/sphere, sphere/box, capsule/capsule, box/box), +/// each gated on a box core having `radius == 0`. +pub fn fastSeed( + comptime T: type, + shape_a: support.SupportShape(T), + pos_a: math.Vec(3, T), + rot_a: math.Quat(T), + shape_b: support.SupportShape(T), + pos_b: math.Vec(3, T), + rot_b: math.Quat(T), +) FastResult(T) { + // E1 foundation: the dispatcher is a pure no-op. `collideOrdered` therefore + // behaves exactly as `collideOrderedGeneric` until the kernels land (E2–E4). + _ = shape_a; + _ = pos_a; + _ = rot_a; + _ = shape_b; + _ = pos_b; + _ = rot_b; + return .not_handled; +} + +const testing = std.testing; + +test "fastSeed is a no-op at E1 (every pair falls through)" { + const T = f32; + const V = math.Vec(3, T); + const Q = math.Quat(T); + const SS = support.SupportShape(T); + const shapes = [_]SS{ + .{ .core = .point, .radius = 0.5 }, + .{ .core = .{ .segment = 1 }, .radius = 0.3 }, + .{ .core = .{ .box = V.fromArray(.{ 1, 1, 1 }) }, .radius = 0 }, + .{ .core = .{ .box = V.fromArray(.{ 0.5, 0.5, 0.5 }) }, .radius = 1 }, // rounded box + }; + for (shapes) |sa| { + for (shapes) |sb| { + const r = fastSeed(T, sa, V.zero, Q.identity, sb, V.fromArray(.{ 0.4, 0, 0 }), Q.identity); + try testing.expect(r == .not_handled); + } + } +} diff --git a/src/modules/forge/forge_3d/pipeline/narrowphase/manifold.zig b/src/modules/forge/forge_3d/pipeline/narrowphase/manifold.zig index 8a07019..f8d7afa 100644 --- a/src/modules/forge/forge_3d/pipeline/narrowphase/manifold.zig +++ b/src/modules/forge/forge_3d/pipeline/narrowphase/manifold.zig @@ -43,6 +43,7 @@ const math = @import("foundation").math; const support = @import("support.zig"); const gjk_mod = @import("gjk.zig"); const epa_mod = @import("epa.zig"); +const fast_paths = @import("fast_paths.zig"); /// The contact manifold between two shapes: a shared world-space contact /// `normal` (A→B) plus up to 4 `ContactPoint`s. FROZEN convention (brief Notes); @@ -119,6 +120,14 @@ pub fn collide( /// use this directly so the feature_id stays frame-stable across a pose change /// that would flip `collide`'s pose-based order (Codex P1b); `BodyManager`'s /// `collidePair` drives it in a canonical body-id order. +/// +/// M1.1.4: an analytic fast path is dispatched first (`fast_paths.fastSeed`). +/// `.not_handled` falls through to the generic GJK/EPA oracle +/// (`collideOrderedGeneric`); `.separated` returns `null`; `.contact` feeds the +/// seed to the SAME `generateManifold` the generic path uses — so the manifold +/// producers, clipping, reduction, order-independence, and frame-stability are +/// inherited identically. The dispatch is a pure function of the shape cores, so +/// a fast pair always takes its fast path (never alternates across frames). pub fn collideOrdered( comptime T: type, shape_a: support.SupportShape(T), @@ -127,6 +136,33 @@ pub fn collideOrdered( shape_b: support.SupportShape(T), pos_b: math.Vec(3, T), rot_b: math.Quat(T), +) ?ContactManifold(T) { + switch (fast_paths.fastSeed(T, shape_a, pos_a, rot_a, shape_b, pos_b, rot_b)) { + .not_handled => return collideOrderedGeneric(T, shape_a, pos_a, rot_a, shape_b, pos_b, rot_b), + .separated => return null, + .contact => |seed| { + const relpose = support.RelativePose(T).init(pos_a, rot_a, pos_b, rot_b); + return generateManifold(T, shape_a, pos_a, rot_a, relpose, shape_b, seed.normal, seed.closest_a, seed.closest_b, seed.base_penetration); + }, + } +} + +/// `collideOrdered` with the fast-path dispatcher BYPASSED — always the generic +/// GJK → shallow/deep → `generateManifold` path. This is the differential ORACLE +/// the M1.1.4 fast-path tests compare against (and the bench baseline): a fast +/// pair's `collideOrdered` must be geometrically equivalent to its +/// `collideOrderedGeneric` (within a named tolerance on normal/points/depth, +/// exact `count`/`feature_id` away from documented topological-flip bands). It +/// computes the seed — `(normal, closest points, base penetration)` — from GJK +/// (shallow) or EPA (deep), exactly what a fast kernel reproduces analytically. +pub fn collideOrderedGeneric( + comptime T: type, + shape_a: support.SupportShape(T), + pos_a: math.Vec(3, T), + rot_a: math.Quat(T), + shape_b: support.SupportShape(T), + pos_b: math.Vec(3, T), + rot_b: math.Quat(T), ) ?ContactManifold(T) { const Vec3T = math.Vec(3, T); const g = gjk_mod.gjk(T, shape_a, pos_a, rot_a, shape_b, pos_b, rot_b); @@ -165,7 +201,11 @@ pub fn collideOrdered( /// `closest_a`/`closest_b` (world core witness points) and `base_penetration` /// feed the single-point (point-core) path; the multi-point path derives per- /// point penetration from the clip. -fn generateManifold( +/// +/// File-`pub` (M1.1.4): consumed by `collideOrdered`'s fast-path arm as well as +/// the generic arm, so a fast kernel's seed produces a manifold identical to the +/// generic one. NOT re-exported by the package facade — it stays package-internal. +pub fn generateManifold( comptime T: type, shape_a: support.SupportShape(T), pos_a: math.Vec(3, T), diff --git a/src/modules/forge/forge_3d/pipeline/narrowphase/root.zig b/src/modules/forge/forge_3d/pipeline/narrowphase/root.zig index 909d840..f2b73e2 100644 --- a/src/modules/forge/forge_3d/pipeline/narrowphase/root.zig +++ b/src/modules/forge/forge_3d/pipeline/narrowphase/root.zig @@ -16,6 +16,7 @@ const support = @import("support.zig"); const gjk_mod = @import("gjk.zig"); const epa_mod = @import("epa.zig"); const manifold = @import("manifold.zig"); +const fast_paths = @import("fast_paths.zig"); // --- Support layer (support.zig) --- @@ -61,7 +62,23 @@ pub const ContactPoint = manifold.ContactPoint; pub const collide = manifold.collide; /// `collide` for a FIXED shape order (no pose canonicalization) — for callers /// that own a stable external key (body ids) and need a frame-stable feature_id. +/// Dispatches the M1.1.4 analytic fast paths, falling through to +/// `collideOrderedGeneric`. pub const collideOrdered = manifold.collideOrdered; +/// `collideOrdered` with the fast-path dispatcher bypassed — the generic GJK/EPA +/// manifold path. The differential oracle + bench baseline for the M1.1.4 fast +/// paths. (`generateManifold` stays package-internal — not re-exported here.) +pub const collideOrderedGeneric = manifold.collideOrderedGeneric; + +// --- Fast paths (analytic per-pair seeds, fast_paths.zig) --- + +/// The `(normal, closest points, base penetration)` seed a fast path supplies to +/// the shared manifold generator (the quantities the generic GJK/EPA block computes). +pub const ContactSeed = fast_paths.ContactSeed; +/// The three-state fast-path dispatcher result (not_handled / separated / contact). +pub const FastResult = fast_paths.FastResult; +/// Analytic per-pair narrowphase dispatcher (`.not_handled` until a kernel lands). +pub const fastSeed = fast_paths.fastSeed; // Pins so every package sub-file is analysed when forge_3d is built as a test // target (engine-zig-conventions.md §13 lazy-analysis guard). @@ -70,4 +87,5 @@ comptime { _ = gjk_mod; _ = epa_mod; _ = manifold; + _ = fast_paths; } diff --git a/src/modules/forge/forge_3d/root.zig b/src/modules/forge/forge_3d/root.zig index 462e647..f488b8b 100644 --- a/src/modules/forge/forge_3d/root.zig +++ b/src/modules/forge/forge_3d/root.zig @@ -113,11 +113,22 @@ pub fn collide(shape_a: SupportShape, pos_a: Vec3r, rot_a: Quatr, shape_b: Suppo /// `collide` for a FIXED shape order (no pose canonicalization) at solver /// precision — the `BodyId`-ordered path `BodyManager.collidePair` drives so the -/// `feature_id` reference/incident ownership stays frame-stable. +/// `feature_id` reference/incident ownership stays frame-stable. Dispatches the +/// M1.1.4 analytic fast paths, falling through to `collideOrderedGeneric`. pub fn collideOrdered(shape_a: SupportShape, pos_a: Vec3r, rot_a: Quatr, shape_b: SupportShape, pos_b: Vec3r, rot_b: Quatr) ?ContactManifold { return narrowphase.collideOrdered(Real, shape_a, pos_a, rot_a, shape_b, pos_b, rot_b); } +/// `collideOrdered` with the fast-path dispatcher bypassed (the generic GJK/EPA +/// oracle) at solver precision — the differential oracle + bench baseline for the +/// M1.1.4 fast paths. +pub fn collideOrderedGeneric(shape_a: SupportShape, pos_a: Vec3r, rot_a: Quatr, shape_b: SupportShape, pos_b: Vec3r, rot_b: Quatr) ?ContactManifold { + return narrowphase.collideOrderedGeneric(Real, shape_a, pos_a, rot_a, shape_b, pos_b, rot_b); +} + +/// The `(normal, closest points, base penetration)` fast-path seed at solver precision. +pub const ContactSeed = narrowphase.ContactSeed(Real); + // Pins so the inline tests + the acceptance suite are analysed when this module // is built as a test target (engine-zig-conventions.md §13). comptime { @@ -132,4 +143,5 @@ comptime { _ = @import("tests/gjk_test.zig"); _ = @import("tests/epa_test.zig"); _ = @import("tests/manifold_test.zig"); + _ = @import("tests/fast_paths_test.zig"); } diff --git a/src/modules/forge/forge_3d/tests/fast_paths_test.zig b/src/modules/forge/forge_3d/tests/fast_paths_test.zig new file mode 100644 index 0000000..9481379 --- /dev/null +++ b/src/modules/forge/forge_3d/tests/fast_paths_test.zig @@ -0,0 +1,94 @@ +//! M1.1.4 acceptance suite for the forge_3d narrowphase fast paths. Keyed to +//! `config.Real` so `-Dphysics_f64=true` sweeps the whole suite at f64 (local). +//! +//! **E1 (this file's current content).** The fast-path dispatcher is wired but +//! returns `.not_handled` for every pair, so `collideOrdered` must be identical +//! to the bypass oracle `collideOrderedGeneric`. Asserting that identity over a +//! representative pair set proves the E1 extraction changed no behaviour and +//! exercises `collideOrderedGeneric` (the §13 surface-coverage rule). The per- +//! pair differential sweeps, the P1d closed-forms, the separated short-circuits, +//! and the consolidated feature_id matrix land with the E2–E4 kernels. + +const std = @import("std"); +const config = @import("../config.zig"); +const narrowphase = @import("../pipeline/narrowphase/root.zig"); +const math = @import("foundation").math; + +const Real = config.Real; +const Vec3r = config.Vec3r; +const Quatr = config.Quatr; +const SupportShape = narrowphase.SupportShape(Real); +const ContactManifold = narrowphase.ContactManifold(Real); +const testing = std.testing; + +fn vr(x: Real, y: Real, z: Real) Vec3r { + return Vec3r.fromArray(.{ x, y, z }); +} +fn sphereShape(radius: Real) SupportShape { + return .{ .core = .point, .radius = radius }; +} +fn capsuleShape(half_height: Real, radius: Real) SupportShape { + return .{ .core = .{ .segment = half_height }, .radius = radius }; +} +fn boxShape(hx: Real, hy: Real, hz: Real) SupportShape { + return .{ .core = .{ .box = vr(hx, hy, hz) }, .radius = 0 }; +} +fn roundedBoxShape(hx: Real, hy: Real, hz: Real, radius: Real) SupportShape { + return .{ .core = .{ .box = vr(hx, hy, hz) }, .radius = radius }; +} + +fn ordered(sa: SupportShape, pa: Vec3r, ra: Quatr, sb: SupportShape, pb: Vec3r, rb: Quatr) ?ContactManifold { + return narrowphase.collideOrdered(Real, sa, pa, ra, sb, pb, rb); +} +fn generic(sa: SupportShape, pa: Vec3r, ra: Quatr, sb: SupportShape, pb: Vec3r, rb: Quatr) ?ContactManifold { + return narrowphase.collideOrderedGeneric(Real, sa, pa, ra, sb, pb, rb); +} + +/// Exact manifold equality (count, normal, every point's position/penetration/ +/// feature_id). Valid at E1 because the no-op dispatcher makes `collideOrdered` +/// call `collideOrderedGeneric` directly — the results are bit-identical. +fn manifoldsIdentical(a: ?ContactManifold, b: ?ContactManifold) bool { + if (a == null or b == null) return (a == null) == (b == null); + const ma = a.?; + const mb = b.?; + if (ma.count != mb.count) return false; + if (!ma.normal.eql(mb.normal)) return false; + for (0..ma.count) |i| { + if (!ma.points[i].position.eql(mb.points[i].position)) return false; + if (ma.points[i].penetration != mb.points[i].penetration) return false; + if (ma.points[i].feature_id != mb.points[i].feature_id) return false; + } + return true; +} + +test "E1 dispatcher is a no-op: collideOrdered equals collideOrderedGeneric" { + const zrot = Quatr.fromAxisAngle(Vec3r.unit_z, std.math.pi / 2.0); + const yaw = Quatr.fromAxisAngle(Vec3r.unit_y, std.math.pi / 4.0); + const Combo = struct { sa: SupportShape, pa: Vec3r, ra: Quatr, sb: SupportShape, pb: Vec3r, rb: Quatr }; + const combos = [_]Combo{ + // sphere/sphere: overlapping and separated. + .{ .sa = sphereShape(1), .pa = vr(0, 0, 0), .ra = Quatr.identity, .sb = sphereShape(1), .pb = vr(1.2, 0, 0), .rb = Quatr.identity }, + .{ .sa = sphereShape(1), .pa = vr(0, 0, 0), .ra = Quatr.identity, .sb = sphereShape(1), .pb = vr(2.5, 0, 0), .rb = Quatr.identity }, + // sphere/box and box/sphere. + .{ .sa = boxShape(1, 1, 1), .pa = vr(0, 0, 0), .ra = Quatr.identity, .sb = sphereShape(0.5), .pb = vr(0, 0, 0.4), .rb = Quatr.identity }, + .{ .sa = sphereShape(0.5), .pa = vr(0, 0, 0.4), .ra = Quatr.identity, .sb = boxShape(1, 1, 1), .pb = vr(0, 0, 0), .rb = Quatr.identity }, + // box/box: face-face and yawed. + .{ .sa = boxShape(1, 1, 1), .pa = vr(0, 0, 0), .ra = Quatr.identity, .sb = boxShape(1, 1, 1), .pb = vr(0, 1.5, 0), .rb = Quatr.identity }, + .{ .sa = boxShape(1, 1, 1), .pa = vr(0, 0, 0), .ra = Quatr.identity, .sb = boxShape(1, 1, 1), .pb = vr(0, 1.9, 0), .rb = yaw }, + // capsule/capsule: parallel side-overlap and crossed. + .{ .sa = capsuleShape(1, 0.5), .pa = vr(0, 0, 0), .ra = Quatr.identity, .sb = capsuleShape(1, 0.5), .pb = vr(0.8, 0, 0), .rb = Quatr.identity }, + .{ .sa = capsuleShape(1, 0.3), .pa = vr(0, 0, 0), .ra = Quatr.identity, .sb = capsuleShape(1, 0.3), .pb = vr(0, 0, 0.5), .rb = zrot }, + // rounded box (radius > 0): must stay on the generic path too. + .{ .sa = roundedBoxShape(0.5, 0.5, 0.5, 1.0), .pa = vr(0, 0, 0), .ra = Quatr.identity, .sb = roundedBoxShape(0.5, 0.5, 0.5, 1.0), .pb = vr(0, 2.5, 0), .rb = Quatr.identity }, + }; + const globals = [_]Quatr{ Quatr.identity, Quatr.fromAxisAngle(vr(1, 2, 3).normalize(), 0.7) }; + for (combos) |c| { + for (globals) |g| { + const pa = g.rotateVec3(c.pa); + const pb = g.rotateVec3(c.pb); + const disp = ordered(c.sa, pa, g.mul(c.ra), c.sb, pb, g.mul(c.rb)); + const gen = generic(c.sa, pa, g.mul(c.ra), c.sb, pb, g.mul(c.rb)); + try testing.expect(manifoldsIdentical(disp, gen)); + } + } +} From e57cc6418187b054b039bf71d343d6d77f63c879 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Mon, 20 Jul 2026 17:44:51 +0200 Subject: [PATCH 04/24] docs(forge): log M1.1.4 E1 execution --- briefs/M1.1.4-narrowphase-fast-paths.md | 47 +++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/briefs/M1.1.4-narrowphase-fast-paths.md b/briefs/M1.1.4-narrowphase-fast-paths.md index 4d31242..599e524 100644 --- a/briefs/M1.1.4-narrowphase-fast-paths.md +++ b/briefs/M1.1.4-narrowphase-fast-paths.md @@ -122,12 +122,53 @@ Named tolerances: the fast-vs-generic differential tolerance is calibrated to th ## Specs read -- [ ] `engine-physics-forge.md` (§303, §315, §1.5–1.6) — read -- [ ] `engine-phase-1-plan.md` (M1.1.4 row, M1.1 arc) — read -- [ ] `engine-zig-conventions.md` — read +- [x] `engine-physics-forge.md` (§303, §315, §1.5–1.6) — read 2026-07-20 17:25 +- [x] `engine-phase-1-plan.md` (M1.1.4 row, M1.1 arc) — read 2026-07-20 17:30 +- [x] `engine-zig-conventions.md` — read 2026-07-20 17:33 + +Specs were pre-staged in `~/Downloads/M1.1.4-narrowphase-fast-paths/` (mtimes +2026-07-20 15:21 — the patched authoritative set), read in full there. ## Execution log +### E1 — Generator fix + `ContactSeed` foundation + dispatcher + generic oracle (2026-07-20) + +- **E1(a) generator fix — RED-first.** Added `test "crossed capsules yield a + single witness contact"` to `manifold_test.zig` and confirmed it RED on the + pre-fix generator (`expected 1, found 2` — the pre-fix `clipSegment` retains + both endpoints of a crossed incident segment; 71/72 others green). Fixed + `generateManifold`: a `face_a.count == 2 and face_b.count == 2` pair that is + NOT parallel now returns a single witness contact (`pointCoreContact`), before + the ref/incident selection. New helper `segmentsParallel` — `sin²θ ≤ + parallel_rel` (`1e-6` f32 / `1e-12` f64, the same relative float-noise form as + `support.supportingFace`'s `aligned_rel`), symmetric under A/B swap, with a + zero-length (degenerate `half_height == 0`) segment treated as a point (→ single + witness). Added `test "nearly-parallel capsules transition deterministically"` + (re-run byte-stability across a yaw band + the 2↔1 count boundary) and `test + "degenerate capsule (half_height == 0) behaves as a sphere"`. T138 (parallel + 2-point) preserved. Commit `dc5dd66`. +- **E1(b) ContactSeed + dispatcher + generic oracle.** New sibling + `fast_paths.zig` (imports `foundation` math + `support.zig` only — no cycle): + `ContactSeed(T)` (`normal`, `closest_a`, `closest_b`, `base_penetration`), + `FastResult(T)` (`not_handled` / `separated` / `contact`), and `fastSeed(...)` + returning `.not_handled` for every pair (E1 no-op). `generateManifold` → + file-`pub` (NOT re-exported by the facade). Extracted the GJK/EPA seed block of + `collideOrdered` into `collideOrderedGeneric` (the bypass oracle); rewrote + `collideOrdered` to dispatch `fastSeed` (`.not_handled` → generic; `.separated` + → null; `.contact` → build relpose + `generateManifold`). `narrowphase/root.zig` + re-exports `collideOrderedGeneric` + `ContactSeed`/`FastResult`/`fastSeed` and + pins `fast_paths.zig`; `forge_3d/root.zig` re-exports `collideOrderedGeneric` + + `ContactSeed` at `Real` and pins `tests/fast_paths_test.zig`. New + `fast_paths_test.zig`: at E1 the no-op dispatcher makes `collideOrdered` + bit-identical to `collideOrderedGeneric` over a representative pair set (proves + the extraction changed nothing; exercises `collideOrderedGeneric`). Commit + `084aeaf`. +- **Validation.** `zig fmt --check` / `zig build lint` / `zig build` clean. + Full `zig build test` green at debug + ReleaseSafe (f32); `zig build + test-forge-3d -Dphysics_f64=true` green at debug + ReleaseSafe. Full existing + suite green ⇒ the extraction + generator fix change nothing on non-crossed + cases. Dispatcher returns `.not_handled` for all pairs (no kernel yet). + ## Recorded deviations ## Blockers encountered From 9f65f6166a23e64e89731615704bf01e78cbcd85 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Mon, 20 Jul 2026 18:14:46 +0200 Subject: [PATCH 05/24] feat(forge): add sphere/sphere and sphere/box narrowphase fast paths --- .../pipeline/narrowphase/fast_paths.zig | 240 +++++++++++++++--- .../forge/forge_3d/tests/fast_paths_test.zig | 189 ++++++++++++-- 2 files changed, 371 insertions(+), 58 deletions(-) diff --git a/src/modules/forge/forge_3d/pipeline/narrowphase/fast_paths.zig b/src/modules/forge/forge_3d/pipeline/narrowphase/fast_paths.zig index ea6af0f..afb9bd5 100644 --- a/src/modules/forge/forge_3d/pipeline/narrowphase/fast_paths.zig +++ b/src/modules/forge/forge_3d/pipeline/narrowphase/fast_paths.zig @@ -18,10 +18,19 @@ //! (fixed order), so `collide`'s pose canonicalization and `collidePair`'s //! BodyId order wrap the fast paths identically to the generic path. //! -//! **E1 status.** The foundation only: `ContactSeed`, `FastResult`, and a -//! dispatcher that returns `.not_handled` for every pair (no kernel yet). The -//! sphere/sphere + sphere/box (E2), box/box SAT (E3), and capsule/capsule (E4) -//! kernels are added behind this same three-state contract. +//! **Status.** E1 laid the foundation (`ContactSeed`, `FastResult`, a no-op +//! dispatcher). E2 wired the point-core pairs — **sphere/sphere** and +//! **sphere/box** (both orders) — each with a `separated` short-circuit (no +//! GJK). Box/box SAT (E3) and capsule/capsule (E4) follow behind this same +//! three-state contract. Anything not yet wired (capsule/*, box/box, a rounded +//! box) returns `.not_handled`. +//! +//! **Classification parity with the generic oracle.** The `separated` decision +//! mirrors `gjk.zig` exactly — `dist − r_sum > conv_k · floatEps(T) · +//! coord_scale` with `conv_k = 16` and `coord_scale = |Δcentres| + +//! coreExtent(a) + coreExtent(b)` (point → 0, box → `|half_extents|`) — so an +//! exact inflated touch stays a contact and the fast/generic boundary agrees +//! (up to the documented flip band). //! //! **Box radius invariant.** A box core in a fast pair must have `radius == 0` //! (the forge_3d box invariant). A rounded box (`radius > 0`) returns @@ -75,11 +84,14 @@ pub fn FastResult(comptime T: type) type { /// Analytic per-pair narrowphase dispatcher: returns a `ContactSeed` (or /// `separated`) for a supported shape pair, else `not_handled` so the caller /// falls through to the generic GJK/EPA oracle. Runs on the world poses, in the -/// same fixed `(a, b)` order as `collideOrdered`. +/// same fixed `(a, b)` order as `collideOrdered` — the seed's normal (A→B) and +/// closest-point ownership follow that order, so `generateManifold`'s feature_id +/// halves swap with the order exactly as on the generic path. /// -/// E1: no kernel is wired yet — every pair returns `.not_handled`. E2–E4 add the -/// four frozen fast paths (sphere/sphere, sphere/box, capsule/capsule, box/box), -/// each gated on a box core having `radius == 0`. +/// Wired (E2): sphere/sphere and sphere/box (both orders). `.not_handled` for +/// everything else (box/box → E3, capsule/capsule → E4, capsule/box and +/// sphere/capsule stay generic), and for any box core with `radius > 0` (a +/// rounded box — the kernels are radius-0-box only). pub fn fastSeed( comptime T: type, shape_a: support.SupportShape(T), @@ -89,34 +101,202 @@ pub fn fastSeed( pos_b: math.Vec(3, T), rot_b: math.Quat(T), ) FastResult(T) { - // E1 foundation: the dispatcher is a pure no-op. `collideOrdered` therefore - // behaves exactly as `collideOrderedGeneric` until the kernels land (E2–E4). - _ = shape_a; - _ = pos_a; - _ = rot_a; - _ = shape_b; - _ = pos_b; - _ = rot_b; - return .not_handled; + switch (shape_a.core) { + .point => switch (shape_b.core) { + // sphere/sphere. + .point => return sphereSphere(T, pos_a, shape_a.radius, pos_b, shape_b.radius), + // sphere (A) / box (B). + .box => |he_b| return sphereBox(T, pos_a, shape_a.radius, pos_b, rot_b, he_b, shape_b.radius, .sphere_is_a), + .segment => return .not_handled, // sphere/capsule stays generic + }, + .box => |he_a| switch (shape_b.core) { + // box (A) / sphere (B). + .point => return sphereBox(T, pos_b, shape_b.radius, pos_a, rot_a, he_a, shape_a.radius, .box_is_a), + else => return .not_handled, // box/box → E3, box/capsule stays generic + }, + .segment => return .not_handled, // capsule/capsule → E4, others stay generic + } +} + +/// A box's core extent (`|half_extents|`) — the box term of the `separated` +/// margin's `coord_scale`; a point (sphere) contributes 0. Mirrors +/// `gjk.coreExtent` so the fast/generic separated boundary agrees. +fn boxExtent(comptime T: type, he: math.Vec(3, T)) T { + return he.length(); +} + +/// The `separated` contact margin — `conv_k · floatEps(T) · coord_scale`, +/// `conv_k = 16`, identical to `gjk.zig`'s so a fast pair and its generic oracle +/// classify the touch/separated boundary the same way (up to the flip band). +fn contactMargin(comptime T: type, coord_scale: T) T { + const conv_k: T = 16; + return conv_k * std.math.floatEps(T) * coord_scale; +} + +/// The sphere/sphere seed: cores are the two centres (radius excluded). Shallow +/// for any non-zero centre distance (points are 0-D, never "deep" unless +/// coincident); `.separated` past the inflated margin; a deterministic +X +/// fallback normal at coincidence (matching the generic `fallbackNormal`, where +/// the A→B axis is geometrically undefined — a measure-zero tie). +fn sphereSphere(comptime T: type, ca: math.Vec(3, T), ra: T, cb: math.Vec(3, T), rb: T) FastResult(T) { + const d = cb.sub(ca); + const dist_sq = d.dot(d); + const dist = @sqrt(dist_sq); + const r_sum = ra + rb; + if (dist - r_sum > contactMargin(T, dist)) return .separated; + const coincidence: T = if (T == f32) 1.0e-12 else 1.0e-24; + const normal = if (dist_sq > coincidence) d.scale(1.0 / dist) else math.Vec(3, T).unit_x; + return .{ .contact = .{ + .normal = normal, + .closest_a = ca, + .closest_b = cb, + .base_penetration = r_sum - dist, + } }; +} + +/// Which member of a sphere/box pair is shape A (fixes the seed's A→B normal and +/// closest-point ownership to the caller's order). +const SphereBoxOwner = enum { sphere_is_a, box_is_a }; + +/// The sphere/box seed (box core radius must be 0). Clamps the sphere centre to +/// the box core: outside → the closest box-surface point + `normalize(centre − +/// surface)` (shallow); inside → the least-penetration face (deep, closed-form, +/// robust at ANY aspect ratio — this is the P1d fix). The box→sphere normal and +/// witness are computed canonically, then oriented + ownership-assigned to the +/// caller's A/B order. A rounded box (`r_box != 0`) is `.not_handled`. +fn sphereBox( + comptime T: type, + sphere_c: math.Vec(3, T), + r_sphere: T, + box_c: math.Vec(3, T), + box_rot: math.Quat(T), + box_he: math.Vec(3, T), + r_box: T, + owner: SphereBoxOwner, +) FastResult(T) { + const Vec3T = math.Vec(3, T); + if (r_box != 0) return .not_handled; // rounded box stays on the generic path + const r_sum = r_sphere + r_box; + + // Sphere centre in the box's local frame; clamp per axis to the box core. + const c_local = box_rot.conjugate().rotateVec3(sphere_c.sub(box_c)); + const cl = c_local.toArray(); + const he = box_he.toArray(); + var q: [3]T = undefined; + var inside = true; + for (0..3) |i| { + q[i] = std.math.clamp(cl[i], -he[i], he[i]); + if (@abs(cl[i]) > he[i]) inside = false; + } + + var cp_box_local: Vec3T = undefined; // closest point on the box core (local) + var n_bs: Vec3T = undefined; // box → sphere normal (world) + var base_penetration: T = undefined; + + if (!inside) { + // Shallow: the clamped point is the closest box-core point to the sphere. + cp_box_local = Vec3T.fromArray(q); + const delta = c_local.sub(cp_box_local); // box surface → sphere centre (local) + const dist_sq = delta.dot(delta); + const dist = @sqrt(dist_sq); + const coord_scale = sphere_c.sub(box_c).length() + boxExtent(T, box_he); + if (dist - r_sum > contactMargin(T, coord_scale)) return .separated; + const coincidence: T = if (T == f32) 1.0e-12 else 1.0e-24; + // Normal from the box surface toward the sphere centre; on the surface + // (dist ≈ 0, the shallow↔deep seam) fall back to the centre direction. + const n_local = if (dist_sq > coincidence) delta.scale(1.0 / dist) else fallbackLocalNormal(T, c_local); + n_bs = box_rot.rotateVec3(n_local); + base_penetration = r_sum - dist; + } else { + // Deep: the sphere centre is inside the box core. The least-penetration + // face (first-index tie-break) gives the exit axis, depth, and witness — + // closed-form, so it is correct at any aspect ratio (P1d). + var i_star: usize = 0; + var best_pen: T = he[0] - @abs(cl[0]); + for (1..3) |i| { + const pen = he[i] - @abs(cl[i]); + if (pen < best_pen) { + best_pen = pen; + i_star = i; + } + } + const s: T = if (cl[i_star] >= 0) 1 else -1; + var cpl = cl; + cpl[i_star] = s * he[i_star]; + cp_box_local = Vec3T.fromArray(cpl); + var axis = [3]T{ 0, 0, 0 }; + axis[i_star] = s; + n_bs = box_rot.rotateVec3(Vec3T.fromArray(axis)); + base_penetration = best_pen + r_sum; + } + + const cp_box_world = box_c.add(box_rot.rotateVec3(cp_box_local)); + return switch (owner) { + // A = box: normal box→sphere, witness_a on the box, witness_b the sphere. + .box_is_a => .{ .contact = .{ + .normal = n_bs, + .closest_a = cp_box_world, + .closest_b = sphere_c, + .base_penetration = base_penetration, + } }, + // A = sphere: negate (A→B = sphere→box) and swap witness ownership. + .sphere_is_a => .{ .contact = .{ + .normal = n_bs.neg(), + .closest_a = sphere_c, + .closest_b = cp_box_world, + .base_penetration = base_penetration, + } }, + }; +} + +/// A deterministic box→sphere fallback normal for the measure-zero surface seam +/// (sphere centre exactly on the box surface, `dist ≈ 0`): the axis of the box's +/// least-penetration face for the centre, else +X. Kept local (box frame) — the +/// caller rotates it to world. +fn fallbackLocalNormal(comptime T: type, c_local: math.Vec(3, T)) math.Vec(3, T) { + const cl = c_local.toArray(); + var i_star: usize = 0; + var best: T = @abs(cl[0]); + for (1..3) |i| { + if (@abs(cl[i]) > best) { + best = @abs(cl[i]); + i_star = i; + } + } + if (!(best > 0)) return math.Vec(3, T).unit_x; + var axis = [3]T{ 0, 0, 0 }; + axis[i_star] = if (cl[i_star] >= 0) 1 else -1; + return math.Vec(3, T).fromArray(axis); } const testing = std.testing; -test "fastSeed is a no-op at E1 (every pair falls through)" { +test "fastSeed dispatch routing (E2 handles the point-core pairs)" { const T = f32; const V = math.Vec(3, T); const Q = math.Quat(T); const SS = support.SupportShape(T); - const shapes = [_]SS{ - .{ .core = .point, .radius = 0.5 }, - .{ .core = .{ .segment = 1 }, .radius = 0.3 }, - .{ .core = .{ .box = V.fromArray(.{ 1, 1, 1 }) }, .radius = 0 }, - .{ .core = .{ .box = V.fromArray(.{ 0.5, 0.5, 0.5 }) }, .radius = 1 }, // rounded box - }; - for (shapes) |sa| { - for (shapes) |sb| { - const r = fastSeed(T, sa, V.zero, Q.identity, sb, V.fromArray(.{ 0.4, 0, 0 }), Q.identity); - try testing.expect(r == .not_handled); - } - } + const sphere = SS{ .core = .point, .radius = 0.5 }; + const capsule = SS{ .core = .{ .segment = 1 }, .radius = 0.3 }; + const box = SS{ .core = .{ .box = V.fromArray(.{ 1, 1, 1 }) }, .radius = 0 }; + const rbox = SS{ .core = .{ .box = V.fromArray(.{ 1, 1, 1 }) }, .radius = 0.2 }; // rounded + + const near = V.fromArray(.{ 0.4, 0, 0 }); // overlapping + const far = V.fromArray(.{ 9, 0, 0 }); // clearly separated + + // Handled point-core pairs → contact (near) / separated (far). + try testing.expect(fastSeed(T, sphere, V.zero, Q.identity, sphere, near, Q.identity) == .contact); + try testing.expect(fastSeed(T, sphere, V.zero, Q.identity, sphere, far, Q.identity) == .separated); + try testing.expect(fastSeed(T, sphere, V.zero, Q.identity, box, near, Q.identity) == .contact); + try testing.expect(fastSeed(T, box, V.zero, Q.identity, sphere, near, Q.identity) == .contact); + try testing.expect(fastSeed(T, sphere, V.zero, Q.identity, box, far, Q.identity) == .separated); + + // Not-yet-wired / unsupported pairs → not_handled (fall through to generic). + try testing.expect(fastSeed(T, box, V.zero, Q.identity, box, near, Q.identity) == .not_handled); // E3 + try testing.expect(fastSeed(T, capsule, V.zero, Q.identity, capsule, near, Q.identity) == .not_handled); // E4 + try testing.expect(fastSeed(T, capsule, V.zero, Q.identity, box, near, Q.identity) == .not_handled); // generic + try testing.expect(fastSeed(T, sphere, V.zero, Q.identity, capsule, near, Q.identity) == .not_handled); // generic + // Rounded box in a sphere/box pair → not_handled (kernels are radius-0-box only). + try testing.expect(fastSeed(T, sphere, V.zero, Q.identity, rbox, near, Q.identity) == .not_handled); + try testing.expect(fastSeed(T, rbox, V.zero, Q.identity, sphere, near, Q.identity) == .not_handled); } diff --git a/src/modules/forge/forge_3d/tests/fast_paths_test.zig b/src/modules/forge/forge_3d/tests/fast_paths_test.zig index 9481379..7358053 100644 --- a/src/modules/forge/forge_3d/tests/fast_paths_test.zig +++ b/src/modules/forge/forge_3d/tests/fast_paths_test.zig @@ -1,13 +1,20 @@ //! M1.1.4 acceptance suite for the forge_3d narrowphase fast paths. Keyed to //! `config.Real` so `-Dphysics_f64=true` sweeps the whole suite at f64 (local). //! -//! **E1 (this file's current content).** The fast-path dispatcher is wired but -//! returns `.not_handled` for every pair, so `collideOrdered` must be identical -//! to the bypass oracle `collideOrderedGeneric`. Asserting that identity over a -//! representative pair set proves the E1 extraction changed no behaviour and -//! exercises `collideOrderedGeneric` (the §13 surface-coverage rule). The per- -//! pair differential sweeps, the P1d closed-forms, the separated short-circuits, -//! and the consolidated feature_id matrix land with the E2–E4 kernels. +//! **Method.** A fast pair's `collideOrdered` (which dispatches the analytic +//! kernel) must be GEOMETRICALLY EQUIVALENT to `collideOrderedGeneric` (the +//! GJK/EPA bypass oracle): same `count`, normal/points/penetration within a +//! named tolerance calibrated to the generic path's convergence residual (NOT +//! `1e-6` — the fast path is in fact MORE accurate), and an EXACT `feature_id` +//! away from alignment/axis ties (the seed feeds the SAME `generateManifold`, so +//! the id is inherited). Where the generic oracle is documented invalid (P1d +//! extreme-aspect box), a CLOSED-FORM oracle replaces it. +//! +//! **E2 content:** sphere/sphere + sphere/box differentials (both orders), +//! separated short-circuits, touch-exact, sphere-internal / coincident, and the +//! P1d point/box closed-form. Pairs not yet wired (box/box → E3, capsule/capsule +//! → E4, capsule/box + rounded box → generic) are still bit-identical to the +//! oracle, asserted below. const std = @import("std"); const config = @import("../config.zig"); @@ -44,9 +51,13 @@ fn generic(sa: SupportShape, pa: Vec3r, ra: Quatr, sb: SupportShape, pb: Vec3r, return narrowphase.collideOrderedGeneric(Real, sa, pa, ra, sb, pb, rb); } -/// Exact manifold equality (count, normal, every point's position/penetration/ -/// feature_id). Valid at E1 because the no-op dispatcher makes `collideOrdered` -/// call `collideOrderedGeneric` directly — the results are bit-identical. +/// Differential tolerance — calibrated to the generic GJK/EPA convergence +/// residual (looser than an analytic-vs-exact bound; NOT `1e-6`). The fast path +/// is more accurate, so this bounds the ORACLE's error, not the kernel's. +const diff_tol: Real = if (Real == f32) 1.0e-3 else 1.0e-8; + +/// Exact manifold equality (bit-for-bit) — for pairs the dispatcher does NOT +/// handle, where `collideOrdered` calls `collideOrderedGeneric` directly. fn manifoldsIdentical(a: ?ContactManifold, b: ?ContactManifold) bool { if (a == null or b == null) return (a == null) == (b == null); const ma = a.?; @@ -61,34 +72,156 @@ fn manifoldsIdentical(a: ?ContactManifold, b: ?ContactManifold) bool { return true; } -test "E1 dispatcher is a no-op: collideOrdered equals collideOrderedGeneric" { - const zrot = Quatr.fromAxisAngle(Vec3r.unit_z, std.math.pi / 2.0); +/// Geometric equivalence of a fast manifold vs the generic oracle: same +/// null-ness, same `count`, normal/position/penetration within `diff_tol`. +/// `feature_id` is asserted exactly only when `check_fid` (i.e. away from an +/// alignment tie). Point-core pairs are `count == 1`, so point matching is by +/// index. `fast` may be MORE accurate than `gen`; the tolerance covers the gap. +fn expectEquivalent(fast: ?ContactManifold, gen: ?ContactManifold, check_fid: bool) !void { + try testing.expectEqual(fast == null, gen == null); + if (fast == null) return; + const f = fast.?; + const g = gen.?; + try testing.expectEqual(g.count, f.count); + try testing.expect(f.normal.approxEql(g.normal, diff_tol)); + for (0..f.count) |i| { + try testing.expect(f.points[i].position.approxEql(g.points[i].position, diff_tol)); + try testing.expectApproxEqAbs(g.points[i].penetration, f.points[i].penetration, diff_tol); + if (check_fid) try testing.expectEqual(g.points[i].feature_id, f.points[i].feature_id); + } +} + +/// Assert the dispatched path equals the generic oracle in BOTH A/B orders. +fn expectBothOrders(sa: SupportShape, pa: Vec3r, ra: Quatr, sb: SupportShape, pb: Vec3r, rb: Quatr, check_fid: bool) !void { + try expectEquivalent(ordered(sa, pa, ra, sb, pb, rb), generic(sa, pa, ra, sb, pb, rb), check_fid); + try expectEquivalent(ordered(sb, pb, rb, sa, pa, ra), generic(sb, pb, rb, sa, pa, ra), check_fid); +} + +/// Whether a count-1 manifold carries the single-witness class pair +/// (`class_a` reference, `class_c` incident) — the point-core producer. +fn isSingleWitnessClass(m: ContactManifold) bool { + if (m.count != 1) return false; + const fid = m.points[0].feature_id; + return ((fid >> 16) & 0xc000) == 0x0000 and (fid & 0xc000) == 0x8000; +} + +test "not-yet-wired pairs equal the generic oracle exactly" { + // box/box (E3), capsule/capsule (E4), capsule/box (stays generic), and any + // rounded box → dispatcher returns `.not_handled`, so `collideOrdered` is the + // generic path verbatim. const yaw = Quatr.fromAxisAngle(Vec3r.unit_y, std.math.pi / 4.0); + const zrot = Quatr.fromAxisAngle(Vec3r.unit_z, std.math.pi / 2.0); const Combo = struct { sa: SupportShape, pa: Vec3r, ra: Quatr, sb: SupportShape, pb: Vec3r, rb: Quatr }; const combos = [_]Combo{ - // sphere/sphere: overlapping and separated. - .{ .sa = sphereShape(1), .pa = vr(0, 0, 0), .ra = Quatr.identity, .sb = sphereShape(1), .pb = vr(1.2, 0, 0), .rb = Quatr.identity }, - .{ .sa = sphereShape(1), .pa = vr(0, 0, 0), .ra = Quatr.identity, .sb = sphereShape(1), .pb = vr(2.5, 0, 0), .rb = Quatr.identity }, - // sphere/box and box/sphere. - .{ .sa = boxShape(1, 1, 1), .pa = vr(0, 0, 0), .ra = Quatr.identity, .sb = sphereShape(0.5), .pb = vr(0, 0, 0.4), .rb = Quatr.identity }, - .{ .sa = sphereShape(0.5), .pa = vr(0, 0, 0.4), .ra = Quatr.identity, .sb = boxShape(1, 1, 1), .pb = vr(0, 0, 0), .rb = Quatr.identity }, - // box/box: face-face and yawed. .{ .sa = boxShape(1, 1, 1), .pa = vr(0, 0, 0), .ra = Quatr.identity, .sb = boxShape(1, 1, 1), .pb = vr(0, 1.5, 0), .rb = Quatr.identity }, .{ .sa = boxShape(1, 1, 1), .pa = vr(0, 0, 0), .ra = Quatr.identity, .sb = boxShape(1, 1, 1), .pb = vr(0, 1.9, 0), .rb = yaw }, - // capsule/capsule: parallel side-overlap and crossed. .{ .sa = capsuleShape(1, 0.5), .pa = vr(0, 0, 0), .ra = Quatr.identity, .sb = capsuleShape(1, 0.5), .pb = vr(0.8, 0, 0), .rb = Quatr.identity }, .{ .sa = capsuleShape(1, 0.3), .pa = vr(0, 0, 0), .ra = Quatr.identity, .sb = capsuleShape(1, 0.3), .pb = vr(0, 0, 0.5), .rb = zrot }, - // rounded box (radius > 0): must stay on the generic path too. + .{ .sa = boxShape(1, 1, 1), .pa = vr(0, 0, 0), .ra = Quatr.identity, .sb = capsuleShape(0.5, 0.5), .pb = vr(1.3, 0, 0), .rb = Quatr.identity }, .{ .sa = roundedBoxShape(0.5, 0.5, 0.5, 1.0), .pa = vr(0, 0, 0), .ra = Quatr.identity, .sb = roundedBoxShape(0.5, 0.5, 0.5, 1.0), .pb = vr(0, 2.5, 0), .rb = Quatr.identity }, + .{ .sa = sphereShape(0.5), .pa = vr(0, 0, 0), .ra = Quatr.identity, .sb = roundedBoxShape(1, 1, 1, 0.2), .pb = vr(0, 1.3, 0), .rb = Quatr.identity }, }; - const globals = [_]Quatr{ Quatr.identity, Quatr.fromAxisAngle(vr(1, 2, 3).normalize(), 0.7) }; for (combos) |c| { - for (globals) |g| { - const pa = g.rotateVec3(c.pa); - const pb = g.rotateVec3(c.pb); - const disp = ordered(c.sa, pa, g.mul(c.ra), c.sb, pb, g.mul(c.rb)); - const gen = generic(c.sa, pa, g.mul(c.ra), c.sb, pb, g.mul(c.rb)); - try testing.expect(manifoldsIdentical(disp, gen)); + try testing.expect(manifoldsIdentical(ordered(c.sa, c.pa, c.ra, c.sb, c.pb, c.rb), generic(c.sa, c.pa, c.ra, c.sb, c.pb, c.rb))); + try testing.expect(manifoldsIdentical(ordered(c.sb, c.pb, c.rb, c.sa, c.pa, c.ra), generic(c.sb, c.pb, c.rb, c.sa, c.pa, c.ra))); + } +} + +test "sphere/sphere differential vs generic" { + const globals = [_]Quatr{ Quatr.identity, Quatr.fromAxisAngle(vr(1, 2, 3).normalize(), 0.7) }; + // r_sum = 2. Offsets straddle shallow overlap (dist < 2) and clear separation. + const dists = [_]Real{ 0.3, 0.8, 1.5, 1.99, 2.5, 4.0 }; + const dirs = [_]Vec3r{ vr(1, 0, 0), vr(0, 1, 0), vr(0.6, 0.8, 0), vr(1, 1, 1) }; + for (globals) |g| { + for (dists) |d| { + for (dirs) |dir| { + const u = dir.normalize(); + const pa = g.rotateVec3(vr(0, 0, 0)); + const pb = g.rotateVec3(u.scale(d)); + try expectBothOrders(sphereShape(1), pa, g, sphereShape(1), pb, g, true); + } + } + } + // Separated → null (both). + try testing.expect(ordered(sphereShape(1), vr(0, 0, 0), Quatr.identity, sphereShape(1), vr(3, 0, 0), Quatr.identity) == null); + // Coincident centres: a degenerate tie (normal geometrically undefined), so + // only the weak contract holds — count 1, penetration → r_sum, finite unit + // normal. NOT compared to the generic normal. + const mc = ordered(sphereShape(1), vr(0, 0, 0), Quatr.identity, sphereShape(1), vr(0, 0, 0), Quatr.identity).?; + try testing.expectEqual(@as(u8, 1), mc.count); + try testing.expectApproxEqAbs(@as(Real, 2.0), mc.points[0].penetration, diff_tol); + try testing.expectApproxEqAbs(@as(Real, 1.0), mc.normal.length(), diff_tol); + try testing.expect(isSingleWitnessClass(mc)); +} + +test "sphere/box differential vs generic" { + const globals = [_]Quatr{ Quatr.identity, Quatr.fromAxisAngle(vr(2, -1, 3).normalize(), 0.6) }; + const box = boxShape(2, 1, 3); // faces at x=±2, y=±1, z=±3; hy=1 the unique min + // Sphere r 0.5. Positions clearly off a single face (fid-exact), plus edge/ + // corner-proximate (geometry only), plus interior (deep) and separated. + const FaceCase = struct { p: Vec3r, fid: bool }; + const cases = [_]FaceCase{ + .{ .p = vr(0, 0, 3.3), .fid = true }, // outside +Z face (dist 0.3) + .{ .p = vr(0, 0, -3.3), .fid = true }, // outside −Z face + .{ .p = vr(2.3, 0, 0), .fid = true }, // outside +X face + .{ .p = vr(0, 1.3, 0), .fid = true }, // outside +Y face + .{ .p = vr(2.3, 1.3, 0), .fid = false }, // near +X/+Y edge (tie band) + .{ .p = vr(2.3, 1.3, 3.3), .fid = false }, // near a corner (tie band) + .{ .p = vr(0, 0.3, 0), .fid = true }, // INTERIOR (deep) — nearest +Y face + .{ .p = vr(0.5, -0.4, 1), .fid = true }, // interior off-centre — nearest −Y face + .{ .p = vr(0, 0, 7), .fid = true }, // clearly separated → null==null + }; + for (globals) |g| { + for (cases) |c| { + const pa = g.rotateVec3(vr(0, 0, 0)); + const pb = g.rotateVec3(c.p); + try expectBothOrders(box, pa, g, sphereShape(0.5), pb, g, c.fid); } } + // Touch-exact: sphere just off the +X face at exactly r_sum ⇒ a contact with + // penetration ~0 (the frozen touch=shallow convention), count 1. + const touch = ordered(box, vr(0, 0, 0), Quatr.identity, sphereShape(0.5), vr(2.5, 0, 0), Quatr.identity).?; + try testing.expectEqual(@as(u8, 1), touch.count); + try testing.expectApproxEqAbs(@as(Real, 0.0), touch.points[0].penetration, diff_tol); + // Sphere centre exactly at the box centre (deep, unique nearest face +Y since + // hy=1 is the smallest half-extent): closed-form normal +Y, depth hy + r_sum. + const mc = ordered(box, vr(0, 0, 0), Quatr.identity, sphereShape(0.5), vr(0, 0, 0), Quatr.identity).?; + try testing.expectEqual(@as(u8, 1), mc.count); + try testing.expect(mc.normal.approxEql(vr(0, 1, 0), diff_tol)); + try testing.expectApproxEqAbs(@as(Real, 1.0 + 0.5), mc.points[0].penetration, diff_tol); +} + +test "sphere/box P1d deep extreme aspect (closed-form)" { + // A sphere deep inside a >50:1 radius-0 box: the generic GJK classification is + // documented invalid at this aspect (`gjk.zig` P1d; reliable to ~30:1), so the + // fast path is validated against a CLOSED-FORM oracle, not the generic path. + // Each case has a UNIQUE nearest face (no axis tie). + const Case = struct { he: Vec3r, c: Vec3r, r: Real, n: Vec3r, pen: Real, pos: Vec3r }; + const cases = [_]Case{ + // 100:1 box, sphere at centre ⇒ nearest +Y face (hy 0.5 the unique min). + // depth hy=0.5, base 1.0; sa=(0,0.5,0), sb=(0,-0.5,0) ⇒ pos (0,0,0). + .{ .he = vr(50, 0.5, 1), .c = vr(0, 0, 0), .r = 0.5, .n = vr(0, 1, 0), .pen = 1.0, .pos = vr(0, 0, 0) }, + // 106:1 box, sphere off-centre ⇒ nearest +Y face (hy 1 < hz 2 < hx 106). + // depth 1, base 1.5; sa=(30,1,0.5), sb=(30,-0.5,0.5) ⇒ pos (30,0.25,0.5). + .{ .he = vr(106, 1, 2), .c = vr(30, 0, 0.5), .r = 0.5, .n = vr(0, 1, 0), .pen = 1.5, .pos = vr(30, 0.25, 0.5) }, + // 212:1 box, sphere off-centre ⇒ nearest +Y face (hy 0.25 the unique min). + // depth 0.25, base 0.75; sa=(10,0.25,0.2), sb=(10,-0.5,0.2) ⇒ pos (10,-0.125,0.2). + .{ .he = vr(53, 0.25, 1), .c = vr(10, 0, 0.2), .r = 0.5, .n = vr(0, 1, 0), .pen = 0.75, .pos = vr(10, -0.125, 0.2) }, + }; + for (cases) |c| { + const box = SupportShape{ .core = .{ .box = c.he }, .radius = 0 }; + // A = box, B = sphere ⇒ normal A→B is the box outward face normal. + const mb = ordered(box, vr(0, 0, 0), Quatr.identity, sphereShape(c.r), c.c, Quatr.identity).?; + try testing.expectEqual(@as(u8, 1), mb.count); + try testing.expect(mb.normal.approxEql(c.n, diff_tol)); + try testing.expectApproxEqAbs(c.pen, mb.points[0].penetration, diff_tol); + try testing.expect(mb.points[0].position.approxEql(c.pos, diff_tol)); + try testing.expect(isSingleWitnessClass(mb)); + // A = sphere, B = box ⇒ the normal negates; count/penetration/position hold. + const ms = ordered(sphereShape(c.r), c.c, Quatr.identity, box, vr(0, 0, 0), Quatr.identity).?; + try testing.expectEqual(@as(u8, 1), ms.count); + try testing.expect(ms.normal.approxEql(c.n.neg(), diff_tol)); + try testing.expectApproxEqAbs(c.pen, ms.points[0].penetration, diff_tol); + try testing.expect(ms.points[0].position.approxEql(c.pos, diff_tol)); + } } From 3904f00bb1994c6125ab86668dd96948b2fa94e6 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Mon, 20 Jul 2026 18:15:13 +0200 Subject: [PATCH 06/24] docs(forge): log M1.1.4 E2 execution --- briefs/M1.1.4-narrowphase-fast-paths.md | 35 +++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/briefs/M1.1.4-narrowphase-fast-paths.md b/briefs/M1.1.4-narrowphase-fast-paths.md index 599e524..cae1a7b 100644 --- a/briefs/M1.1.4-narrowphase-fast-paths.md +++ b/briefs/M1.1.4-narrowphase-fast-paths.md @@ -169,6 +169,41 @@ Specs were pre-staged in `~/Downloads/M1.1.4-narrowphase-fast-paths/` (mtimes suite green ⇒ the extraction + generator fix change nothing on non-crossed cases. Dispatcher returns `.not_handled` for all pairs (no kernel yet). +### E2 — sphere/sphere + sphere/box (point-core pairs) (2026-07-20) + +- **Kernels (`fast_paths.zig`).** `fastSeed` now dispatches the two point-core + pairs; everything else (box/box → E3, capsule/capsule → E4, capsule/box + + sphere/capsule stay generic, any box `radius > 0`) returns `.not_handled`. + - `sphereSphere`: cores are the centres. `separated` past the inflated margin + (mirrors `gjk.zig` exactly — `dist − r_sum > 16·floatEps·coord_scale`, + `coord_scale = |Δcentres|`), else a shallow seed `normalize(cb − ca)` / + `r_sum − dist`; +X fallback at coincidence (matches `fallbackNormal`, a + measure-zero tie). + - `sphereBox` (both orders via a `SphereBoxOwner` enum): clamp the sphere + centre to the box core. Outside → closest box-surface point + `normalize` + (shallow); inside → least-penetration face (first-index tie-break) giving a + closed-form deep seed — robust at ANY aspect ratio (the **P1d fix**). The + box→sphere normal + witness are computed canonically, then oriented and + ownership-assigned to the caller's A/B order (so `generateManifold`'s + feature_id halves swap with the order). Rounded box (`r_box != 0`) → + `.not_handled`. `coord_scale = |Δcentres| + |box_he|`. +- **Tests (`fast_paths_test.zig`).** `diff_tol` calibrated to the generic + convergence residual (`1e-3` f32 / `1e-8` f64, documented — NOT `1e-6`). (1) + `not-yet-wired pairs equal the generic oracle exactly` (box/box, capsule pairs, + capsule/box, rounded box → still bit-identical). (2) `sphere/sphere + differential vs generic` — pose sweep × 4 directions × 6 distances × 2 globals, + both orders, exact `feature_id`; separated → null; coincident (weak contract: + count 1, pen → r_sum, unit normal, single-witness class). (3) `sphere/box + differential vs generic` — face-proximate (fid-exact), edge/corner (geometry + only, tie band), interior deep, both orders; touch-exact (pen ≈ 0); sphere at + box centre (closed-form). (4) `sphere/box P1d deep extreme aspect (closed-form)` + — 100:1, 106:1, 212:1 boxes, sphere deep inside, asserted against a CLOSED-FORM + oracle (generic invalid at this aspect), both orders (normal negates). Inline + dispatch-routing test in `fast_paths.zig` updated (E1 no-op → E2 routing). +- **Validation.** `zig fmt --check` / `zig build lint` / `zig build` clean; full + `zig build test` green debug + ReleaseSafe (f32); `zig build test-forge-3d + -Dphysics_f64=true` green debug + ReleaseSafe. + ## Recorded deviations ## Blockers encountered From 9b7e111af8fca7c2d7d73ea3fb05f5057de288d4 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Mon, 20 Jul 2026 18:46:50 +0200 Subject: [PATCH 07/24] feat(forge): add box/box SAT narrowphase fast path --- .../pipeline/narrowphase/fast_paths.zig | 200 ++++++++++++++++-- .../forge/forge_3d/tests/fast_paths_test.zig | 144 ++++++++++++- 2 files changed, 325 insertions(+), 19 deletions(-) diff --git a/src/modules/forge/forge_3d/pipeline/narrowphase/fast_paths.zig b/src/modules/forge/forge_3d/pipeline/narrowphase/fast_paths.zig index afb9bd5..a0ad6e4 100644 --- a/src/modules/forge/forge_3d/pipeline/narrowphase/fast_paths.zig +++ b/src/modules/forge/forge_3d/pipeline/narrowphase/fast_paths.zig @@ -20,10 +20,10 @@ //! //! **Status.** E1 laid the foundation (`ContactSeed`, `FastResult`, a no-op //! dispatcher). E2 wired the point-core pairs — **sphere/sphere** and -//! **sphere/box** (both orders) — each with a `separated` short-circuit (no -//! GJK). Box/box SAT (E3) and capsule/capsule (E4) follow behind this same -//! three-state contract. Anything not yet wired (capsule/*, box/box, a rounded -//! box) returns `.not_handled`. +//! **sphere/box** (both orders). E3 added **box/box** via a separating-axis test +//! (15 candidate axes → the least-overlap axis → seed). capsule/capsule (E4) +//! follows behind this same three-state contract. Anything not yet wired +//! (capsule/*, a rounded box) returns `.not_handled`. //! //! **Classification parity with the generic oracle.** The `separated` decision //! mirrors `gjk.zig` exactly — `dist − r_sum > conv_k · floatEps(T) · @@ -88,10 +88,10 @@ pub fn FastResult(comptime T: type) type { /// closest-point ownership follow that order, so `generateManifold`'s feature_id /// halves swap with the order exactly as on the generic path. /// -/// Wired (E2): sphere/sphere and sphere/box (both orders). `.not_handled` for -/// everything else (box/box → E3, capsule/capsule → E4, capsule/box and -/// sphere/capsule stay generic), and for any box core with `radius > 0` (a -/// rounded box — the kernels are radius-0-box only). +/// Wired: sphere/sphere, sphere/box (E2), and box/box SAT (E3), all orders. +/// `.not_handled` for capsule/capsule (→ E4), capsule/box + sphere/capsule (stay +/// generic), and any box core with `radius > 0` (a rounded box — the kernels are +/// radius-0-box only). pub fn fastSeed( comptime T: type, shape_a: support.SupportShape(T), @@ -112,7 +112,12 @@ pub fn fastSeed( .box => |he_a| switch (shape_b.core) { // box (A) / sphere (B). .point => return sphereBox(T, pos_b, shape_b.radius, pos_a, rot_a, he_a, shape_a.radius, .box_is_a), - else => return .not_handled, // box/box → E3, box/capsule stays generic + // box/box — SAT (both cores must be radius-0). + .box => |he_b| { + if (shape_a.radius != 0 or shape_b.radius != 0) return .not_handled; // rounded box → generic + return boxBox(T, pos_a, rot_a, he_a, pos_b, rot_b, he_b); + }, + .segment => return .not_handled, // box/capsule stays generic }, .segment => return .not_handled, // capsule/capsule → E4, others stay generic } @@ -269,9 +274,175 @@ fn fallbackLocalNormal(comptime T: type, c_local: math.Vec(3, T)) math.Vec(3, T) return math.Vec(3, T).fromArray(axis); } +/// The box/box separating-axis test → `ContactSeed` (both boxes radius 0). Tests +/// the 15 candidate axes — 3 A-face normals, 3 B-face normals, 9 edge×edge cross +/// products (degeneracy-guarded: a near-parallel edge pair is skipped, its +/// separation already covered by the face axes) — and returns `.separated` on +/// the first axis with no overlap. Otherwise the least-overlap axis is the +/// contact normal + depth. A small face-preferring bias resolves face/edge ties +/// (matching EPA, and killing edge-axis numerical jitter). SAT has NO GJK +/// degeneracy, so this is robust at ANY aspect ratio (the box/box P1d fix). +/// +/// The seed feeds the unchanged `generateManifold`: a FACE axis (`n_a` aligned +/// with a box face) drives the supporting-face clip → the multi-point manifold +/// (closest points + `base_penetration` unused there); an EDGE axis drives the +/// FIX-1 single-witness fallback → the contact at the midpoint of the closest +/// points of the two contributing edges (computed here). +fn boxBox( + comptime T: type, + ca: math.Vec(3, T), + ra: math.Quat(T), + he_a: math.Vec(3, T), + cb: math.Vec(3, T), + rb: math.Quat(T), + he_b: math.Vec(3, T), +) FastResult(T) { + const Vec3T = math.Vec(3, T); + const axa = [3]Vec3T{ ra.rotateVec3(Vec3T.unit_x), ra.rotateVec3(Vec3T.unit_y), ra.rotateVec3(Vec3T.unit_z) }; + const axb = [3]Vec3T{ rb.rotateVec3(Vec3T.unit_x), rb.rotateVec3(Vec3T.unit_y), rb.rotateVec3(Vec3T.unit_z) }; + const hea = he_a.toArray(); + const heb = he_b.toArray(); + const dc = cb.sub(ca); + const scale = dc.length() + he_a.length() + he_b.length(); + const margin = contactMargin(T, scale); + + // Track the least-overlap FACE axis and least-overlap EDGE axis separately so + // a small bias can prefer a face on a near-tie (edge cross products carry more + // rounding, and face contacts are the stable manifold). + var face_depth: T = std.math.floatMax(T); + var face_axis = Vec3T.unit_x; + var edge_depth: T = std.math.floatMax(T); + var edge_axis = Vec3T.unit_x; + var edge_i: usize = 0; + var edge_j: usize = 0; + var have_edge = false; + + // Face axes (already unit). + for (0..6) |k| { + const L = if (k < 3) axa[k] else axb[k - 3]; + const ov = boxOverlapOnAxis(T, L, axa, hea, axb, heb, dc); + if (ov < -margin) return .separated; + if (ov < face_depth) { + face_depth = ov; + face_axis = L; + } + } + // Edge×edge axes. + const par_eps: T = if (T == f32) 1.0e-6 else 1.0e-12; + for (0..3) |i| { + for (0..3) |j| { + var L = axa[i].cross(axb[j]); + const l2 = L.dot(L); + if (l2 <= par_eps) continue; // parallel edges — covered by the faces + L = L.scale(1.0 / @sqrt(l2)); + const ov = boxOverlapOnAxis(T, L, axa, hea, axb, heb, dc); + if (ov < -margin) return .separated; + if (!have_edge or ov < edge_depth) { + edge_depth = ov; + edge_axis = L; + edge_i = i; + edge_j = j; + have_edge = true; + } + } + } + + // Choose face vs edge (face-preferring on a tie), then orient the normal A→B. + const edge_bias: T = (if (T == f32) @as(T, 1.0e-3) else @as(T, 1.0e-6)) * scale; + const use_edge = have_edge and (edge_depth + edge_bias < face_depth); + var normal = if (use_edge) edge_axis else face_axis; + const depth = if (use_edge) edge_depth else face_depth; + if (dc.dot(normal) < 0) normal = normal.neg(); + + if (use_edge) { + // The two contributing edges: A's edge (parallel to axis edge_i) extremal + // toward +normal, B's edge (parallel to edge_j) extremal toward −normal. + const ea = contactEdge(T, ca, axa, hea, edge_i, normal); + const eb = contactEdge(T, cb, axb, heb, edge_j, normal.neg()); + const cp = closestSegSeg(T, ea[0], ea[1], eb[0], eb[1]); + return .{ .contact = .{ + .normal = normal, + .closest_a = cp[0], + .closest_b = cp[1], + .base_penetration = depth, + } }; + } + // Face axis: the clip ignores the closest points, but supply sane support + // points (A's deepest toward +normal, B's toward −normal) for robustness. + return .{ .contact = .{ + .normal = normal, + .closest_a = boxSupportPoint(T, ca, axa, hea, normal), + .closest_b = boxSupportPoint(T, cb, axb, heb, normal.neg()), + .base_penetration = depth, + } }; +} + +/// Signed overlap of the two OBBs projected onto unit axis `L`: `radius_a + +/// radius_b − |Δcentre · L|`. Negative ⇒ `L` is a separating axis. +fn boxOverlapOnAxis(comptime T: type, L: math.Vec(3, T), axa: [3]math.Vec(3, T), hea: [3]T, axb: [3]math.Vec(3, T), heb: [3]T, dc: math.Vec(3, T)) T { + var ra_proj: T = 0; + var rb_proj: T = 0; + for (0..3) |k| { + ra_proj += hea[k] * @abs(axa[k].dot(L)); + rb_proj += heb[k] * @abs(axb[k].dot(L)); + } + return ra_proj + rb_proj - @abs(dc.dot(L)); +} + +/// The box corner (world) farthest along `dir`: `centre + Σ_k axis_k · +/// sign(axis_k · dir) · he_k`. +fn boxSupportPoint(comptime T: type, c: math.Vec(3, T), ax: [3]math.Vec(3, T), he: [3]T, dir: math.Vec(3, T)) math.Vec(3, T) { + var p = c; + for (0..3) |k| { + const s: T = if (ax[k].dot(dir) >= 0) 1 else -1; + p = p.add(ax[k].scale(s * he[k])); + } + return p; +} + +/// The box edge (world segment) parallel to local axis `dir_idx` and extremal +/// toward `toward`: the two axes perpendicular to `dir_idx` are pinned to the +/// corner farthest along `toward`; the edge spans `±he[dir_idx]` along its axis. +fn contactEdge(comptime T: type, c: math.Vec(3, T), ax: [3]math.Vec(3, T), he: [3]T, dir_idx: usize, toward: math.Vec(3, T)) [2]math.Vec(3, T) { + var center = c; + for (0..3) |k| { + if (k == dir_idx) continue; + const s: T = if (ax[k].dot(toward) >= 0) 1 else -1; + center = center.add(ax[k].scale(s * he[k])); + } + const half = ax[dir_idx].scale(he[dir_idx]); + return .{ center.sub(half), center.add(half) }; +} + +/// Closest points between two segments `p1`–`q1` and `p2`–`q2` (Ericson RTCD +/// §5.1.9). Returns `[closest_on_1, closest_on_2]`. Both segments here are box +/// edges (non-degenerate, `he > 0`), so the segment lengths are strictly +/// positive; the parametric denominators are guarded regardless. +fn closestSegSeg(comptime T: type, p1: math.Vec(3, T), q1: math.Vec(3, T), p2: math.Vec(3, T), q2: math.Vec(3, T)) [2]math.Vec(3, T) { + const d1 = q1.sub(p1); + const d2 = q2.sub(p2); + const r = p1.sub(p2); + const a = d1.dot(d1); + const e = d2.dot(d2); + const f = d2.dot(r); + const c = d1.dot(r); + const b = d1.dot(d2); + const denom = a * e - b * b; + var s: T = if (denom > 0) std.math.clamp((b * f - c * e) / denom, 0, 1) else 0; + var t: T = if (e > 0) (b * s + f) / e else 0; + if (t < 0) { + t = 0; + s = if (a > 0) std.math.clamp(-c / a, 0, 1) else 0; + } else if (t > 1) { + t = 1; + s = if (a > 0) std.math.clamp((b - c) / a, 0, 1) else 0; + } + return .{ p1.add(d1.scale(s)), p2.add(d2.scale(t)) }; +} + const testing = std.testing; -test "fastSeed dispatch routing (E2 handles the point-core pairs)" { +test "fastSeed dispatch routing (E3 handles sphere and box pairs)" { const T = f32; const V = math.Vec(3, T); const Q = math.Quat(T); @@ -284,19 +455,22 @@ test "fastSeed dispatch routing (E2 handles the point-core pairs)" { const near = V.fromArray(.{ 0.4, 0, 0 }); // overlapping const far = V.fromArray(.{ 9, 0, 0 }); // clearly separated - // Handled point-core pairs → contact (near) / separated (far). + // Handled pairs → contact (near) / separated (far). try testing.expect(fastSeed(T, sphere, V.zero, Q.identity, sphere, near, Q.identity) == .contact); try testing.expect(fastSeed(T, sphere, V.zero, Q.identity, sphere, far, Q.identity) == .separated); try testing.expect(fastSeed(T, sphere, V.zero, Q.identity, box, near, Q.identity) == .contact); try testing.expect(fastSeed(T, box, V.zero, Q.identity, sphere, near, Q.identity) == .contact); try testing.expect(fastSeed(T, sphere, V.zero, Q.identity, box, far, Q.identity) == .separated); + try testing.expect(fastSeed(T, box, V.zero, Q.identity, box, near, Q.identity) == .contact); // box/box (E3) + try testing.expect(fastSeed(T, box, V.zero, Q.identity, box, far, Q.identity) == .separated); // Not-yet-wired / unsupported pairs → not_handled (fall through to generic). - try testing.expect(fastSeed(T, box, V.zero, Q.identity, box, near, Q.identity) == .not_handled); // E3 try testing.expect(fastSeed(T, capsule, V.zero, Q.identity, capsule, near, Q.identity) == .not_handled); // E4 try testing.expect(fastSeed(T, capsule, V.zero, Q.identity, box, near, Q.identity) == .not_handled); // generic try testing.expect(fastSeed(T, sphere, V.zero, Q.identity, capsule, near, Q.identity) == .not_handled); // generic - // Rounded box in a sphere/box pair → not_handled (kernels are radius-0-box only). + // Any rounded box → not_handled (kernels are radius-0-box only). try testing.expect(fastSeed(T, sphere, V.zero, Q.identity, rbox, near, Q.identity) == .not_handled); try testing.expect(fastSeed(T, rbox, V.zero, Q.identity, sphere, near, Q.identity) == .not_handled); + try testing.expect(fastSeed(T, box, V.zero, Q.identity, rbox, near, Q.identity) == .not_handled); + try testing.expect(fastSeed(T, rbox, V.zero, Q.identity, rbox, near, Q.identity) == .not_handled); } diff --git a/src/modules/forge/forge_3d/tests/fast_paths_test.zig b/src/modules/forge/forge_3d/tests/fast_paths_test.zig index 7358053..a8b940d 100644 --- a/src/modules/forge/forge_3d/tests/fast_paths_test.zig +++ b/src/modules/forge/forge_3d/tests/fast_paths_test.zig @@ -97,6 +97,35 @@ fn expectBothOrders(sa: SupportShape, pa: Vec3r, ra: Quatr, sb: SupportShape, pb try expectEquivalent(ordered(sb, pb, rb, sa, pa, ra), generic(sb, pb, rb, sa, pa, ra), check_fid); } +/// Geometric equivalence for MULTI-point manifolds, order-independent: same +/// null-ness and `count`, same normal within tol, and every fast point matches +/// some generic point (position + penetration within tol; `feature_id` too when +/// `check_fid`). Used for box/box, whose clip may emit points in a different +/// order between the fast and generic paths. +fn expectEquivalentUnordered(fast: ?ContactManifold, gen: ?ContactManifold, check_fid: bool) !void { + try testing.expectEqual(fast == null, gen == null); + if (fast == null) return; + const f = fast.?; + const g = gen.?; + try testing.expectEqual(g.count, f.count); + try testing.expect(f.normal.approxEql(g.normal, diff_tol)); + for (0..f.count) |i| { + var matched = false; + for (0..g.count) |j| { + const same_pos = f.points[i].position.approxEql(g.points[j].position, diff_tol); + const same_pen = @abs(f.points[i].penetration - g.points[j].penetration) <= diff_tol; + const same_fid = !check_fid or f.points[i].feature_id == g.points[j].feature_id; + if (same_pos and same_pen and same_fid) matched = true; + } + try testing.expect(matched); + } +} + +fn expectBothOrdersUnordered(sa: SupportShape, pa: Vec3r, ra: Quatr, sb: SupportShape, pb: Vec3r, rb: Quatr, check_fid: bool) !void { + try expectEquivalentUnordered(ordered(sa, pa, ra, sb, pb, rb), generic(sa, pa, ra, sb, pb, rb), check_fid); + try expectEquivalentUnordered(ordered(sb, pb, rb, sa, pa, ra), generic(sb, pb, rb, sa, pa, ra), check_fid); +} + /// Whether a count-1 manifold carries the single-witness class pair /// (`class_a` reference, `class_c` incident) — the point-core producer. fn isSingleWitnessClass(m: ContactManifold) bool { @@ -106,20 +135,18 @@ fn isSingleWitnessClass(m: ContactManifold) bool { } test "not-yet-wired pairs equal the generic oracle exactly" { - // box/box (E3), capsule/capsule (E4), capsule/box (stays generic), and any - // rounded box → dispatcher returns `.not_handled`, so `collideOrdered` is the - // generic path verbatim. - const yaw = Quatr.fromAxisAngle(Vec3r.unit_y, std.math.pi / 4.0); + // capsule/capsule (E4), capsule/box (stays generic), and any rounded box → + // dispatcher returns `.not_handled`, so `collideOrdered` is the generic path + // verbatim. (sphere and box pairs are now wired — see the differentials.) const zrot = Quatr.fromAxisAngle(Vec3r.unit_z, std.math.pi / 2.0); const Combo = struct { sa: SupportShape, pa: Vec3r, ra: Quatr, sb: SupportShape, pb: Vec3r, rb: Quatr }; const combos = [_]Combo{ - .{ .sa = boxShape(1, 1, 1), .pa = vr(0, 0, 0), .ra = Quatr.identity, .sb = boxShape(1, 1, 1), .pb = vr(0, 1.5, 0), .rb = Quatr.identity }, - .{ .sa = boxShape(1, 1, 1), .pa = vr(0, 0, 0), .ra = Quatr.identity, .sb = boxShape(1, 1, 1), .pb = vr(0, 1.9, 0), .rb = yaw }, .{ .sa = capsuleShape(1, 0.5), .pa = vr(0, 0, 0), .ra = Quatr.identity, .sb = capsuleShape(1, 0.5), .pb = vr(0.8, 0, 0), .rb = Quatr.identity }, .{ .sa = capsuleShape(1, 0.3), .pa = vr(0, 0, 0), .ra = Quatr.identity, .sb = capsuleShape(1, 0.3), .pb = vr(0, 0, 0.5), .rb = zrot }, .{ .sa = boxShape(1, 1, 1), .pa = vr(0, 0, 0), .ra = Quatr.identity, .sb = capsuleShape(0.5, 0.5), .pb = vr(1.3, 0, 0), .rb = Quatr.identity }, .{ .sa = roundedBoxShape(0.5, 0.5, 0.5, 1.0), .pa = vr(0, 0, 0), .ra = Quatr.identity, .sb = roundedBoxShape(0.5, 0.5, 0.5, 1.0), .pb = vr(0, 2.5, 0), .rb = Quatr.identity }, .{ .sa = sphereShape(0.5), .pa = vr(0, 0, 0), .ra = Quatr.identity, .sb = roundedBoxShape(1, 1, 1, 0.2), .pb = vr(0, 1.3, 0), .rb = Quatr.identity }, + .{ .sa = boxShape(1, 1, 1), .pa = vr(0, 0, 0), .ra = Quatr.identity, .sb = roundedBoxShape(1, 1, 1, 0.2), .pb = vr(0, 1.5, 0), .rb = Quatr.identity }, }; for (combos) |c| { try testing.expect(manifoldsIdentical(ordered(c.sa, c.pa, c.ra, c.sb, c.pb, c.rb), generic(c.sa, c.pa, c.ra, c.sb, c.pb, c.rb))); @@ -225,3 +252,108 @@ test "sphere/box P1d deep extreme aspect (closed-form)" { try testing.expect(ms.points[0].position.approxEql(c.pos, diff_tol)); } } + +/// Whether the generic oracle is SELF-CONSISTENT on this pair — same null-ness, +/// same `count`, and negated normal across the two A/B orders. `collideOrdered` +/// is fixed-order and runs GJK/EPA in the frame of A; for a deep, rotated pair +/// EPA can converge to DIFFERENT faces in the two frames (an M1.1.3 EPA +/// frame-dependence, NOT a fast-path issue — the SAT fast path is order- +/// independent by construction; see the order-independence test). Where generic +/// disagrees with itself it is an unreliable oracle, so the differential skips +/// it. This is the concrete motivation for the analytic fast path. +fn genericConsistent(sa: SupportShape, pa: Vec3r, ra: Quatr, sb: SupportShape, pb: Vec3r, rb: Quatr) bool { + const ab = generic(sa, pa, ra, sb, pb, rb); + const ba = generic(sb, pb, rb, sa, pa, ra); + if ((ab == null) != (ba == null)) return false; + if (ab == null) return true; + if (ab.?.count != ba.?.count) return false; + return ab.?.normal.approxEql(ba.?.normal.neg(), diff_tol); +} + +test "box/box SAT differential vs generic (<=30:1)" { + // Cube A at the origin vs cube B rotated/offset — a broad sweep hitting the + // face-face, face-vertex and edge-edge regimes. Per config, `collideOrdered` + // is compared to the generic oracle in BOTH A/B orders, but only where that + // oracle is self-consistent across orders (deep rotated pairs where the + // generic EPA is frame-dependent — an M1.1.3 EPA limitation the SAT fast path + // does not share — are skipped via `genericConsistent`). Geometry-only (fid + // tie bands excluded); fid-exact is asserted on the clean explicit configs. + const box = boxShape(1, 1, 1); + const rots = [_]Quatr{ + Quatr.identity, + Quatr.fromAxisAngle(Vec3r.unit_y, std.math.pi / 4.0), // yaw ⇒ face-face octagon→4 + Quatr.fromAxisAngle(Vec3r.unit_x, 0.4), // tilt ⇒ edge/face + Quatr.fromAxisAngle(vr(1, 1, 1).normalize(), 0.62), // corner-lead ⇒ vertex/edge + Quatr.fromAxisAngle(Vec3r.unit_z, 0.5), + }; + const offs = [_]Vec3r{ vr(0, 1.5, 0), vr(0.5, 1.5, 0.3), vr(0, 1.8, 0), vr(0.3, 1.6, -0.3) }; + const globals = [_]Quatr{ Quatr.identity, Quatr.fromAxisAngle(vr(1, 2, 3).normalize(), 0.7) }; + var saw_face = false; // a multi-point (clip) manifold + var saw_single = false; // a single-witness (edge/vertex) manifold + var compared: u32 = 0; // configs where the oracle was trustworthy + for (globals) |g| { + for (rots) |r| { + for (offs) |o| { + const pa = g.rotateVec3(vr(0, 0, 0)); + const pb = g.rotateVec3(o); + const ra = g; + const rb = g.mul(r); + const m = ordered(box, pa, ra, box, pb, rb) orelse continue; + // Compare fast vs generic in both orders, only where the oracle + // is self-consistent (i.e. its EPA converged reliably). + if (genericConsistent(box, pa, ra, box, pb, rb)) { + try expectBothOrdersUnordered(box, pa, ra, box, pb, rb, false); + compared += 1; + } + if (m.count >= 3) saw_face = true; + if (m.count == 1) saw_single = true; + } + } + } + // The sweep genuinely exercised both a face-face clip and an edge/vertex + // single witness, and the trustworthy-oracle differential ran on many configs. + try testing.expect(saw_face and saw_single); + try testing.expect(compared >= 20); + + // Clean explicit configs, fid-exact (away from ties), both orders: + // axis-aligned face-face (4 pts) and a 45°-yaw face-face (octagon→4 pts) — + // shallow, so the generic oracle is reliable in both orders. + try expectBothOrdersUnordered(box, vr(0, 0, 0), Quatr.identity, box, vr(0, 1.9, 0), Quatr.identity, true); + const yaw = Quatr.fromAxisAngle(Vec3r.unit_y, std.math.pi / 4.0); + try expectBothOrdersUnordered(box, vr(0, 0, 0), Quatr.identity, box, vr(0, 1.9, 0), yaw, true); + + // Moderate aspect (up to 30:1) face-face — still the generic-reliable regime. + const aspects = [_]Real{ 5, 15, 30 }; + for (aspects) |ar| { + const b = boxShape(ar, 1, ar * 0.5); + try expectBothOrdersUnordered(b, vr(0, 0, 0), Quatr.identity, b, vr(0, 1.9, 0), Quatr.identity, true); + } +} + +test "box/box SAT extreme aspect (closed-form)" { + // A face-face overlap of two >50:1 boxes: SAT has no GJK degeneracy, so the + // fast path is correct at any aspect (the box/box P1d fix). The generic path + // is documented invalid here, so the oracle is CLOSED-FORM. Two equal boxes + // stacked 1.5 apart in Y (half-extents hy=1) ⇒ overlap 0.5, normal +Y, 4 + // points on the plane y=0.75, spanning the full X/Z overlap rectangle. + const aspects = [_]Real{ 50, 100, 212 }; + for (aspects) |hx| { + const box = boxShape(hx, 1, 1); + const m = ordered(box, vr(0, 0, 0), Quatr.identity, box, vr(0, 1.5, 0), Quatr.identity).?; + try testing.expectEqual(@as(u8, 4), m.count); + try testing.expect(m.normal.approxEql(vr(0, 1, 0), diff_tol)); + var max_abs_x: Real = 0; + for (0..m.count) |i| { + try testing.expectApproxEqAbs(@as(Real, 0.5), m.points[i].penetration, diff_tol); // r_sum 0 ⇒ overlap 0.5 + try testing.expectApproxEqAbs(@as(Real, 0.75), m.points[i].position.toArray()[1], diff_tol); // contact plane + max_abs_x = @max(max_abs_x, @abs(m.points[i].position.toArray()[0])); + } + // The manifold spans the full X overlap (proves the clip works at extreme + // aspect — a naive GJK/EPA would collapse or mis-classify here). + try testing.expectApproxEqAbs(hx, max_abs_x, diff_tol); + // Swapped order ⇒ the normal negates, geometry holds. + const ms = ordered(box, vr(0, 1.5, 0), Quatr.identity, box, vr(0, 0, 0), Quatr.identity).?; + try testing.expectEqual(@as(u8, 4), ms.count); + try testing.expect(ms.normal.approxEql(vr(0, -1, 0), diff_tol)); + } +} From b8b9ac2f9d26d1e5be6f8a9b0c2172c928cf4724 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Mon, 20 Jul 2026 18:47:30 +0200 Subject: [PATCH 08/24] docs(forge): log M1.1.4 E3 execution --- briefs/M1.1.4-narrowphase-fast-paths.md | 47 +++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/briefs/M1.1.4-narrowphase-fast-paths.md b/briefs/M1.1.4-narrowphase-fast-paths.md index cae1a7b..359052f 100644 --- a/briefs/M1.1.4-narrowphase-fast-paths.md +++ b/briefs/M1.1.4-narrowphase-fast-paths.md @@ -204,8 +204,55 @@ Specs were pre-staged in `~/Downloads/M1.1.4-narrowphase-fast-paths/` (mtimes `zig build test` green debug + ReleaseSafe (f32); `zig build test-forge-3d -Dphysics_f64=true` green debug + ReleaseSafe. +### E3 — box/box SAT (2026-07-20) + +- **Kernel (`fast_paths.zig` `boxBox`).** Separating-axis test over the 15 + candidate axes (3 A-face + 3 B-face normals + 9 edge×edge cross products, + parallel-edge degeneracy guarded by `|cross|² ≤ par_eps`). First axis with no + overlap → `.separated` (noise margin mirrors `gjk.zig`). Otherwise the + least-overlap axis (face-preferring bias on ties) is the normal (oriented A→B + by `sign(Δcentre · axis)`) + depth. Gated on both boxes `radius == 0` (rounded + → `.not_handled`). Seed → the unchanged `generateManifold`: a FACE axis drives + the supporting-face clip (multi-point); an EDGE axis drives the FIX-1 + single-witness fallback, whose witness = closest points between the two + contributing box edges (`contactEdge` + Ericson `closestSegSeg`). Helpers + `boxOverlapOnAxis`, `boxSupportPoint`. Robust at ANY aspect (SAT has no GJK + degeneracy — the box/box P1d fix). +- **Tests (`fast_paths_test.zig`).** (1) `box/box SAT differential vs generic + (<=30:1)` — a rotation × offset × global sweep hitting face-face, face-vertex + and edge-edge; fast vs generic in BOTH orders, GATED on the oracle being + self-consistent across orders (`genericConsistent`); asserts ≥ 20 configs + compared and both a multi-point and a single-witness manifold seen; plus clean + explicit fid-exact face-face configs (axis-aligned, 45° yaw, aspect 5/15/30). + (2) `box/box SAT extreme aspect (closed-form)` — 50:1 / 100:1 / 212:1 boxes, + face-face overlap, asserted against a CLOSED-FORM oracle (count 4, normal +Y, + pen 0.5, plane y=0.75, span = full ±hx), both orders. The whole M1.1.3 + `manifold_test` acceptance suite now routes box/box + sphere pairs THROUGH the + fast path (via `collide` / `collidePair`) and stays green — feature_ids, + reduction, order-independence (via `collide`), frame-stability all inherited. +- **Validation.** `zig fmt --check` / `zig build lint` / `zig build` clean; full + `zig build test` green debug + ReleaseSafe (f32); `zig build test-forge-3d + -Dphysics_f64=true` green debug + ReleaseSafe. + ## Recorded deviations +- **E3 — generic oracle gated on self-consistency (test-design adaptation).** The + brief frames the box/box differential as "vs `collideOrderedGeneric` bounded to + ≤30:1 (the regime where the oracle is reliable)". A sweep config surfaced that + `collideOrderedGeneric` (fixed-order, GJK/EPA in the frame of A) is NOT reliable + for a DEEP, ROTATED cube pair even at 1:1 aspect: the generic path returned pen + 1.047 in the forward order (matching the SAT ground truth) but pen 0 / count 1 + in the reverse order — an M1.1.3 EPA frame-dependence, independent of aspect + ratio. The SAT fast path is order-independent by construction and gave the + correct 1.047 both orders (its 15 axis overlaps are identical up to the A↔B + relabelling — verified). Adaptation: the differential compares fast vs generic + in both orders only where the generic oracle is self-consistent across orders + (`genericConsistent`), skipping the EPA-unreliable configs; the closed-form + extreme-aspect test and the full `manifold_test` suite (now routed through the + fast path) provide oracle-independent correctness. No GJK/EPA source touched + (out of scope). **Flag for review:** this is concrete evidence that the analytic + box/box fast path is more robust than the generic EPA, not only faster. + ## Blockers encountered ## Closing notes From a0772f316b824b889dd739542227d1632463db4f Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Mon, 20 Jul 2026 19:24:38 +0200 Subject: [PATCH 09/24] fix(forge): box/box SAT depth equals true MTV, add deep tests --- .../pipeline/narrowphase/fast_paths.zig | 15 ++- .../forge/forge_3d/tests/fast_paths_test.zig | 127 ++++++++++++++++++ 2 files changed, 136 insertions(+), 6 deletions(-) diff --git a/src/modules/forge/forge_3d/pipeline/narrowphase/fast_paths.zig b/src/modules/forge/forge_3d/pipeline/narrowphase/fast_paths.zig index a0ad6e4..d6370c8 100644 --- a/src/modules/forge/forge_3d/pipeline/narrowphase/fast_paths.zig +++ b/src/modules/forge/forge_3d/pipeline/narrowphase/fast_paths.zig @@ -279,9 +279,10 @@ fn fallbackLocalNormal(comptime T: type, c_local: math.Vec(3, T)) math.Vec(3, T) /// products (degeneracy-guarded: a near-parallel edge pair is skipped, its /// separation already covered by the face axes) — and returns `.separated` on /// the first axis with no overlap. Otherwise the least-overlap axis is the -/// contact normal + depth. A small face-preferring bias resolves face/edge ties -/// (matching EPA, and killing edge-axis numerical jitter). SAT has NO GJK -/// degeneracy, so this is robust at ANY aspect ratio (the box/box P1d fix). +/// contact normal + depth (a FACE wins an exact face/edge tie — the stable +/// manifold — so the reported depth is always the true SAT min-overlap / MTV). +/// SAT has NO GJK degeneracy, so this is robust at ANY aspect ratio (the box/box +/// P1d fix) and order-independent by construction. /// /// The seed feeds the unchanged `generateManifold`: a FACE axis (`n_a` aligned /// with a box face) drives the supporting-face clip → the multi-point manifold @@ -347,9 +348,11 @@ fn boxBox( } } - // Choose face vs edge (face-preferring on a tie), then orient the normal A→B. - const edge_bias: T = (if (T == f32) @as(T, 1.0e-3) else @as(T, 1.0e-6)) * scale; - const use_edge = have_edge and (edge_depth + edge_bias < face_depth); + // Choose the least-overlap axis. Strict `<` makes a FACE win an exact face/ + // edge tie (the stable manifold, and deterministic); the reported depth is + // therefore always the true SAT min-overlap (the MTV), never inflated by a + // bias — the oracle-free depth test relies on this. Then orient the normal A→B. + const use_edge = have_edge and (edge_depth < face_depth); var normal = if (use_edge) edge_axis else face_axis; const depth = if (use_edge) edge_depth else face_depth; if (dc.dot(normal) < 0) normal = normal.neg(); diff --git a/src/modules/forge/forge_3d/tests/fast_paths_test.zig b/src/modules/forge/forge_3d/tests/fast_paths_test.zig index a8b940d..8deceda 100644 --- a/src/modules/forge/forge_3d/tests/fast_paths_test.zig +++ b/src/modules/forge/forge_3d/tests/fast_paths_test.zig @@ -56,6 +56,51 @@ fn generic(sa: SupportShape, pa: Vec3r, ra: Quatr, sb: SupportShape, pb: Vec3r, /// is more accurate, so this bounds the ORACLE's error, not the kernel's. const diff_tol: Real = if (Real == f32) 1.0e-3 else 1.0e-8; +/// The deepest per-point penetration in a manifold — for a box/box contact this +/// equals the SAT min-overlap (the true MTV depth): the clip's deepest point (or +/// the single witness) carries the full penetration. +fn maxPen(m: ContactManifold) Real { + var p: Real = 0; + for (0..m.count) |i| p = @max(p, m.points[i].penetration); + return p; +} + +/// An INDEPENDENT box/box separating-axis min-overlap scan, recomputed inline in +/// the test (a second, trivial SAT) so the fast kernel's reported depth can be +/// checked against the true MTV WITHOUT any GJK/EPA oracle. Returns the least +/// overlap over the 15 axes (both boxes assumed radius 0 and overlapping). +fn satMinOverlap(pa: Vec3r, ra: Quatr, hea_v: Vec3r, pb: Vec3r, rb: Quatr, heb_v: Vec3r) Real { + const axa = [3]Vec3r{ ra.rotateVec3(Vec3r.unit_x), ra.rotateVec3(Vec3r.unit_y), ra.rotateVec3(Vec3r.unit_z) }; + const axb = [3]Vec3r{ rb.rotateVec3(Vec3r.unit_x), rb.rotateVec3(Vec3r.unit_y), rb.rotateVec3(Vec3r.unit_z) }; + const hea = hea_v.toArray(); + const heb = heb_v.toArray(); + const dc = pb.sub(pa); + const ov = struct { + fn f(L: Vec3r, xa: [3]Vec3r, ha: [3]Real, xb: [3]Vec3r, hb: [3]Real, d: Vec3r) Real { + var rap: Real = 0; + var rbp: Real = 0; + for (0..3) |k| { + rap += ha[k] * @abs(xa[k].dot(L)); + rbp += hb[k] * @abs(xb[k].dot(L)); + } + return rap + rbp - @abs(d.dot(L)); + } + }.f; + var mn: Real = std.math.floatMax(Real); + for (0..3) |k| mn = @min(mn, ov(axa[k], axa, hea, axb, heb, dc)); + for (0..3) |k| mn = @min(mn, ov(axb[k], axa, hea, axb, heb, dc)); + for (0..3) |i| { + for (0..3) |j| { + var L = axa[i].cross(axb[j]); + const l2 = L.dot(L); + if (l2 <= (if (Real == f32) @as(Real, 1.0e-6) else @as(Real, 1.0e-12))) continue; + L = L.scale(1.0 / @sqrt(l2)); + mn = @min(mn, ov(L, axa, hea, axb, heb, dc)); + } + } + return mn; +} + /// Exact manifold equality (bit-for-bit) — for pairs the dispatcher does NOT /// handle, where `collideOrdered` calls `collideOrderedGeneric` directly. fn manifoldsIdentical(a: ?ContactManifold, b: ?ContactManifold) bool { @@ -357,3 +402,85 @@ test "box/box SAT extreme aspect (closed-form)" { try testing.expect(ms.normal.approxEql(vr(0, -1, 0), diff_tol)); } } + +/// Assert `collideOrdered` is order-independent on this box pair in the +/// properties the generic EPA VIOLATES for deep rotated boxes: same `count`, +/// negated normal, and equal DEPTH (the deep-rotated EPA gave pen 1.047 one order +/// and pen 0 the other — see § Recorded deviations; the SAT kernel gives the same +/// depth + a negated normal both orders, by construction). +/// +/// The multi-point POSITIONS are deliberately NOT asserted equal for `count > 1`: +/// `generateManifold` (FROZEN, M1.1.3) selects the reference face by A/B order +/// (its `feature_id` ownership contract), so a fixed-order clip of a deep +/// face-face contact keeps a different — equally valid, coplanar, same-depth — +/// subset of the overlap polygon in each order. That is a clip artifact of the +/// fixed-order entry (present on the generic path too), canonicalized away by +/// `collide` (pose order) and `collidePair` (BodyId order); it is orthogonal to +/// the EPA depth/normal defect. For `count == 1` (single witness) the position IS +/// order-independent (the witness is symmetric) and is asserted. +fn expectOrderIndependent(sa: SupportShape, pa: Vec3r, ra: Quatr, sb: SupportShape, pb: Vec3r, rb: Quatr) !void { + const ab = ordered(sa, pa, ra, sb, pb, rb).?; + const ba = ordered(sb, pb, rb, sa, pa, ra).?; + try testing.expectEqual(ab.count, ba.count); + try testing.expect(ab.normal.approxEql(ba.normal.neg(), diff_tol)); + try testing.expectApproxEqAbs(maxPen(ab), maxPen(ba), diff_tol); + if (ab.count == 1) { + try testing.expect(ab.points[0].position.approxEql(ba.points[0].position, diff_tol)); + try testing.expectApproxEqAbs(ab.points[0].penetration, ba.points[0].penetration, diff_tol); + } +} + +test "box/box SAT deep rotated is correct (oracle-free)" { + // The prod-common, generic-EPA-unreliable region: a box deeply embedded in + // another under strong rotation (several axes carry large overlap). The + // generic oracle is skipped here (frame-dependent EPA — § Recorded + // deviations), so correctness is proven WITHOUT it, three ways: + // (a) `collideOrdered` is order-independent (the property EPA violates); + // (b) the reported depth equals an INDEPENDENT inline 15-axis SAT scan (the + // true MTV — proves the depth is not a mis-converged EPA value); + // (c) frame-invariance: under a rigid global transform the count/depth are + // invariant and the normal rotates with the transform. + const Pair = struct { a: SupportShape, b: SupportShape }; + const pairs = [_]Pair{ + .{ .a = boxShape(1, 1, 1), .b = boxShape(1, 1, 1) }, // 1:1 + .{ .a = boxShape(2, 1, 1.5), .b = boxShape(1.5, 2, 1) }, // moderate aspect + }; + const rots = [_]Quatr{ + Quatr.fromAxisAngle(Vec3r.unit_y, std.math.pi / 4.0), + Quatr.fromAxisAngle(Vec3r.unit_x, 0.4), + Quatr.fromAxisAngle(vr(1, 1, 1).normalize(), 0.62), + Quatr.fromAxisAngle(vr(2, -1, 0.5).normalize(), 0.9), + Quatr.fromAxisAngle(Vec3r.unit_z, 0.5), + }; + // Deep offsets (centres close ⇒ large overlap on multiple axes). + const offs = [_]Vec3r{ vr(0.3, 0.4, 0.5), vr(0.2, 0.6, 0.3), vr(0.5, 0.45, 0.4), vr(0.1, 0.7, 0.35) }; + const globals = [_]Quatr{ + Quatr.identity, + Quatr.fromAxisAngle(vr(1, 2, 3).normalize(), 0.7), + Quatr.fromAxisAngle(vr(-2, 1, 1).normalize(), -0.5), + }; + for (pairs) |p| { + for (rots) |r| { + for (offs) |o| { + const m0 = ordered(p.a, vr(0, 0, 0), Quatr.identity, p.b, o, r) orelse continue; + // (a) order-independence. + try expectOrderIndependent(p.a, vr(0, 0, 0), Quatr.identity, p.b, o, r); + // (b) depth == the true MTV (independent inline SAT scan). + const mtv = satMinOverlap(vr(0, 0, 0), Quatr.identity, p.a.core.box, o, r, p.b.core.box); + try testing.expectApproxEqAbs(mtv, maxPen(m0), diff_tol); + // (c) frame-invariance under a rigid global transform: count and + // depth invariant, normal rotated by g. (The exact 4-point SUBSET + // can differ under rotation when `reduceToFour` breaks an + // octagon→quad area tie differently at float noise — a reduction + // artifact, not a depth/normal defect — so the point set is + // validated by containment (d), not by frame-equivariance.) + for (globals) |g| { + const mg = ordered(p.a, g.rotateVec3(vr(0, 0, 0)), g, p.b, g.rotateVec3(o), g.mul(r)).?; + try testing.expectEqual(m0.count, mg.count); + try testing.expectApproxEqAbs(maxPen(m0), maxPen(mg), diff_tol); + try testing.expect(mg.normal.approxEql(g.rotateVec3(m0.normal), diff_tol)); + } + } + } + } +} From c15833dfafa68c18d5a0cdda459c27922635d554 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Mon, 20 Jul 2026 19:24:48 +0200 Subject: [PATCH 10/24] docs(forge): log M1.1.4 E3 re-review and epa defect --- briefs/M1.1.4-narrowphase-fast-paths.md | 70 ++++++++++++++++++------- 1 file changed, 50 insertions(+), 20 deletions(-) diff --git a/briefs/M1.1.4-narrowphase-fast-paths.md b/briefs/M1.1.4-narrowphase-fast-paths.md index 359052f..15f5940 100644 --- a/briefs/M1.1.4-narrowphase-fast-paths.md +++ b/briefs/M1.1.4-narrowphase-fast-paths.md @@ -210,9 +210,12 @@ Specs were pre-staged in `~/Downloads/M1.1.4-narrowphase-fast-paths/` (mtimes candidate axes (3 A-face + 3 B-face normals + 9 edge×edge cross products, parallel-edge degeneracy guarded by `|cross|² ≤ par_eps`). First axis with no overlap → `.separated` (noise margin mirrors `gjk.zig`). Otherwise the - least-overlap axis (face-preferring bias on ties) is the normal (oriented A→B - by `sign(Δcentre · axis)`) + depth. Gated on both boxes `radius == 0` (rounded - → `.not_handled`). Seed → the unchanged `generateManifold`: a FACE axis drives + least-overlap axis is the normal (oriented A→B by `sign(Δcentre · axis)`) + + depth; a FACE wins an exact face/edge tie via a strict `edge_depth < + face_depth` (no bias constant — so the reported depth is always the true SAT + min-overlap / MTV, which the oracle-free depth test relies on). Gated on both + boxes `radius == 0` (rounded → `.not_handled`). Seed → the unchanged + `generateManifold`: a FACE axis drives the supporting-face clip (multi-point); an EDGE axis drives the FIX-1 single-witness fallback, whose witness = closest points between the two contributing box edges (`contactEdge` + Ericson `closestSegSeg`). Helpers @@ -226,7 +229,17 @@ Specs were pre-staged in `~/Downloads/M1.1.4-narrowphase-fast-paths/` (mtimes explicit fid-exact face-face configs (axis-aligned, 45° yaw, aspect 5/15/30). (2) `box/box SAT extreme aspect (closed-form)` — 50:1 / 100:1 / 212:1 boxes, face-face overlap, asserted against a CLOSED-FORM oracle (count 4, normal +Y, - pen 0.5, plane y=0.75, span = full ±hx), both orders. The whole M1.1.3 + pen 0.5, plane y=0.75, span = full ±hx), both orders. **(3) `box/box SAT deep + rotated is correct (oracle-free)` [re-review addition]** — the prod-common, + generic-EPA-unreliable region (a box deeply embedded under strong rotation, 1:1 + and moderate aspect, ×5 rotations ×4 deep offsets ×±3 globals), validated with + NO oracle: (a) `collideOrdered` order-independent in count + negated normal + + DEPTH (the exact properties the generic EPA violates; positions asserted for + count==1 — the count>1 clip subset is a fixed-order `generateManifold` + ref-selection artifact, canonicalized by `collide`/`collidePair`); (b) reported + depth == an INDEPENDENT inline 15-axis SAT scan (`satMinOverlap`) — proves the + depth is the true MTV, not a mis-converged EPA value; (c) frame-invariance + (count/depth invariant, normal rotates with the global). The whole M1.1.3 `manifold_test` acceptance suite now routes box/box + sphere pairs THROUGH the fast path (via `collide` / `collidePair`) and stays green — feature_ids, reduction, order-independence (via `collide`), frame-stability all inherited. @@ -236,22 +249,39 @@ Specs were pre-staged in `~/Downloads/M1.1.4-narrowphase-fast-paths/` (mtimes ## Recorded deviations -- **E3 — generic oracle gated on self-consistency (test-design adaptation).** The - brief frames the box/box differential as "vs `collideOrderedGeneric` bounded to - ≤30:1 (the regime where the oracle is reliable)". A sweep config surfaced that - `collideOrderedGeneric` (fixed-order, GJK/EPA in the frame of A) is NOT reliable - for a DEEP, ROTATED cube pair even at 1:1 aspect: the generic path returned pen - 1.047 in the forward order (matching the SAT ground truth) but pen 0 / count 1 - in the reverse order — an M1.1.3 EPA frame-dependence, independent of aspect - ratio. The SAT fast path is order-independent by construction and gave the - correct 1.047 both orders (its 15 axis overlaps are identical up to the A↔B - relabelling — verified). Adaptation: the differential compares fast vs generic - in both orders only where the generic oracle is self-consistent across orders - (`genericConsistent`), skipping the EPA-unreliable configs; the closed-form - extreme-aspect test and the full `manifold_test` suite (now routed through the - fast path) provide oracle-independent correctness. No GJK/EPA source touched - (out of scope). **Flag for review:** this is concrete evidence that the analytic - box/box fast path is more robust than the generic EPA, not only faster. +- **E3 — box/box differential re-scoped + generic EPA frame-dependence + reclassified as an M1.1.3 DEFECT (Guy re-review, 2026-07-20).** + - **What was found.** `collideOrderedGeneric` (fixed-order, GJK/EPA in the + frame of A) is NOT a reliable oracle for a DEEP, ROTATED cube pair even at + 1:1 aspect: for one sweep config it returned pen 1.047 in the forward order + (matching the SAT ground truth) but pen 0 / count 1 in the reverse order. The + SAT fast path gave the correct 1.047 in both orders (its 15 axis overlaps are + identical up to the A↔B relabelling — verified inline). + - **Reclassification (NOT a mere test adaptation).** This is a **defect**: EPA + in the frame of A converges to different faces in the two frames, **violating + the frozen order-independence contract** of the M1.1.3 narrowphase + (`engine-physics-forge.md §303` — points and depths must be order-independent). + It is independent of aspect ratio (the documented P1d limit is a separate, + aspect issue). **Open decision** to be recorded in the **CLAUDE.md §3.4 patch + (E5, content from Claude.ai)**, pointing to a dedicated hotfix + **`m1.1.3-hf-epa-frame-dependence`** — creation/planning is **Guy's decision**, + not done here. + - **Residual exposure.** The four M1.1.4 fast paths bypass the generic EPA for + their pairs, so sphere/sphere, sphere/box, capsule/capsule and box/box are + now robust. **capsule/box — a frequent demo contact — stays on the generic + path** (not in the frozen four, out of scope), so it remains exposed to this + EPA defect until the hotfix. + - **What was done in E3 (this milestone).** (i) The differential compares fast + vs generic only where the generic oracle is self-consistent across orders + (`genericConsistent`), skipping EPA-unreliable configs. (ii) The + generic-unreliable deep+rotated region is instead covered by the ORACLE-FREE + test `box/box SAT deep rotated is correct (oracle-free)` — SAT + order-independence + inline-SAT MTV depth + frame-invariance — so that region + is validated, not skipped. (iii) The face-preferring bias was removed (strict + `<`) so the reported depth is exactly the SAT MTV. **No GJK/EPA source touched + (`gjk.zig`/`epa.zig` are out of scope for M1.1.4).** + - **Flag for review:** concrete evidence that the analytic box/box fast path is + more ROBUST than the generic EPA, not only faster. ## Blockers encountered From 9289b2f46a4b8bfa2136a2583c9d483997e5eba5 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Mon, 20 Jul 2026 19:37:57 +0200 Subject: [PATCH 11/24] test(forge): add box/box deep surface-witness position check --- briefs/M1.1.4-narrowphase-fast-paths.md | 5 ++- .../forge/forge_3d/tests/fast_paths_test.zig | 34 +++++++++++++++++-- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/briefs/M1.1.4-narrowphase-fast-paths.md b/briefs/M1.1.4-narrowphase-fast-paths.md index 15f5940..ee47bf3 100644 --- a/briefs/M1.1.4-narrowphase-fast-paths.md +++ b/briefs/M1.1.4-narrowphase-fast-paths.md @@ -239,7 +239,10 @@ Specs were pre-staged in `~/Downloads/M1.1.4-narrowphase-fast-paths/` (mtimes ref-selection artifact, canonicalized by `collide`/`collidePair`); (b) reported depth == an INDEPENDENT inline 15-axis SAT scan (`satMinOverlap`) — proves the depth is the true MTV, not a mis-converged EPA value; (c) frame-invariance - (count/depth invariant, normal rotates with the global). The whole M1.1.3 + (count/depth invariant, normal rotates with the global); (d) each contact + point's two surface witnesses `p ± (pen/2)·n` land on the two box surfaces + (`pointInBox`) — a per-point geometric validity check pinning the multi-point + POSITIONS oracle-free. The whole M1.1.3 `manifold_test` acceptance suite now routes box/box + sphere pairs THROUGH the fast path (via `collide` / `collidePair`) and stays green — feature_ids, reduction, order-independence (via `collide`), frame-stability all inherited. diff --git a/src/modules/forge/forge_3d/tests/fast_paths_test.zig b/src/modules/forge/forge_3d/tests/fast_paths_test.zig index 8deceda..32be6c9 100644 --- a/src/modules/forge/forge_3d/tests/fast_paths_test.zig +++ b/src/modules/forge/forge_3d/tests/fast_paths_test.zig @@ -147,6 +147,18 @@ fn expectBothOrders(sa: SupportShape, pa: Vec3r, ra: Quatr, sb: SupportShape, pb /// some generic point (position + penetration within tol; `feature_id` too when /// `check_fid`). Used for box/box, whose clip may emit points in a different /// order between the fast and generic paths. +/// Whether world point `q` lies inside-or-on the box core (centre `c`, rotation +/// `rot`, half-extents `he`) within `tol` — `q` mapped to the box's local frame +/// must satisfy `|local[k]| ≤ he[k] + tol` on every axis. +fn pointInBox(q: Vec3r, c: Vec3r, rot: Quatr, he: Vec3r, tol: Real) bool { + const local = rot.conjugate().rotateVec3(q.sub(c)).toArray(); + const h = he.toArray(); + for (0..3) |k| { + if (@abs(local[k]) > h[k] + tol) return false; + } + return true; +} + fn expectEquivalentUnordered(fast: ?ContactManifold, gen: ?ContactManifold, check_fid: bool) !void { try testing.expectEqual(fast == null, gen == null); if (fast == null) return; @@ -439,7 +451,11 @@ test "box/box SAT deep rotated is correct (oracle-free)" { // (b) the reported depth equals an INDEPENDENT inline 15-axis SAT scan (the // true MTV — proves the depth is not a mis-converged EPA value); // (c) frame-invariance: under a rigid global transform the count/depth are - // invariant and the normal rotates with the transform. + // invariant and the normal rotates with the transform; + // (d) every contact point's two surface witnesses `p ± (pen/2)·n` land on + // the two box surfaces — a per-point geometric validity check that pins + // the multi-point POSITIONS oracle-free (each point is midway between + // the two box surfaces along the normal, at half its own penetration). const Pair = struct { a: SupportShape, b: SupportShape }; const pairs = [_]Pair{ .{ .a = boxShape(1, 1, 1), .b = boxShape(1, 1, 1) }, // 1:1 @@ -472,14 +488,26 @@ test "box/box SAT deep rotated is correct (oracle-free)" { // depth invariant, normal rotated by g. (The exact 4-point SUBSET // can differ under rotation when `reduceToFour` breaks an // octagon→quad area tie differently at float noise — a reduction - // artifact, not a depth/normal defect — so the point set is - // validated by containment (d), not by frame-equivariance.) + // artifact, not a depth/normal defect — so multi-point positions + // are validated by (d) below, not by frame-equivariance.) for (globals) |g| { const mg = ordered(p.a, g.rotateVec3(vr(0, 0, 0)), g, p.b, g.rotateVec3(o), g.mul(r)).?; try testing.expectEqual(m0.count, mg.count); try testing.expectApproxEqAbs(maxPen(m0), maxPen(mg), diff_tol); try testing.expect(mg.normal.approxEql(g.rotateVec3(m0.normal), diff_tol)); } + // (d) each contact point sits midway between the two box surfaces + // along the normal: its witnesses `p ± (pen/2)·n` land one on each + // box (n is A→B, so the sign assignment is checked both ways). + const witness_tol: Real = if (Real == f32) 3.0e-3 else 1.0e-6; + for (0..m0.count) |i| { + const half = m0.normal.scale(m0.points[i].penetration * 0.5); + const wp = m0.points[i].position.add(half); + const wm = m0.points[i].position.sub(half); + const a_side = pointInBox(wm, vr(0, 0, 0), Quatr.identity, p.a.core.box, witness_tol) and pointInBox(wp, o, r, p.b.core.box, witness_tol); + const b_side = pointInBox(wp, vr(0, 0, 0), Quatr.identity, p.a.core.box, witness_tol) and pointInBox(wm, o, r, p.b.core.box, witness_tol); + try testing.expect(a_side or b_side); + } } } } From 1b93cffd2b535cfb078531c3716cbdc36fe78a46 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Mon, 20 Jul 2026 19:51:43 +0200 Subject: [PATCH 12/24] feat(forge): add capsule/capsule narrowphase fast path --- .../pipeline/narrowphase/fast_paths.zig | 80 ++++++++++++++++--- .../forge/forge_3d/tests/fast_paths_test.zig | 47 +++++++++-- 2 files changed, 112 insertions(+), 15 deletions(-) diff --git a/src/modules/forge/forge_3d/pipeline/narrowphase/fast_paths.zig b/src/modules/forge/forge_3d/pipeline/narrowphase/fast_paths.zig index d6370c8..843d997 100644 --- a/src/modules/forge/forge_3d/pipeline/narrowphase/fast_paths.zig +++ b/src/modules/forge/forge_3d/pipeline/narrowphase/fast_paths.zig @@ -21,9 +21,9 @@ //! **Status.** E1 laid the foundation (`ContactSeed`, `FastResult`, a no-op //! dispatcher). E2 wired the point-core pairs — **sphere/sphere** and //! **sphere/box** (both orders). E3 added **box/box** via a separating-axis test -//! (15 candidate axes → the least-overlap axis → seed). capsule/capsule (E4) -//! follows behind this same three-state contract. Anything not yet wired -//! (capsule/*, a rounded box) returns `.not_handled`. +//! (15 candidate axes → the least-overlap axis → seed). E4 added +//! **capsule/capsule** via closest-segment analytics. capsule/box and +//! sphere/capsule stay on the generic path; a rounded box returns `.not_handled`. //! //! **Classification parity with the generic oracle.** The `separated` decision //! mirrors `gjk.zig` exactly — `dist − r_sum > conv_k · floatEps(T) · @@ -88,8 +88,8 @@ pub fn FastResult(comptime T: type) type { /// closest-point ownership follow that order, so `generateManifold`'s feature_id /// halves swap with the order exactly as on the generic path. /// -/// Wired: sphere/sphere, sphere/box (E2), and box/box SAT (E3), all orders. -/// `.not_handled` for capsule/capsule (→ E4), capsule/box + sphere/capsule (stay +/// Wired: sphere/sphere, sphere/box (E2), box/box SAT (E3), and capsule/capsule +/// (E4), all orders. `.not_handled` for capsule/box + sphere/capsule (stay /// generic), and any box core with `radius > 0` (a rounded box — the kernels are /// radius-0-box only). pub fn fastSeed( @@ -119,7 +119,11 @@ pub fn fastSeed( }, .segment => return .not_handled, // box/capsule stays generic }, - .segment => return .not_handled, // capsule/capsule → E4, others stay generic + .segment => |ha| switch (shape_b.core) { + // capsule/capsule. + .segment => |hb| return capsuleCapsule(T, pos_a, rot_a, ha, shape_a.radius, pos_b, rot_b, hb, shape_b.radius), + else => return .not_handled, // capsule/sphere, capsule/box stay generic + }, } } @@ -443,6 +447,61 @@ fn closestSegSeg(comptime T: type, p1: math.Vec(3, T), q1: math.Vec(3, T), p2: m return .{ p1.add(d1.scale(s)), p2.add(d2.scale(t)) }; } +/// The capsule/capsule seed: the two cores are Y-axis segments (`±half_height` +/// in each local frame). Their closest points (Ericson `closestSegSeg`) plus the +/// inflation radii give the seed; `.separated` past the inflated margin (mirrors +/// `gjk.zig`, `coord_scale = |Δcentres| + half_height_a + half_height_b`). The +/// THREE contact regimes are produced by the unchanged `generateManifold` from +/// the segments' supporting features — end-on (a count-1 endpoint → 1 point), +/// crossed / non-parallel (1 witness, via the E1 generator fix), and parallel +/// projection overlap (2 points via `clipSegment`) — so the kernel only supplies +/// `(normal, closest points, depth)`, skipping the GJK descent. +fn capsuleCapsule( + comptime T: type, + ca: math.Vec(3, T), + ra: math.Quat(T), + ha: T, + r_a: T, + cb: math.Vec(3, T), + rb: math.Quat(T), + hb: T, + r_b: T, +) FastResult(T) { + const Vec3T = math.Vec(3, T); + const ay = ra.rotateVec3(Vec3T.unit_y); + const by = rb.rotateVec3(Vec3T.unit_y); + // Segment endpoints (a degenerate `half_height == 0` collapses to a point — + // `closestSegSeg` guards the zero-length denominators). + const cp = closestSegSeg(T, ca.sub(ay.scale(ha)), ca.add(ay.scale(ha)), cb.sub(by.scale(hb)), cb.add(by.scale(hb))); + const d = cp[1].sub(cp[0]); + const dist_sq = d.dot(d); + const dist = @sqrt(dist_sq); + const r_sum = r_a + r_b; + const coord_scale = cb.sub(ca).length() + ha + hb; + if (dist - r_sum > contactMargin(T, coord_scale)) return .separated; + const coincidence: T = if (T == f32) 1.0e-12 else 1.0e-24; + const normal = if (dist_sq > coincidence) d.scale(1.0 / dist) else capsuleFallbackNormal(T, ay, by, cb.sub(ca)); + return .{ .contact = .{ + .normal = normal, + .closest_a = cp[0], + .closest_b = cp[1], + .base_penetration = r_sum - dist, + } }; +} + +/// A deterministic separation normal for the measure-zero case of two segment +/// cores that touch (`dist ≈ 0`): the mutual perpendicular `axis_a × axis_b` +/// (the natural crossed-segment separation), else the centre direction, else +X. +fn capsuleFallbackNormal(comptime T: type, axis_a: math.Vec(3, T), axis_b: math.Vec(3, T), dcentre: math.Vec(3, T)) math.Vec(3, T) { + const cr = axis_a.cross(axis_b); + const cr2 = cr.dot(cr); + const noise: T = if (T == f32) 1.0e-12 else 1.0e-24; + if (cr2 > noise) return cr.scale(1.0 / @sqrt(cr2)); + const dc2 = dcentre.dot(dcentre); + if (dc2 > noise) return dcentre.scale(1.0 / @sqrt(dc2)); + return math.Vec(3, T).unit_x; +} + const testing = std.testing; test "fastSeed dispatch routing (E3 handles sphere and box pairs)" { @@ -466,11 +525,12 @@ test "fastSeed dispatch routing (E3 handles sphere and box pairs)" { try testing.expect(fastSeed(T, sphere, V.zero, Q.identity, box, far, Q.identity) == .separated); try testing.expect(fastSeed(T, box, V.zero, Q.identity, box, near, Q.identity) == .contact); // box/box (E3) try testing.expect(fastSeed(T, box, V.zero, Q.identity, box, far, Q.identity) == .separated); + try testing.expect(fastSeed(T, capsule, V.zero, Q.identity, capsule, near, Q.identity) == .contact); // capsule/capsule (E4) + try testing.expect(fastSeed(T, capsule, V.zero, Q.identity, capsule, far, Q.identity) == .separated); - // Not-yet-wired / unsupported pairs → not_handled (fall through to generic). - try testing.expect(fastSeed(T, capsule, V.zero, Q.identity, capsule, near, Q.identity) == .not_handled); // E4 - try testing.expect(fastSeed(T, capsule, V.zero, Q.identity, box, near, Q.identity) == .not_handled); // generic - try testing.expect(fastSeed(T, sphere, V.zero, Q.identity, capsule, near, Q.identity) == .not_handled); // generic + // Unsupported pairs → not_handled (fall through to generic). + try testing.expect(fastSeed(T, capsule, V.zero, Q.identity, box, near, Q.identity) == .not_handled); // capsule/box generic + try testing.expect(fastSeed(T, sphere, V.zero, Q.identity, capsule, near, Q.identity) == .not_handled); // sphere/capsule generic // Any rounded box → not_handled (kernels are radius-0-box only). try testing.expect(fastSeed(T, sphere, V.zero, Q.identity, rbox, near, Q.identity) == .not_handled); try testing.expect(fastSeed(T, rbox, V.zero, Q.identity, sphere, near, Q.identity) == .not_handled); diff --git a/src/modules/forge/forge_3d/tests/fast_paths_test.zig b/src/modules/forge/forge_3d/tests/fast_paths_test.zig index 32be6c9..0072bd5 100644 --- a/src/modules/forge/forge_3d/tests/fast_paths_test.zig +++ b/src/modules/forge/forge_3d/tests/fast_paths_test.zig @@ -192,15 +192,14 @@ fn isSingleWitnessClass(m: ContactManifold) bool { } test "not-yet-wired pairs equal the generic oracle exactly" { - // capsule/capsule (E4), capsule/box (stays generic), and any rounded box → + // capsule/box + sphere/capsule (stay generic) and any rounded box → // dispatcher returns `.not_handled`, so `collideOrdered` is the generic path - // verbatim. (sphere and box pairs are now wired — see the differentials.) - const zrot = Quatr.fromAxisAngle(Vec3r.unit_z, std.math.pi / 2.0); + // verbatim. (sphere, box and capsule/capsule pairs are now wired — see the + // differentials.) const Combo = struct { sa: SupportShape, pa: Vec3r, ra: Quatr, sb: SupportShape, pb: Vec3r, rb: Quatr }; const combos = [_]Combo{ - .{ .sa = capsuleShape(1, 0.5), .pa = vr(0, 0, 0), .ra = Quatr.identity, .sb = capsuleShape(1, 0.5), .pb = vr(0.8, 0, 0), .rb = Quatr.identity }, - .{ .sa = capsuleShape(1, 0.3), .pa = vr(0, 0, 0), .ra = Quatr.identity, .sb = capsuleShape(1, 0.3), .pb = vr(0, 0, 0.5), .rb = zrot }, .{ .sa = boxShape(1, 1, 1), .pa = vr(0, 0, 0), .ra = Quatr.identity, .sb = capsuleShape(0.5, 0.5), .pb = vr(1.3, 0, 0), .rb = Quatr.identity }, + .{ .sa = sphereShape(0.5), .pa = vr(0, 0, 0), .ra = Quatr.identity, .sb = capsuleShape(1, 0.3), .pb = vr(0.7, 0, 0), .rb = Quatr.identity }, .{ .sa = roundedBoxShape(0.5, 0.5, 0.5, 1.0), .pa = vr(0, 0, 0), .ra = Quatr.identity, .sb = roundedBoxShape(0.5, 0.5, 0.5, 1.0), .pb = vr(0, 2.5, 0), .rb = Quatr.identity }, .{ .sa = sphereShape(0.5), .pa = vr(0, 0, 0), .ra = Quatr.identity, .sb = roundedBoxShape(1, 1, 1, 0.2), .pb = vr(0, 1.3, 0), .rb = Quatr.identity }, .{ .sa = boxShape(1, 1, 1), .pa = vr(0, 0, 0), .ra = Quatr.identity, .sb = roundedBoxShape(1, 1, 1, 0.2), .pb = vr(0, 1.5, 0), .rb = Quatr.identity }, @@ -512,3 +511,41 @@ test "box/box SAT deep rotated is correct (oracle-free)" { } } } + +test "capsule/capsule differential vs generic (three regimes)" { + // Capsule/capsule is always shallow (segment cores are 1-D), so the generic + // GJK oracle is robust (no deep-EPA frame-dependence like box/box) — compared + // directly, both orders, fid-exact. The three regimes are produced by + // `generateManifold` from the segment features (E1-corrected generator). + const cap = capsuleShape(1, 0.3); + const cap2 = capsuleShape(0.6, 0.4); + const zrot = Quatr.fromAxisAngle(Vec3r.unit_z, std.math.pi / 2.0); + const globals = [_]Quatr{ Quatr.identity, Quatr.fromAxisAngle(vr(1, 2, 3).normalize(), 0.7) }; + const Case = struct { a: SupportShape, pb: Vec3r, b: SupportShape, rb: Quatr, count: u8 }; + const cases = [_]Case{ + .{ .a = cap, .pb = vr(0, 2.2, 0), .b = cap, .rb = Quatr.identity, .count = 1 }, // end-on (collinear, stacked) + .{ .a = cap, .pb = vr(0, 0, 0.5), .b = cap, .rb = zrot, .count = 1 }, // crossed (E1 fix) + .{ .a = cap, .pb = vr(0, 0.3, 0.4), .b = cap2, .rb = zrot, .count = 1 }, // crossed, different sizes + .{ .a = cap, .pb = vr(0.5, 0, 0), .b = cap, .rb = Quatr.identity, .count = 2 }, // parallel side-by-side + .{ .a = cap, .pb = vr(0.5, 0.4, 0), .b = cap, .rb = Quatr.identity, .count = 2 }, // parallel, staggered along Y + }; + for (globals) |g| { + for (cases) |c| { + const pa = g.rotateVec3(vr(0, 0, 0)); + const pb = g.rotateVec3(c.pb); + const rb = g.mul(c.rb); + const m = ordered(c.a, pa, g, c.b, pb, rb) orelse return error.UnexpectedSeparation; + try testing.expectEqual(c.count, m.count); // exact count per regime + try expectBothOrdersUnordered(c.a, pa, g, c.b, pb, rb, true); // fast ≡ generic, fid-exact, both orders + } + } + // Separated → null (both). + try testing.expect(ordered(cap, vr(0, 0, 0), Quatr.identity, cap, vr(3, 0, 0), Quatr.identity) == null); + // Closed-form crossed (the E1 oracle geometry, now via the fast path): count + // 1, normal +Z, pen 0.1, contact at (0,0,0.25). + const mc = ordered(cap, vr(0, 0, 0), Quatr.identity, cap, vr(0, 0, 0.5), zrot).?; + try testing.expectEqual(@as(u8, 1), mc.count); + try testing.expect(mc.normal.approxEql(vr(0, 0, 1), diff_tol)); + try testing.expectApproxEqAbs(@as(Real, 0.1), mc.points[0].penetration, diff_tol); + try testing.expect(mc.points[0].position.approxEql(vr(0, 0, 0.25), diff_tol)); +} From 093d1aa2286bb5b710d3da94b10017a6f19abfe5 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Mon, 20 Jul 2026 19:52:07 +0200 Subject: [PATCH 13/24] docs(forge): log M1.1.4 E4 execution --- briefs/M1.1.4-narrowphase-fast-paths.md | 26 +++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/briefs/M1.1.4-narrowphase-fast-paths.md b/briefs/M1.1.4-narrowphase-fast-paths.md index ee47bf3..52fd1f9 100644 --- a/briefs/M1.1.4-narrowphase-fast-paths.md +++ b/briefs/M1.1.4-narrowphase-fast-paths.md @@ -250,6 +250,32 @@ Specs were pre-staged in `~/Downloads/M1.1.4-narrowphase-fast-paths/` (mtimes `zig build test` green debug + ReleaseSafe (f32); `zig build test-forge-3d -Dphysics_f64=true` green debug + ReleaseSafe. +### E4 — capsule/capsule (segment/segment) (2026-07-20) + +- **Kernel (`fast_paths.zig` `capsuleCapsule`).** Cores are the two Y-axis + segments; their closest points (reusing E3's Ericson `closestSegSeg`) + the + inflation radii give the seed; `.separated` past the inflated margin (mirrors + `gjk.zig`, `coord_scale = |Δcentres| + ha + hb`). The THREE regimes are + produced by the UNCHANGED `generateManifold` from the segments' supporting + features — end-on (count-1 endpoint), crossed/non-parallel (single witness via + the **E1 generator fix**), parallel-projection overlap (2-point line contact + via `clipSegment`). The kernel only supplies `(normal, closest points, depth)`, + skipping the GJK descent. `capsuleFallbackNormal` handles the measure-zero + exact-crossing (mutual perpendicular `axis_a × axis_b` → centre dir → +X). No + radius gate (capsule inflation is expected). +- **Tests (`fast_paths_test.zig`).** `capsule/capsule differential vs generic + (three regimes)` — capsule/capsule is always shallow (segment cores 1-D), so + the generic GJK oracle is robust (no deep-EPA frame-dependence) and is compared + DIRECTLY, both orders, fid-exact, with the EXACT count asserted per regime + (end-on 1, crossed 1, crossed-different-sizes 1, parallel-side 2, parallel- + staggered 2), × 2 globals; separated → null; closed-form crossed (the E1 oracle + geometry via the fast path — count 1, +Z, pen 0.1, (0,0,0.25)). Inline + dispatch-routing test updated (capsule/capsule now handled). The E1 manifold + crossed / nearly-parallel / degenerate tests already cover the generator side. +- **Validation.** `zig fmt --check` / `zig build lint` / `zig build` clean; full + `zig build test` green debug + ReleaseSafe (f32); `zig build test-forge-3d + -Dphysics_f64=true` green debug + ReleaseSafe. + ## Recorded deviations - **E3 — box/box differential re-scoped + generic EPA frame-dependence From 215888e6780339cab7727f57c1aee8b59e8e475e Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Mon, 20 Jul 2026 20:22:29 +0200 Subject: [PATCH 14/24] test(forge): add feature_id matrix audit and fast-path bench --- bench/forge_narrowphase.zig | 195 ++++++++++++++++++ bench/results/forge_narrowphase.md | 16 ++ build.zig | 27 +++ .../forge/forge_3d/tests/fast_paths_test.zig | 115 ++++++++++- 4 files changed, 348 insertions(+), 5 deletions(-) create mode 100644 bench/forge_narrowphase.zig create mode 100644 bench/results/forge_narrowphase.md diff --git a/bench/forge_narrowphase.zig b/bench/forge_narrowphase.zig new file mode 100644 index 0000000..6263956 --- /dev/null +++ b/bench/forge_narrowphase.zig @@ -0,0 +1,195 @@ +//! forge_3d narrowphase fast-path microbench (M1.1.4). +//! +//! For each of the four fast pairs (sphere/sphere, sphere/box, box/box, +//! capsule/capsule) it times the REAL dispatched `collideOrdered` (which routes +//! through the analytic kernel) against the generic GJK/EPA oracle +//! `collideOrderedGeneric` on the SAME pre-computed pose set, mixing contact and +//! separated inputs. A running checksum over the manifolds is written to the +//! report to defeat dead-code elimination. Reports per-pair ns/pair and the +//! dispatched/generic ratio; the fast path must be strictly faster on every +//! pair. ReleaseFast (a Debug/ReleaseSafe run only prints a warning — the ratio +//! is still meaningful, the absolute ns are not). Writes +//! `bench/results/forge_narrowphase.md`. + +const std = @import("std"); +const builtin = @import("builtin"); +const forge = @import("forge_3d"); + +const Real = forge.Real; +const Vec3r = forge.Vec3r; +const Quatr = forge.Quatr; +const SupportShape = forge.SupportShape; +const ContactManifold = forge.ContactManifold; + +const n_poses = 2000; // pre-computed B poses per pair (contact + separated mix) +const n_reps = 100; // measured repetitions over the whole pose set + +// --- Monotonic clock (mirrors bench/ipc_rtt.zig: clock_gettime on POSIX, QPC on +// Windows — std.time.Timer is avoided for the same cross-platform reason). --- + +const timespec_t = extern struct { tv_sec: i64, tv_nsec: i64 }; +const CLOCK_MONOTONIC: i32 = if (builtin.os.tag == .linux) 1 else 6; +extern "c" fn clock_gettime(clk_id: i32, tp: *timespec_t) c_int; +extern "kernel32" fn QueryPerformanceCounter(out: *i64) callconv(.winapi) i32; +extern "kernel32" fn QueryPerformanceFrequency(out: *i64) callconv(.winapi) i32; + +var qpc_freq_cached: i64 = 0; +fn qpcFreq() i64 { + if (qpc_freq_cached == 0) _ = QueryPerformanceFrequency(&qpc_freq_cached); + return qpc_freq_cached; +} + +fn nowNs() i64 { + return switch (builtin.os.tag) { + .windows => blk: { + var counter: i64 = 0; + _ = QueryPerformanceCounter(&counter); + const freq = qpcFreq(); + const sec_part: i64 = @divFloor(counter, freq); + const rem: i64 = counter - sec_part * freq; + break :blk sec_part * std.time.ns_per_s + @divFloor(rem * std.time.ns_per_s, freq); + }, + else => blk: { + var ts = timespec_t{ .tv_sec = 0, .tv_nsec = 0 }; + _ = clock_gettime(CLOCK_MONOTONIC, &ts); + break :blk ts.tv_sec * std.time.ns_per_s + ts.tv_nsec; + }, + }; +} + +fn sphereShape(radius: Real) SupportShape { + return .{ .core = .point, .radius = radius }; +} +fn boxShape(hx: Real, hy: Real, hz: Real) SupportShape { + return .{ .core = .{ .box = Vec3r.fromArray(.{ hx, hy, hz }) }, .radius = 0 }; +} +fn capsuleShape(half_height: Real, radius: Real) SupportShape { + return .{ .core = .{ .segment = half_height }, .radius = radius }; +} + +/// Fold a manifold into the anti-DCE checksum (reads normal, count, penetrations). +fn accumulate(chk: *f64, m: ?ContactManifold) void { + if (m) |x| { + chk.* += @as(f64, @floatFromInt(x.count)) + @as(f64, @floatCast(x.normal.toArray()[0])); + for (0..x.count) |i| chk.* += @as(f64, @floatCast(x.points[i].penetration)); + } +} + +const PairResult = struct { + name: []const u8, + disp_ns_per: f64, + gen_ns_per: f64, + ratio: f64, // dispatched / generic (< 1 ⇒ faster) +}; + +/// Time the dispatched path and the generic oracle over the same pose set. +fn benchPair(name: []const u8, sa: SupportShape, sb: SupportShape, positions: []const Vec3r, rotations: []const Quatr, chk: *f64) PairResult { + const origin = Vec3r.zero; + const id = Quatr.identity; + // Warm-up (untimed) — one pass each. + for (positions, rotations) |pb, rb| { + accumulate(chk, forge.collideOrdered(sa, origin, id, sb, pb, rb)); + accumulate(chk, forge.collideOrderedGeneric(sa, origin, id, sb, pb, rb)); + } + // Dispatched. + const t0 = nowNs(); + var rep: usize = 0; + while (rep < n_reps) : (rep += 1) { + for (positions, rotations) |pb, rb| accumulate(chk, forge.collideOrdered(sa, origin, id, sb, pb, rb)); + } + const disp_ns: f64 = @floatFromInt(nowNs() - t0); + // Generic oracle. + const t1 = nowNs(); + rep = 0; + while (rep < n_reps) : (rep += 1) { + for (positions, rotations) |pb, rb| accumulate(chk, forge.collideOrderedGeneric(sa, origin, id, sb, pb, rb)); + } + const gen_ns: f64 = @floatFromInt(nowNs() - t1); + const calls: f64 = @floatFromInt(n_poses * n_reps); + return .{ + .name = name, + .disp_ns_per = disp_ns / calls, + .gen_ns_per = gen_ns / calls, + .ratio = disp_ns / gen_ns, + }; +} + +pub fn main() !void { + if (builtin.mode != .ReleaseFast) { + std.debug.print("warning: forge-narrowphase bench should run in ReleaseFast (got {s}); the dispatched/generic ratio is still meaningful, absolute ns are not.\n", .{@tagName(builtin.mode)}); + } + + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena.deinit(); + const gpa = arena.allocator(); + + // Deterministic pose set (fixed seed): B positions in a cube around A (a mix + // of overlapping and clearly separated) + a random orientation each. Shared + // by all pairs so the dispatched and generic paths see identical inputs. + var prng = std.Random.DefaultPrng.init(0xF03B_1149); + const rng = prng.random(); + const positions = try gpa.alloc(Vec3r, n_poses); + const rotations = try gpa.alloc(Quatr, n_poses); + const range: Real = 2.5; + for (positions, rotations) |*p, *r| { + p.* = Vec3r.fromArray(.{ + (rng.float(Real) * 2 - 1) * range, + (rng.float(Real) * 2 - 1) * range, + (rng.float(Real) * 2 - 1) * range, + }); + var axis = Vec3r.fromArray(.{ rng.float(Real) * 2 - 1, rng.float(Real) * 2 - 1, rng.float(Real) * 2 - 1 }); + if (axis.dot(axis) < 1.0e-6) axis = Vec3r.unit_y; + r.* = Quatr.fromAxisAngle(axis.normalize(), rng.float(Real) * 2 * std.math.pi); + } + + var chk: f64 = 0; + var results: [4]PairResult = undefined; + results[0] = benchPair("sphere/sphere", sphereShape(1), sphereShape(1), positions, rotations, &chk); + results[1] = benchPair("sphere/box", sphereShape(0.7), boxShape(1, 1, 1), positions, rotations, &chk); + results[2] = benchPair("box/box", boxShape(1, 1, 1), boxShape(1, 1, 1), positions, rotations, &chk); + results[3] = benchPair("capsule/capsule", capsuleShape(1, 0.3), capsuleShape(1, 0.3), positions, rotations, &chk); + + var all_faster = true; + std.debug.print("\nforge narrowphase fast-path bench ({s}, {d} poses x {d} reps)\n", .{ @tagName(builtin.mode), n_poses, n_reps }); + std.debug.print(" {s:<16} {s:>12} {s:>12} {s:>8}\n", .{ "pair", "dispatched", "generic", "ratio" }); + for (results) |res| { + if (res.ratio >= 1.0) all_faster = false; + std.debug.print(" {s:<16} {d:>9.2} ns {d:>9.2} ns {d:>7.3}\n", .{ res.name, res.disp_ns_per, res.gen_ns_per, res.ratio }); + } + std.debug.print(" verdict: {s} (checksum {d})\n", .{ if (all_faster) "GO — dispatched faster on every pair" else "NO-GO", chk }); + + // Markdown report (arena-allocated pieces concatenated). + var buf: std.ArrayList(u8) = .empty; + try buf.appendSlice(gpa, try std.fmt.allocPrint(gpa, + \\# forge_3d narrowphase fast-path bench + \\ + \\- Build mode: {s} + \\- Poses per pair: {d} (contact + separated mix), reps: {d} + \\- Anti-DCE checksum: {d} + \\ + \\| pair | dispatched (ns/pair) | generic (ns/pair) | ratio (disp/gen) | + \\|---|---|---|---| + \\ + , .{ @tagName(builtin.mode), n_poses, n_reps, chk })); + for (results) |res| { + try buf.appendSlice(gpa, try std.fmt.allocPrint(gpa, "| {s} | {d:.2} | {d:.2} | {d:.3} |\n", .{ res.name, res.disp_ns_per, res.gen_ns_per, res.ratio })); + } + try buf.appendSlice(gpa, try std.fmt.allocPrint(gpa, + \\ + \\**Verdict:** {s} — the dispatched fast path must be strictly faster than + \\the generic GJK/EPA oracle on every pair (ratio < 1). Absolute ns are only + \\meaningful in ReleaseFast. + \\ + , .{if (all_faster) "GO" else "NO-GO"})); + + const bytes = buf.items; + const path: [:0]const u8 = "bench/results/forge_narrowphase.md"; + const fp = fopen(path.ptr, "w"); + if (fp == null) return error.WriteReportFailed; + defer _ = fclose(fp.?); + _ = fwrite(bytes.ptr, 1, bytes.len, fp.?); +} + +extern "c" fn fopen(path: [*:0]const u8, mode: [*:0]const u8) ?*anyopaque; +extern "c" fn fwrite(ptr: [*]const u8, size: usize, n: usize, stream: *anyopaque) usize; +extern "c" fn fclose(stream: *anyopaque) c_int; diff --git a/bench/results/forge_narrowphase.md b/bench/results/forge_narrowphase.md new file mode 100644 index 0000000..26d819d --- /dev/null +++ b/bench/results/forge_narrowphase.md @@ -0,0 +1,16 @@ +# forge_3d narrowphase fast-path bench + +- Build mode: ReleaseFast +- Poses per pair: 2000 (contact + separated mix), reps: 100 +- Anti-DCE checksum: 1356124.4934110916 + +| pair | dispatched (ns/pair) | generic (ns/pair) | ratio (disp/gen) | +|---|---|---|---| +| sphere/sphere | 38.97 | 67.17 | 0.580 | +| sphere/box | 56.31 | 204.78 | 0.275 | +| box/box | 177.10 | 746.42 | 0.237 | +| capsule/capsule | 13.90 | 128.67 | 0.108 | + +**Verdict:** GO — the dispatched fast path must be strictly faster than +the generic GJK/EPA oracle on every pair (ratio < 1). Absolute ns are only +meaningful in ReleaseFast. diff --git a/build.zig b/build.zig index 0ea7905..7df02df 100644 --- a/build.zig +++ b/build.zig @@ -904,6 +904,33 @@ pub fn build(b: *std.Build) void { ); bench_step.dependOn(&bench_run.step); + // ------------------------------ M1.1.4 forge narrowphase fast-path bench -- + // + // Per-pair dispatched `collideOrdered` vs the generic GJK/EPA oracle + // `collideOrderedGeneric`, same pose set, contact + separated, checksum + // anti-DCE. Writes `bench/results/forge_narrowphase.md`. ReleaseFast for the + // absolute ns; the dispatched/generic ratio is meaningful in any mode. + const forge_np_bench_module = b.createModule(.{ + .root_source_file = b.path("bench/forge_narrowphase.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + }); + forge_np_bench_module.addImport("forge_3d", forge_3d_module); + const forge_np_bench_exe = b.addExecutable(.{ + .name = "forge-narrowphase-bench", + .root_module = forge_np_bench_module, + }); + b.installArtifact(forge_np_bench_exe); + const forge_np_bench_run = b.addRunArtifact(forge_np_bench_exe); + forge_np_bench_run.step.dependOn(b.getInstallStep()); + if (b.args) |args| forge_np_bench_run.addArgs(args); + const forge_np_bench_step = b.step( + "bench-forge-narrowphase", + "Run the M1.1.4 forge narrowphase fast-path bench (dispatched vs generic per pair, writes bench/results/forge_narrowphase.md)", + ); + forge_np_bench_step.dependOn(&forge_np_bench_run.step); + // -------------------------------------- M1.0.5 scene loader bench -------- // // `loadFromBytes` on a ~10k-entity image synthesized in-bench via the diff --git a/src/modules/forge/forge_3d/tests/fast_paths_test.zig b/src/modules/forge/forge_3d/tests/fast_paths_test.zig index 0072bd5..39d2cb9 100644 --- a/src/modules/forge/forge_3d/tests/fast_paths_test.zig +++ b/src/modules/forge/forge_3d/tests/fast_paths_test.zig @@ -10,11 +10,12 @@ //! the id is inherited). Where the generic oracle is documented invalid (P1d //! extreme-aspect box), a CLOSED-FORM oracle replaces it. //! -//! **E2 content:** sphere/sphere + sphere/box differentials (both orders), -//! separated short-circuits, touch-exact, sphere-internal / coincident, and the -//! P1d point/box closed-form. Pairs not yet wired (box/box → E3, capsule/capsule -//! → E4, capsule/box + rounded box → generic) are still bit-identical to the -//! oracle, asserted below. +//! **Coverage.** sphere/sphere + sphere/box (E2), box/box (E3), capsule/capsule +//! (E4) differentials + closed-forms + separated short-circuits; the deep+rotated +//! box/box oracle-free suite (order-independence, inline-SAT MTV, frame-invariance, +//! surface-witness positions); and the consolidated `feature_id` producer × pair +//! × A/B-order matrix (E5). Pairs that stay on the generic path (capsule/box, +//! sphere/capsule, rounded box) are asserted bit-identical to the oracle. const std = @import("std"); const config = @import("../config.zig"); @@ -549,3 +550,107 @@ test "capsule/capsule differential vs generic (three regimes)" { try testing.expectApproxEqAbs(@as(Real, 0.1), mc.points[0].penetration, diff_tol); try testing.expect(mc.points[0].position.approxEql(vr(0, 0, 0.25), diff_tol)); } + +// --- E5: consolidated feature_id producer × pair × A/B-order matrix --- + +const fid_class_mask: u32 = 0xc000; +const fid_id_mask: u32 = 0x3fff; +const fid_class_a: u32 = 0x0000; // reference face / incident vertex +const fid_class_edge: u32 = 0x4000; // reference side plane / incident edge +const fid_class_c: u32 = 0x8000; // reference vertex (corner) / incident face + +/// Whether a `feature_id`'s class pair is one of the FOUR producers (class-tagged +/// disjoint ranges): kept vertex `(a,a)`, edge×ref-edge `(edge,edge)`, reference +/// corner `(c,c)`, or single witness `(a,c)`. +fn validClassPair(fid: u32) bool { + const r = (fid >> 16) & fid_class_mask; + const c = fid & fid_class_mask; + return (r == fid_class_a and c == fid_class_a) or + (r == fid_class_edge and c == fid_class_edge) or + (r == fid_class_c and c == fid_class_c) or + (r == fid_class_a and c == fid_class_c); +} + +/// Whether both halves carry a REAL sub-feature id in range for their class: +/// reference face 0..7 (box `axis·2+sign` 0..5 / segment 6 / point 7), reference +/// side-plane ≤ 23, reference vertex ≤ 759; incident vertex 0..7 (box sign-pattern +/// / segment endpoint 0/1 / point 0), incident edge ≤ 63, incident face 0..7. +fn validSubFeatures(fid: u32) bool { + const ref = fid >> 16; + const inc = fid & 0xffff; + const ref_class = ref & fid_class_mask; + const ref_id = ref & fid_id_mask; + const inc_class = inc & fid_class_mask; + const inc_id = inc & fid_id_mask; + const ref_ok = if (ref_class == fid_class_a) ref_id <= 7 else if (ref_class == fid_class_edge) ref_id <= 23 else ref_id <= 759; + const inc_ok = if (inc_class == fid_class_a) inc_id <= 7 else if (inc_class == fid_class_edge) inc_id <= 63 else inc_id <= 7; + return ref_ok and inc_ok; +} + +/// Whether every `feature_id` in the first `n` entries of `a` appears in `b`. +fn fidSetSubset(a: ContactManifold, b: ContactManifold) bool { + for (0..a.count) |i| { + var found = false; + for (0..b.count) |j| { + if (a.points[i].feature_id == b.points[j].feature_id) found = true; + } + if (!found) return false; + } + return true; +} + +/// Audit one fixed A/B order: every `feature_id` carries a valid producer class +/// pair + a real in-range sub-feature, and all ids are pairwise distinct. When +/// `frame_stable` (the config has no reduction tie), also assert the id SET is +/// frame-stable under a ±1e-4 shift (same fixed order) while the topology (count +/// + normal) is unchanged. A yawed face-face octagon is NOT frame_stable: its +/// `reduceToFour` keeps a different — equally valid — 4 of the 8 clip points +/// under a tiny shift (an area-tie reduction artifact, not a feature_id defect). +fn auditOrder(sa: SupportShape, pa: Vec3r, ra: Quatr, sb: SupportShape, pb: Vec3r, rb: Quatr, frame_stable: bool) !void { + const m = ordered(sa, pa, ra, sb, pb, rb) orelse return; // separated ⇒ no ids + for (0..m.count) |i| { + try testing.expect(validClassPair(m.points[i].feature_id)); + try testing.expect(validSubFeatures(m.points[i].feature_id)); + } + for (0..m.count) |i| { + for (i + 1..m.count) |j| try testing.expect(m.points[i].feature_id != m.points[j].feature_id); + } + if (!frame_stable) return; + inline for (.{ 1.0e-4, -1.0e-4 }) |d| { + const shifted = pb.add(vr(d, d, d)); + if (ordered(sa, pa, ra, sb, shifted, rb)) |ms| { + if (ms.count == m.count and m.normal.approxEql(ms.normal, diff_tol)) { + try testing.expect(fidSetSubset(m, ms) and fidSetSubset(ms, m)); + } + } + } +} + +test "feature_id producer x pair x order matrix" { + // Every fast pair × both orders × the regimes reachable per pair, asserting + // the class-tagged disjoint producer ranges, real in-range sub-features, + // uniqueness per manifold, and frame-stability of the id SET (fixed order). + const zrot = Quatr.fromAxisAngle(Vec3r.unit_z, std.math.pi / 2.0); + const yaw = Quatr.fromAxisAngle(Vec3r.unit_y, std.math.pi / 4.0); + const cap = capsuleShape(1, 0.3); + const box = boxShape(1, 1, 1); + const sph = sphereShape(0.5); + const Cfg = struct { a: SupportShape, b: SupportShape, pb: Vec3r, rb: Quatr, fs: bool }; + const cfgs = [_]Cfg{ + .{ .a = sphereShape(1), .b = sphereShape(1), .pb = vr(1.2, 0, 0), .rb = Quatr.identity, .fs = true }, // sphere/sphere (a,c) + .{ .a = sph, .b = box, .pb = vr(0, 0, 1.3), .rb = Quatr.identity, .fs = true }, // sphere/box face (a,c) + .{ .a = sph, .b = box, .pb = vr(0, 0.3, 0), .rb = Quatr.identity, .fs = true }, // sphere/box deep interior (a,c) + .{ .a = box, .b = boxShape(0.5, 1, 0.5), .pb = vr(0, 1.5, 0), .rb = Quatr.identity, .fs = true }, // box/box kept vertices (a,a) + .{ .a = box, .b = box, .pb = vr(0, 1.9, 0), .rb = yaw, .fs = false }, // box/box yawed face-face — edge×plane (edge,edge); octagon reduction tie + .{ .a = box, .b = box, .pb = vr(0.6, 1.5, 0.4), .rb = Quatr.identity, .fs = true }, // box/box staggered — reference corner (c,c) + .{ .a = cap, .b = cap, .pb = vr(0, 2.2, 0), .rb = Quatr.identity, .fs = true }, // capsule end-on (a,c) + .{ .a = cap, .b = cap, .pb = vr(0, 0, 0.5), .rb = zrot, .fs = true }, // capsule crossed (a,c) + .{ .a = cap, .b = capsuleShape(0.6, 0.3), .pb = vr(0.5, 0, 0), .rb = Quatr.identity, .fs = true }, // capsule parallel (shorter B strictly inside ⇒ stable segment clip) + }; + const pa = vr(0, 0, 0); + const ra = Quatr.identity; + for (cfgs) |c| { + try auditOrder(c.a, pa, ra, c.b, c.pb, c.rb, c.fs); // A/B order + try auditOrder(c.b, c.pb, c.rb, c.a, pa, ra, c.fs); // B/A order + } +} From 9355c7f738d531664c53efae451dc1978f633215 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Mon, 20 Jul 2026 20:22:54 +0200 Subject: [PATCH 15/24] docs(forge): log M1.1.4 E5 matrix and bench --- briefs/M1.1.4-narrowphase-fast-paths.md | 30 +++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/briefs/M1.1.4-narrowphase-fast-paths.md b/briefs/M1.1.4-narrowphase-fast-paths.md index 52fd1f9..44ca4e0 100644 --- a/briefs/M1.1.4-narrowphase-fast-paths.md +++ b/briefs/M1.1.4-narrowphase-fast-paths.md @@ -276,6 +276,36 @@ Specs were pre-staged in `~/Downloads/M1.1.4-narrowphase-fast-paths/` (mtimes `zig build test` green debug + ReleaseSafe (f32); `zig build test-forge-3d -Dphysics_f64=true` green debug + ReleaseSafe. +### E5 — consolidation (matrix + bench) (2026-07-20) + +- **feature_id matrix (`fast_paths_test.zig`).** `feature_id producer x pair x + order matrix` — every fast pair × both A/B orders × the reachable regimes + (sphere/sphere; sphere/box face + deep; box/box kept-vertices (a,a) + yawed + edge×plane (edge,edge) + staggered reference-corner (c,c); capsule end-on + + crossed + parallel), asserting: class-tagged disjoint producer pairs + (`validClassPair`), real in-range sub-features (`validSubFeatures` — ref face + 0..7 / side-plane ≤23 / vertex ≤759, incident vertex 0..7 / edge ≤63 / face + 0..7), uniqueness per manifold, and frame-stability of the id SET under a ±1e-4 + shift in a FIXED order (skipped for the yawed octagon, whose `reduceToFour` + keeps a different valid 4-of-8 under the shift — a reduction area-tie artifact, + documented via the `fs` flag; a boundary-aligned parallel capsule pair was + replaced with a strictly-inside shorter B so its endpoints stay kept). +- **Bench (`bench/forge_narrowphase.zig` + `build.zig` `bench-forge-narrowphase`).** + Per-pair REAL dispatched `collideOrdered` vs `collideOrderedGeneric` on the SAME + deterministic pose set (2000 poses × 100 reps, contact + separated), checksum + anti-DCE, writes `bench/results/forge_narrowphase.md`. ReleaseFast result + (M4-class dev machine): **dispatched strictly faster on every pair** — + sphere/sphere 0.580, sphere/box 0.275, box/box 0.237, capsule/capsule 0.108 + (dispatched/generic ratio; 1.7×–9.3× faster). Verdict GO. +- **Validation.** `zig fmt --check` / `zig build lint` / `zig build` clean; full + `zig build test` green debug + ReleaseSafe (f32); `zig build test-forge-3d + -Dphysics_f64=true` green debug + ReleaseSafe; `zig build bench-forge-narrowphase + -Doptimize=ReleaseFast` GO. +- **Pending (Guy):** the `CLAUDE.md §3.4` patch (with the EPA frame-dependence + Open decision + capsule/box residual exposure), the squash-commit message, and + the tag annotation are produced by Claude.ai against the final branch state; + the milestone is NOT closed, merged, or tagged here. + ## Recorded deviations - **E3 — box/box differential re-scoped + generic EPA frame-dependence From 695f1399d3e405dd0c38daf899858f1fa0cc3696 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Mon, 20 Jul 2026 21:08:21 +0200 Subject: [PATCH 16/24] fix(forge): resolve narrowphase P1 defects and harden fast-path tests --- bench/results/forge_narrowphase.md | 8 +- .../pipeline/narrowphase/fast_paths.zig | 126 ++++++++++---- .../forge/forge_3d/tests/fast_paths_test.zig | 162 +++++++++++++----- .../forge/forge_3d/tests/manifold_test.zig | 61 ++++--- 4 files changed, 248 insertions(+), 109 deletions(-) diff --git a/bench/results/forge_narrowphase.md b/bench/results/forge_narrowphase.md index 26d819d..dd5af15 100644 --- a/bench/results/forge_narrowphase.md +++ b/bench/results/forge_narrowphase.md @@ -6,10 +6,10 @@ | pair | dispatched (ns/pair) | generic (ns/pair) | ratio (disp/gen) | |---|---|---|---| -| sphere/sphere | 38.97 | 67.17 | 0.580 | -| sphere/box | 56.31 | 204.78 | 0.275 | -| box/box | 177.10 | 746.42 | 0.237 | -| capsule/capsule | 13.90 | 128.67 | 0.108 | +| sphere/sphere | 52.77 | 62.91 | 0.839 | +| sphere/box | 55.50 | 214.69 | 0.259 | +| box/box | 197.03 | 723.16 | 0.272 | +| capsule/capsule | 15.95 | 123.67 | 0.129 | **Verdict:** GO — the dispatched fast path must be strictly faster than the generic GJK/EPA oracle on every pair (ratio < 1). Absolute ns are only diff --git a/src/modules/forge/forge_3d/pipeline/narrowphase/fast_paths.zig b/src/modules/forge/forge_3d/pipeline/narrowphase/fast_paths.zig index 843d997..3a4b5ef 100644 --- a/src/modules/forge/forge_3d/pipeline/narrowphase/fast_paths.zig +++ b/src/modules/forge/forge_3d/pipeline/narrowphase/fast_paths.zig @@ -332,22 +332,33 @@ fn boxBox( face_axis = L; } } - // Edge×edge axes. - const par_eps: T = if (T == f32) 1.0e-6 else 1.0e-12; + // Edge×edge axes. SEPARATION is tested on EVERY non-zero cross axis using the + // NON-normalized `L_raw` (its overlap sign is preserved; the margin is scaled + // by `|L_raw| = √l2`) — a near-parallel edge pair can still be THE separating + // axis and must never be skipped for separation (P1-2). Only MTV CANDIDACY is + // gated by a true numerical floor `mtv_floor` (NOT `1e-6`): below it the + // normalized cross direction is numerical noise, and the parallel case's MTV + // is carried by the face axes anyway. + const mtv_floor: T = if (T == f32) 1.0e-10 else 1.0e-20; for (0..3) |i| { for (0..3) |j| { - var L = axa[i].cross(axb[j]); - const l2 = L.dot(L); - if (l2 <= par_eps) continue; // parallel edges — covered by the faces - L = L.scale(1.0 / @sqrt(l2)); - const ov = boxOverlapOnAxis(T, L, axa, hea, axb, heb, dc); - if (ov < -margin) return .separated; - if (!have_edge or ov < edge_depth) { - edge_depth = ov; - edge_axis = L; - edge_i = i; - edge_j = j; - have_edge = true; + const l_raw = axa[i].cross(axb[j]); + const l2 = l_raw.dot(l_raw); + if (l2 <= 0) continue; // exactly parallel ⇒ the zero axis carries no info + // Separation on the raw axis: `ov_raw = √l2 · true_overlap`, so + // `ov_raw < −margin·√l2 ⇔ true_overlap < −margin`. + const ov_raw = boxOverlapOnAxis(T, l_raw, axa, hea, axb, heb, dc); + if (ov_raw < -margin * @sqrt(l2)) return .separated; + if (l2 > mtv_floor) { + const L = l_raw.scale(1.0 / @sqrt(l2)); + const ov = boxOverlapOnAxis(T, L, axa, hea, axb, heb, dc); + if (!have_edge or ov < edge_depth) { + edge_depth = ov; + edge_axis = L; + edge_i = i; + edge_j = j; + have_edge = true; + } } } } @@ -422,27 +433,50 @@ fn contactEdge(comptime T: type, c: math.Vec(3, T), ax: [3]math.Vec(3, T), he: [ } /// Closest points between two segments `p1`–`q1` and `p2`–`q2` (Ericson RTCD -/// §5.1.9). Returns `[closest_on_1, closest_on_2]`. Both segments here are box -/// edges (non-degenerate, `he > 0`), so the segment lengths are strictly -/// positive; the parametric denominators are guarded regardless. +/// §5.1.9, ALL degenerate branches). Returns `[closest_on_1, closest_on_2]`. A +/// segment whose squared length is ≤ `seg_eps` is treated as a POINT: if both +/// degenerate, `s = t = 0`; if only segment 1, `s = 0`, `t = clamp(f/e)` (project +/// its point onto segment 2); if only segment 2, `t = 0`, `s = clamp(−c/a)` +/// (project its point onto segment 1). A `half_height == 0` capsule reaches these +/// point branches; box edges never do (P1-1). fn closestSegSeg(comptime T: type, p1: math.Vec(3, T), q1: math.Vec(3, T), p2: math.Vec(3, T), q2: math.Vec(3, T)) [2]math.Vec(3, T) { const d1 = q1.sub(p1); const d2 = q2.sub(p2); const r = p1.sub(p2); - const a = d1.dot(d1); - const e = d2.dot(d2); + const a = d1.dot(d1); // |seg1|² + const e = d2.dot(d2); // |seg2|² const f = d2.dot(r); - const c = d1.dot(r); - const b = d1.dot(d2); - const denom = a * e - b * b; - var s: T = if (denom > 0) std.math.clamp((b * f - c * e) / denom, 0, 1) else 0; - var t: T = if (e > 0) (b * s + f) / e else 0; - if (t < 0) { + const seg_eps: T = if (T == f32) 1.0e-10 else 1.0e-20; + var s: T = 0; + var t: T = 0; + if (a <= seg_eps and e <= seg_eps) { + // Both segments are points. + s = 0; t = 0; - s = if (a > 0) std.math.clamp(-c / a, 0, 1) else 0; - } else if (t > 1) { - t = 1; - s = if (a > 0) std.math.clamp((b - c) / a, 0, 1) else 0; + } else if (a <= seg_eps) { + // Segment 1 is a point ⇒ project it onto segment 2. + s = 0; + t = std.math.clamp(f / e, 0, 1); + } else { + const c = d1.dot(r); + if (e <= seg_eps) { + // Segment 2 is a point ⇒ project it onto segment 1. + t = 0; + s = std.math.clamp(-c / a, 0, 1); + } else { + // General non-degenerate case. + const b = d1.dot(d2); + const denom = a * e - b * b; + s = if (denom > 0) std.math.clamp((b * f - c * e) / denom, 0, 1) else 0; + t = (b * s + f) / e; + if (t < 0) { + t = 0; + s = std.math.clamp(-c / a, 0, 1); + } else if (t > 1) { + t = 1; + s = std.math.clamp((b - c) / a, 0, 1); + } + } } return .{ p1.add(d1.scale(s)), p2.add(d2.scale(t)) }; } @@ -490,16 +524,36 @@ fn capsuleCapsule( } /// A deterministic separation normal for the measure-zero case of two segment -/// cores that touch (`dist ≈ 0`): the mutual perpendicular `axis_a × axis_b` -/// (the natural crossed-segment separation), else the centre direction, else +X. +/// cores that touch (`dist ≈ 0`). For CROSSED axes the natural separation is the +/// mutual perpendicular `axis_a × axis_b`. For PARALLEL axes an axial normal is +/// WRONG (the capsules separate radially, not along their shared axis): take the +/// LATERAL (axis-perpendicular) component of `dcentre`; if that is zero too (a +/// pure collinear overlap), pick a deterministic perpendicular to the axis. Never +/// returns a component along the axis for a parallel pair (P1-3). fn capsuleFallbackNormal(comptime T: type, axis_a: math.Vec(3, T), axis_b: math.Vec(3, T), dcentre: math.Vec(3, T)) math.Vec(3, T) { + const noise: T = if (T == f32) 1.0e-12 else 1.0e-24; const cr = axis_a.cross(axis_b); const cr2 = cr.dot(cr); - const noise: T = if (T == f32) 1.0e-12 else 1.0e-24; - if (cr2 > noise) return cr.scale(1.0 / @sqrt(cr2)); - const dc2 = dcentre.dot(dcentre); - if (dc2 > noise) return dcentre.scale(1.0 / @sqrt(dc2)); - return math.Vec(3, T).unit_x; + if (cr2 > noise) return cr.scale(1.0 / @sqrt(cr2)); // crossed ⇒ mutual perpendicular + // Parallel axes: strip the axial component of `dcentre` → the radial direction. + const lateral = dcentre.sub(axis_a.scale(dcentre.dot(axis_a))); + const lat2 = lateral.dot(lateral); + if (lat2 > noise) return lateral.scale(1.0 / @sqrt(lat2)); + return perpendicularTo(T, axis_a); // pure collinear ⇒ any axis-perpendicular +} + +/// A deterministic unit vector perpendicular to `axis` (assumed unit): cross it +/// with the world basis vector least aligned with it (so the cross is well away +/// from zero), then normalize. +fn perpendicularTo(comptime T: type, axis: math.Vec(3, T)) math.Vec(3, T) { + const Vec3T = math.Vec(3, T); + const a = axis.toArray(); + const ax = @abs(a[0]); + const ay = @abs(a[1]); + const az = @abs(a[2]); + const basis = if (ax <= ay and ax <= az) Vec3T.unit_x else if (ay <= az) Vec3T.unit_y else Vec3T.unit_z; + const p = axis.cross(basis); + return p.scale(1.0 / @sqrt(p.dot(p))); } const testing = std.testing; diff --git a/src/modules/forge/forge_3d/tests/fast_paths_test.zig b/src/modules/forge/forge_3d/tests/fast_paths_test.zig index 39d2cb9..13eb3fb 100644 --- a/src/modules/forge/forge_3d/tests/fast_paths_test.zig +++ b/src/modules/forge/forge_3d/tests/fast_paths_test.zig @@ -92,11 +92,14 @@ fn satMinOverlap(pa: Vec3r, ra: Quatr, hea_v: Vec3r, pb: Vec3r, rb: Quatr, heb_v for (0..3) |k| mn = @min(mn, ov(axb[k], axa, hea, axb, heb, dc)); for (0..3) |i| { for (0..3) |j| { - var L = axa[i].cross(axb[j]); - const l2 = L.dot(L); - if (l2 <= (if (Real == f32) @as(Real, 1.0e-6) else @as(Real, 1.0e-12))) continue; - L = L.scale(1.0 / @sqrt(l2)); - mn = @min(mn, ov(L, axa, hea, axb, heb, dc)); + const l_raw = axa[i].cross(axb[j]); + const l2 = l_raw.dot(l_raw); + // Independent of the kernel: test EVERY non-parallel edge axis (only a + // strictly-zero cross carries no info). The normalized overlap is + // `ov_raw / √l2` — no `1e-6` skip is copied, so this oracle would catch + // a kernel that dropped a separating/min axis. + if (l2 <= 0) continue; + mn = @min(mn, ov(l_raw, axa, hea, axb, heb, dc) / @sqrt(l2)); } } return mn; @@ -148,16 +151,21 @@ fn expectBothOrders(sa: SupportShape, pa: Vec3r, ra: Quatr, sb: SupportShape, pb /// some generic point (position + penetration within tol; `feature_id` too when /// `check_fid`). Used for box/box, whose clip may emit points in a different /// order between the fast and generic paths. -/// Whether world point `q` lies inside-or-on the box core (centre `c`, rotation -/// `rot`, half-extents `he`) within `tol` — `q` mapped to the box's local frame -/// must satisfy `|local[k]| ≤ he[k] + tol` on every axis. -fn pointInBox(q: Vec3r, c: Vec3r, rot: Quatr, he: Vec3r, tol: Real) bool { +/// Whether world point `q` lies ON the box core's SURFACE (centre `c`, rotation +/// `rot`, half-extents `he`) within `tol`: `q` mapped to the box local frame must +/// be inside-or-on every axis (`|local[k]| ≤ he[k] + tol`) AND touch at least one +/// face (`|local[k]| ≥ he[k] − tol` for some `k`). Interiority alone is NOT +/// enough — a surface witness must prove incidence, else a too-small penetration +/// (a strictly-interior witness) would pass (P2-5). +fn pointOnBoxSurface(q: Vec3r, c: Vec3r, rot: Quatr, he: Vec3r, tol: Real) bool { const local = rot.conjugate().rotateVec3(q.sub(c)).toArray(); const h = he.toArray(); + var on_face = false; for (0..3) |k| { - if (@abs(local[k]) > h[k] + tol) return false; + if (@abs(local[k]) > h[k] + tol) return false; // outside ⇒ not on the surface + if (@abs(local[k]) >= h[k] - tol) on_face = true; // touches this face } - return true; + return on_face; } fn expectEquivalentUnordered(fast: ?ContactManifold, gen: ?ContactManifold, check_fid: bool) !void { @@ -496,16 +504,17 @@ test "box/box SAT deep rotated is correct (oracle-free)" { try testing.expectApproxEqAbs(maxPen(m0), maxPen(mg), diff_tol); try testing.expect(mg.normal.approxEql(g.rotateVec3(m0.normal), diff_tol)); } - // (d) each contact point sits midway between the two box surfaces - // along the normal: its witnesses `p ± (pen/2)·n` land one on each - // box (n is A→B, so the sign assignment is checked both ways). + // (d) each contact point sits midway between the two box SURFACES + // along the normal: its witnesses `p ± (pen/2)·n` each land ON a + // box surface (containment AND face incidence — P2-5), one per box + // (n is A→B, so the sign assignment is checked both ways). const witness_tol: Real = if (Real == f32) 3.0e-3 else 1.0e-6; for (0..m0.count) |i| { const half = m0.normal.scale(m0.points[i].penetration * 0.5); const wp = m0.points[i].position.add(half); const wm = m0.points[i].position.sub(half); - const a_side = pointInBox(wm, vr(0, 0, 0), Quatr.identity, p.a.core.box, witness_tol) and pointInBox(wp, o, r, p.b.core.box, witness_tol); - const b_side = pointInBox(wp, vr(0, 0, 0), Quatr.identity, p.a.core.box, witness_tol) and pointInBox(wm, o, r, p.b.core.box, witness_tol); + const a_side = pointOnBoxSurface(wm, vr(0, 0, 0), Quatr.identity, p.a.core.box, witness_tol) and pointOnBoxSurface(wp, o, r, p.b.core.box, witness_tol); + const b_side = pointOnBoxSurface(wp, vr(0, 0, 0), Quatr.identity, p.a.core.box, witness_tol) and pointOnBoxSurface(wm, o, r, p.b.core.box, witness_tol); try testing.expect(a_side or b_side); } } @@ -551,6 +560,50 @@ test "capsule/capsule differential vs generic (three regimes)" { try testing.expect(mc.points[0].position.approxEql(vr(0, 0, 0.25), diff_tol)); } +// --- E6: Codex review P1 regression repros (RED-first) --- + +test "degenerate-segment capsule pair is symmetric (P1-1)" { + // P1-1: `closestSegSeg` must handle a point (`h == 0`) segment in BOTH orders + // (Ericson RTCD §5.1.9 degenerate branches). A real capsule vs a + // sphere-degenerate capsule, cores 0.8 apart, r_sum 1.0 ⇒ contact pen 0.2 in + // EITHER order. Pre-fix the (h=1 then h=0) order took the `e ≤ eps` case as + // `s = 0` (no projection) ⇒ wrong distance ⇒ null. + const real = capsuleShape(1, 0.5); + const degen = capsuleShape(0, 0.5); // h = 0 ⇒ point core + const ab = ordered(real, vr(0, 0, 0), Quatr.identity, degen, vr(0.8, 0, 0), Quatr.identity) orelse return error.NoContactAB; + try testing.expectEqual(@as(u8, 1), ab.count); + try testing.expectApproxEqAbs(@as(Real, 0.2), ab.points[0].penetration, diff_tol); + const ba = ordered(degen, vr(0.8, 0, 0), Quatr.identity, real, vr(0, 0, 0), Quatr.identity) orelse return error.NoContactBA; + try testing.expectEqual(@as(u8, 1), ba.count); + try testing.expectApproxEqAbs(@as(Real, 0.2), ba.points[0].penetration, diff_tol); +} + +test "SAT tests every edge axis for separation (P1-2)" { + // P1-2: a near-parallel edge×edge axis can be THE separating axis. Pre-fix the + // `par_eps = 1e-6` skip dropped it ⇒ a false contact (visible at f32). This + // Codex config separates on A.x×B.x with overlap ≈ −0.003 ⇒ must be `null` + // (separated) in both orders. + const a = boxShape(100, 0.5, 1); + const b = boxShape(80, 0.4, 0.8); + const rot = Quatr.fromAxisAngle(vr(-0.7067754, -0.9417722, -0.8722187), 0.00084793355); + const pb = vr(31.123383, -0.89649105, -1.8084037); + try testing.expect(ordered(a, vr(0, 0, 0), Quatr.identity, b, pb, rot) == null); + try testing.expect(ordered(b, pb, rot, a, vr(0, 0, 0), Quatr.identity) == null); +} + +test "collinear capsules use a radial normal (P1-3)" { + // P1-3: two coaxial (collinear) Y-capsules with overlapping projections. The + // contact normal must be RADIAL (⊥ the shared Y axis), NOT axial — the + // parallel 2-point regime. Pre-fix the fallback returned +Y (axial) ⇒ count 1 + // with an invalid point. + const cap = capsuleShape(1, 0.3); + const m = ordered(cap, vr(0, 0, 0), Quatr.identity, cap, vr(0, 0.4, 0), Quatr.identity) orelse return error.NoContact; + try testing.expectApproxEqAbs(@as(Real, 0), m.normal.toArray()[1], diff_tol); // ⊥ Y + try testing.expectApproxEqAbs(@as(Real, 1), m.normal.length(), diff_tol); + try testing.expectEqual(@as(u8, 2), m.count); // parallel projection overlap + for (0..m.count) |i| try testing.expectApproxEqAbs(@as(Real, 0.6), m.points[i].penetration, diff_tol); +} + // --- E5: consolidated feature_id producer × pair × A/B-order matrix --- const fid_class_mask: u32 = 0xc000; @@ -599,58 +652,75 @@ fn fidSetSubset(a: ContactManifold, b: ContactManifold) bool { return true; } -/// Audit one fixed A/B order: every `feature_id` carries a valid producer class -/// pair + a real in-range sub-feature, and all ids are pairwise distinct. When -/// `frame_stable` (the config has no reduction tie), also assert the id SET is -/// frame-stable under a ±1e-4 shift (same fixed order) while the topology (count -/// + normal) is unchanged. A yawed face-face octagon is NOT frame_stable: its -/// `reduceToFour` keeps a different — equally valid — 4 of the 8 clip points -/// under a tiny shift (an area-tie reduction artifact, not a feature_id defect). -fn auditOrder(sa: SupportShape, pa: Vec3r, ra: Quatr, sb: SupportShape, pb: Vec3r, rb: Quatr, frame_stable: bool) !void { - const m = ordered(sa, pa, ra, sb, pb, rb) orelse return; // separated ⇒ no ids +/// Audit one fixed A/B order. The config MUST produce a manifold — no silent +/// return on separation (P2-6). Every `feature_id` carries a valid producer class +/// pair + a real in-range sub-feature, all ids are pairwise distinct, and the +/// config's EXPECTED producer class `(exp_ref, exp_inc)` appears at least once +/// (a regression deleting that producer reddens). When `frame_stable` (no +/// reduction tie), also assert the id SET is frame-stable under a ±1e-4 shift +/// (same fixed order) while the topology (count + normal) is unchanged. A yawed +/// face-face octagon is NOT frame_stable: `reduceToFour` keeps a different — +/// equally valid — 4 of the 8 clip points under a tiny shift (an area-tie +/// reduction artifact, not a feature_id defect). +fn auditOrder(sa: SupportShape, pa: Vec3r, ra: Quatr, sb: SupportShape, pb: Vec3r, rb: Quatr, frame_stable: bool, exp_ref: u32, exp_inc: u32) !bool { + const m = ordered(sa, pa, ra, sb, pb, rb) orelse return error.MatrixConfigUnexpectedlySeparated; + var has_expected = false; for (0..m.count) |i| { - try testing.expect(validClassPair(m.points[i].feature_id)); - try testing.expect(validSubFeatures(m.points[i].feature_id)); + const fid = m.points[i].feature_id; + try testing.expect(validClassPair(fid)); + try testing.expect(validSubFeatures(fid)); + if (((fid >> 16) & fid_class_mask) == exp_ref and (fid & fid_class_mask) == exp_inc) has_expected = true; } for (0..m.count) |i| { for (i + 1..m.count) |j| try testing.expect(m.points[i].feature_id != m.points[j].feature_id); } - if (!frame_stable) return; - inline for (.{ 1.0e-4, -1.0e-4 }) |d| { - const shifted = pb.add(vr(d, d, d)); - if (ordered(sa, pa, ra, sb, shifted, rb)) |ms| { - if (ms.count == m.count and m.normal.approxEql(ms.normal, diff_tol)) { - try testing.expect(fidSetSubset(m, ms) and fidSetSubset(ms, m)); + if (frame_stable) { + inline for (.{ 1.0e-4, -1.0e-4 }) |d| { + const shifted = pb.add(vr(d, d, d)); + if (ordered(sa, pa, ra, sb, shifted, rb)) |ms| { + if (ms.count == m.count and m.normal.approxEql(ms.normal, diff_tol)) { + try testing.expect(fidSetSubset(m, ms) and fidSetSubset(ms, m)); + } } } } + return has_expected; } test "feature_id producer x pair x order matrix" { // Every fast pair × both orders × the regimes reachable per pair, asserting // the class-tagged disjoint producer ranges, real in-range sub-features, - // uniqueness per manifold, and frame-stability of the id SET (fixed order). + // uniqueness per manifold, a non-null contact carrying the EXPECTED producer + // class, and frame-stability of the id SET (fixed order). const zrot = Quatr.fromAxisAngle(Vec3r.unit_z, std.math.pi / 2.0); const yaw = Quatr.fromAxisAngle(Vec3r.unit_y, std.math.pi / 4.0); const cap = capsuleShape(1, 0.3); const box = boxShape(1, 1, 1); const sph = sphereShape(0.5); - const Cfg = struct { a: SupportShape, b: SupportShape, pb: Vec3r, rb: Quatr, fs: bool }; + const a_a = fid_class_a; // reference face / incident vertex + const edge = fid_class_edge; // reference side plane / incident edge + const c_c = fid_class_c; // reference vertex (corner) / incident face + const Cfg = struct { a: SupportShape, b: SupportShape, pb: Vec3r, rb: Quatr, fs: bool, er: u32, ei: u32 }; const cfgs = [_]Cfg{ - .{ .a = sphereShape(1), .b = sphereShape(1), .pb = vr(1.2, 0, 0), .rb = Quatr.identity, .fs = true }, // sphere/sphere (a,c) - .{ .a = sph, .b = box, .pb = vr(0, 0, 1.3), .rb = Quatr.identity, .fs = true }, // sphere/box face (a,c) - .{ .a = sph, .b = box, .pb = vr(0, 0.3, 0), .rb = Quatr.identity, .fs = true }, // sphere/box deep interior (a,c) - .{ .a = box, .b = boxShape(0.5, 1, 0.5), .pb = vr(0, 1.5, 0), .rb = Quatr.identity, .fs = true }, // box/box kept vertices (a,a) - .{ .a = box, .b = box, .pb = vr(0, 1.9, 0), .rb = yaw, .fs = false }, // box/box yawed face-face — edge×plane (edge,edge); octagon reduction tie - .{ .a = box, .b = box, .pb = vr(0.6, 1.5, 0.4), .rb = Quatr.identity, .fs = true }, // box/box staggered — reference corner (c,c) - .{ .a = cap, .b = cap, .pb = vr(0, 2.2, 0), .rb = Quatr.identity, .fs = true }, // capsule end-on (a,c) - .{ .a = cap, .b = cap, .pb = vr(0, 0, 0.5), .rb = zrot, .fs = true }, // capsule crossed (a,c) - .{ .a = cap, .b = capsuleShape(0.6, 0.3), .pb = vr(0.5, 0, 0), .rb = Quatr.identity, .fs = true }, // capsule parallel (shorter B strictly inside ⇒ stable segment clip) + .{ .a = sphereShape(1), .b = sphereShape(1), .pb = vr(1.2, 0, 0), .rb = Quatr.identity, .fs = true, .er = a_a, .ei = c_c }, // sphere/sphere single witness (a,c) + .{ .a = sph, .b = box, .pb = vr(0, 0, 1.3), .rb = Quatr.identity, .fs = true, .er = a_a, .ei = c_c }, // sphere/box face (a,c) + .{ .a = sph, .b = box, .pb = vr(0, 0.3, 0), .rb = Quatr.identity, .fs = true, .er = a_a, .ei = c_c }, // sphere/box deep interior (a,c) + .{ .a = box, .b = boxShape(0.5, 1, 0.5), .pb = vr(0, 1.5, 0), .rb = Quatr.identity, .fs = true, .er = a_a, .ei = a_a }, // box/box kept vertices (a,a) + .{ .a = box, .b = box, .pb = vr(0, 1.9, 0), .rb = yaw, .fs = false, .er = edge, .ei = edge }, // box/box yawed — edge×plane (edge,edge) + .{ .a = box, .b = box, .pb = vr(0.6, 1.5, 0.4), .rb = Quatr.identity, .fs = true, .er = c_c, .ei = c_c }, // box/box staggered — reference corner (c,c) + .{ .a = cap, .b = cap, .pb = vr(0, 2.2, 0), .rb = Quatr.identity, .fs = true, .er = a_a, .ei = c_c }, // capsule end-on (a,c) + .{ .a = cap, .b = cap, .pb = vr(0, 0, 0.5), .rb = zrot, .fs = true, .er = a_a, .ei = c_c }, // capsule crossed (a,c) + .{ .a = cap, .b = capsuleShape(0.6, 0.3), .pb = vr(0.5, 0, 0), .rb = Quatr.identity, .fs = true, .er = a_a, .ei = a_a }, // capsule parallel — kept endpoints (a,a) }; const pa = vr(0, 0, 0); const ra = Quatr.identity; for (cfgs) |c| { - try auditOrder(c.a, pa, ra, c.b, c.pb, c.rb, c.fs); // A/B order - try auditOrder(c.b, c.pb, c.rb, c.a, pa, ra, c.fs); // B/A order + // Both orders are fully audited (class pair, sub-features, uniqueness, + // frame-stability). The expected producer CLASS is order-dependent for a + // clip (the reference face is chosen by A/B order), so it must appear in + // AT LEAST ONE order — a regression deleting the producer reddens both. + const ab = try auditOrder(c.a, pa, ra, c.b, c.pb, c.rb, c.fs, c.er, c.ei); + const ba = try auditOrder(c.b, c.pb, c.rb, c.a, pa, ra, c.fs, c.er, c.ei); + try testing.expect(ab or ba); } } diff --git a/src/modules/forge/forge_3d/tests/manifold_test.zig b/src/modules/forge/forge_3d/tests/manifold_test.zig index 0cc209a..019a681 100644 --- a/src/modules/forge/forge_3d/tests/manifold_test.zig +++ b/src/modules/forge/forge_3d/tests/manifold_test.zig @@ -168,32 +168,47 @@ test "crossed capsules yield a single witness contact" { } test "nearly-parallel capsules transition deterministically" { - // M1.1.4/E1: the parallel↔crossed classification is a pure function of the - // pose (no accumulated state), so re-running any config gives a byte-identical - // manifold — no flip under re-run. Sweep a small yaw band straddling the - // parallel threshold; every config is stable, and the point count only ever - // takes the two allowed values (2 = parallel line, 1 = crossed witness). + // M1.1.4/E1 (P2-4): sample the ACTUAL scalar-specific parallel threshold — + // `sin²θ ≤ parallel_rel` (`1e-6` f32 / `1e-12` f64) ⇒ θ_thr ≈ `1e-3` f32 / + // `1e-6` f64 — just below / at / clearly above, in BOTH fixed A/B orders. + // Below ⇒ parallel 2-point; above ⇒ crossed single witness; every case is + // byte-deterministic on re-run. (A coarse 0.01-rad step skips the whole band.) const cap = capsuleShape(1, 0.4); - var a: Real = 0; - while (a <= 0.20 + 1.0e-9) : (a += 0.01) { - const rot = Quatr.fromAxisAngle(Vec3r.unit_z, a); - // Cores 0.5 apart in Z, r_sum 0.8 ⇒ a genuine overlap at every angle. - const m1 = collide(cap, vr(0, 0, 0), Quatr.identity, cap, vr(0, 0, 0.5), rot).?; - const m2 = collide(cap, vr(0, 0, 0), Quatr.identity, cap, vr(0, 0, 0.5), rot).?; - // Deterministic: identical count, points, penetration, ids on re-run. - try testing.expectEqual(m1.count, m2.count); - try testing.expect(m1.count == 1 or m1.count == 2); - for (0..m1.count) |i| { - try testing.expect(m1.points[i].position.approxEql(m2.points[i].position, 1.0e-6)); - try testing.expectEqual(m1.points[i].penetration, m2.points[i].penetration); - try testing.expectEqual(m1.points[i].feature_id, m2.points[i].feature_id); + const thr: Real = if (Real == f32) 1.0e-3 else 1.0e-6; + const pa = vr(0, 0, 0); + const pb = vr(0, 0, 0.5); // cores 0.5 apart in Z ⇒ overlap at every angle + const co = struct { + fn f(sa: Vec3r, ra: Quatr, sb: Vec3r, rb: Quatr, cap_s: SupportShape) ContactManifold { + return narrowphase.collideOrdered(Real, cap_s, sa, ra, cap_s, sb, rb).?; + } + }.f; + const Case = struct { angle: Real, expect: u8 }; + const cases = [_]Case{ + .{ .angle = 0, .expect = 2 }, // exactly parallel + .{ .angle = 0.4 * thr, .expect = 2 }, // just below the threshold ⇒ parallel + .{ .angle = 4.0 * thr, .expect = 1 }, // clearly above ⇒ crossed + }; + for (cases) |c| { + const rot = Quatr.fromAxisAngle(Vec3r.unit_z, c.angle); + inline for (.{ true, false }) |ab| { + const m1 = if (ab) co(pa, Quatr.identity, pb, rot, cap) else co(pb, rot, pa, Quatr.identity, cap); + const m2 = if (ab) co(pa, Quatr.identity, pb, rot, cap) else co(pb, rot, pa, Quatr.identity, cap); + try testing.expectEqual(c.expect, m1.count); + try testing.expectEqual(m1.count, m2.count); // deterministic on re-run + for (0..m1.count) |i| { + try testing.expect(m1.points[i].position.approxEql(m2.points[i].position, 1.0e-6)); + try testing.expectEqual(m1.points[i].penetration, m2.points[i].penetration); + try testing.expectEqual(m1.points[i].feature_id, m2.points[i].feature_id); + } } } - // Exactly parallel (yaw 0) is the 2-point regime; a clear 0.2 rad yaw is a - // crossed single witness. - try testing.expectEqual(@as(u8, 2), collide(cap, vr(0, 0, 0), Quatr.identity, cap, vr(0, 0, 0.5), Quatr.identity).?.count); - const crossed = Quatr.fromAxisAngle(Vec3r.unit_z, 0.2); - try testing.expectEqual(@as(u8, 1), collide(cap, vr(0, 0, 0), Quatr.identity, cap, vr(0, 0, 0.5), crossed).?.count); + // At the threshold itself: the regime is a boundary (count 1 or 2), but the + // result is stable on re-run. + const at_rot = Quatr.fromAxisAngle(Vec3r.unit_z, thr); + const a1 = co(pa, Quatr.identity, pb, at_rot, cap); + const a2 = co(pa, Quatr.identity, pb, at_rot, cap); + try testing.expect(a1.count == 1 or a1.count == 2); + try testing.expectEqual(a1.count, a2.count); } test "degenerate capsule (half_height == 0) behaves as a sphere" { From d15eaeb8cd0b62d114d343e260d6d2204658760d Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Mon, 20 Jul 2026 21:08:27 +0200 Subject: [PATCH 17/24] docs(forge): log M1.1.4 E6 codex fixes --- briefs/M1.1.4-narrowphase-fast-paths.md | 48 +++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/briefs/M1.1.4-narrowphase-fast-paths.md b/briefs/M1.1.4-narrowphase-fast-paths.md index 44ca4e0..913b279 100644 --- a/briefs/M1.1.4-narrowphase-fast-paths.md +++ b/briefs/M1.1.4-narrowphase-fast-paths.md @@ -306,6 +306,54 @@ Specs were pre-staged in `~/Downloads/M1.1.4-narrowphase-fast-paths/` (mtimes the tag annotation are produced by Claude.ai against the final branch state; the milestone is NOT closed, merged, or tagged here. +### E6 — Codex review fixes (NO-GO merge → RED-first P1 + hardened P2) (2026-07-20) + +Codex review found 3 functional P1 bugs + 3 P2 test gaps (the tests that should +have caught them). Each P1 fixed RED-first (a repro that fails on the pre-fix +code, then passes). No `gjk.zig`/`epa.zig` touched (out of scope). + +- **P1-1 — `closestSegSeg` degenerate branches (`fast_paths.zig`).** Added the + full Ericson RTCD §5.1.9 point branches: both-points `s=t=0`; seg-1 point + `s=0, t=clamp(f/e)`; seg-2 point `t=0, s=clamp(−c/a)`. Pre-fix the `e ≤ eps` + (seg-2 point) case fell through as `s=0` (no projection) ⇒ wrong distance. Repro + `degenerate-segment capsule pair is symmetric (P1-1)`: capsule(h=1) vs + capsule(h=0) at (0.8,0,0) ⇒ contact pen 0.2 in BOTH orders (pre-fix one order + returned null). +- **P1-2 — SAT never skips a separating axis (`fast_paths.zig` + test oracle).** + The edge×edge loop now tests SEPARATION on every non-zero cross axis using the + NON-normalized `L_raw` (sign preserved; margin scaled by `√l2`); only MTV + candidacy is gated, by a true numerical floor `mtv_floor = 1e-10` (not the old + `1e-6` skip that dropped near-parallel separating axes). `satMinOverlap` (the + test's independent MTV oracle) likewise tests all `l2 > 0` axes via `ov_raw/√l2` + (no copied skip). Repro `SAT tests every edge axis for separation (P1-2)`: the + Codex 100:1/80:1 config separating on A.x×B.x (overlap ≈ −0.003) ⇒ `null` both + orders (pre-fix false contact at f32). +- **P1-3 — parallel/collinear capsules never use an axial normal + (`fast_paths.zig`).** `capsuleFallbackNormal`: crossed axes → mutual + perpendicular (unchanged); PARALLEL axes → strip `dcentre`'s axial component + (radial normal); pure collinear (zero lateral) → a deterministic + axis-perpendicular (`perpendicularTo`). Repro `collinear capsules use a radial + normal (P1-3)`: coaxial Y-capsules at y=0 / y=0.4 ⇒ normal ⊥ Y, parallel + 2-point, pen 0.6 (pre-fix count 1, axial +Y, invalid point). +- **P2-4 — nearly-parallel sweep samples the real threshold (`manifold_test.zig`).** + Sample `θ_thr ≈ √parallel_rel` (1e-3 f32 / 1e-6 f64) just below / at / clearly + above, in BOTH fixed orders, byte-deterministic on re-run (the old 0.01-rad step + skipped the whole band). +- **P2-5 — oracle (d) proves surface incidence (`fast_paths_test.zig`).** The + deep+rotated witness check now uses `pointOnBoxSurface` (containment AND ≥1 + local coord at ±he), not mere interiority — a too-small penetration + (strictly-interior witness) now reddens. +- **P2-6 — feature_id matrix requires a contact + the expected producer class + (`fast_paths_test.zig`).** `auditOrder` no longer returns silently on + separation (each config MUST contact) and reports whether its declared producer + class appears; the matrix asserts the class appears in ≥1 order (the class is + order-dependent for a clip — the reference face follows A/B order). A regression + deleting a producer reddens both orders. +- **Validation.** `zig fmt --check` / `zig build lint` / `zig build` clean; full + `zig build test` green debug + ReleaseSafe (f32); `zig build test-forge-3d + -Dphysics_f64=true` green debug + ReleaseSafe. Bench re-run GO (ratios + sphere/sphere 0.84, sphere/box 0.26, box/box 0.27, capsule/capsule 0.13). + ## Recorded deviations - **E3 — box/box differential re-scoped + generic EPA frame-dependence From 1826a9d4380f816eb5b91c1c50a93509923ca598 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Mon, 20 Jul 2026 22:49:02 +0200 Subject: [PATCH 18/24] fix(forge): make narrowphase length thresholds scale-relative --- bench/results/forge_narrowphase.md | 8 +- .../pipeline/narrowphase/fast_paths.zig | 63 ++++++++----- .../pipeline/narrowphase/manifold.zig | 25 ++++-- .../forge/forge_3d/tests/fast_paths_test.zig | 89 +++++++++++++++++++ 4 files changed, 156 insertions(+), 29 deletions(-) diff --git a/bench/results/forge_narrowphase.md b/bench/results/forge_narrowphase.md index dd5af15..0c7aeea 100644 --- a/bench/results/forge_narrowphase.md +++ b/bench/results/forge_narrowphase.md @@ -6,10 +6,10 @@ | pair | dispatched (ns/pair) | generic (ns/pair) | ratio (disp/gen) | |---|---|---|---| -| sphere/sphere | 52.77 | 62.91 | 0.839 | -| sphere/box | 55.50 | 214.69 | 0.259 | -| box/box | 197.03 | 723.16 | 0.272 | -| capsule/capsule | 15.95 | 123.67 | 0.129 | +| sphere/sphere | 38.96 | 68.53 | 0.569 | +| sphere/box | 56.88 | 204.37 | 0.278 | +| box/box | 199.50 | 736.46 | 0.271 | +| capsule/capsule | 15.19 | 128.74 | 0.118 | **Verdict:** GO — the dispatched fast path must be strictly faster than the generic GJK/EPA oracle on every pair (ratio < 1). Absolute ns are only diff --git a/src/modules/forge/forge_3d/pipeline/narrowphase/fast_paths.zig b/src/modules/forge/forge_3d/pipeline/narrowphase/fast_paths.zig index 3a4b5ef..c110166 100644 --- a/src/modules/forge/forge_3d/pipeline/narrowphase/fast_paths.zig +++ b/src/modules/forge/forge_3d/pipeline/narrowphase/fast_paths.zig @@ -142,6 +142,18 @@ fn contactMargin(comptime T: type, coord_scale: T) T { return conv_k * std.math.floatEps(T) * coord_scale; } +/// Squared coincidence threshold: two witness points are numerically coincident +/// (their difference is pure float rounding, so the A→B normal is undefined) when +/// `dist² ≤ (noise_k · floatEps(T) · coord_scale)²`. `coord_scale` is the ABSOLUTE +/// coordinate magnitude the `cb − ca` subtraction rounds over — NEVER an absolute +/// metric constant, and NOT `|Δcentres|` (which vanishes near coincidence). This +/// is the length-based-threshold discipline of `gjk.zig` / spec §315 (E7). +fn coincidenceEpsSq(comptime T: type, coord_scale: T) T { + const noise_k: T = 8; + const eps = noise_k * std.math.floatEps(T) * coord_scale; + return eps * eps; +} + /// The sphere/sphere seed: cores are the two centres (radius excluded). Shallow /// for any non-zero centre distance (points are 0-D, never "deep" unless /// coincident); `.separated` past the inflated margin; a deterministic +X @@ -153,8 +165,9 @@ fn sphereSphere(comptime T: type, ca: math.Vec(3, T), ra: T, cb: math.Vec(3, T), const dist = @sqrt(dist_sq); const r_sum = ra + rb; if (dist - r_sum > contactMargin(T, dist)) return .separated; - const coincidence: T = if (T == f32) 1.0e-12 else 1.0e-24; - const normal = if (dist_sq > coincidence) d.scale(1.0 / dist) else math.Vec(3, T).unit_x; + // Coincident ⟺ dist below the absolute-coordinate rounding scale (E7). + const coord_scale = ca.length() + cb.length(); + const normal = if (dist_sq > coincidenceEpsSq(T, coord_scale)) d.scale(1.0 / dist) else math.Vec(3, T).unit_x; return .{ .contact = .{ .normal = normal, .closest_a = ca, @@ -210,10 +223,11 @@ fn sphereBox( const dist = @sqrt(dist_sq); const coord_scale = sphere_c.sub(box_c).length() + boxExtent(T, box_he); if (dist - r_sum > contactMargin(T, coord_scale)) return .separated; - const coincidence: T = if (T == f32) 1.0e-12 else 1.0e-24; // Normal from the box surface toward the sphere centre; on the surface - // (dist ≈ 0, the shallow↔deep seam) fall back to the centre direction. - const n_local = if (dist_sq > coincidence) delta.scale(1.0 / dist) else fallbackLocalNormal(T, c_local); + // (dist ≈ 0, the shallow↔deep seam) fall back to the least-penetration + // face axis. Coincidence scales with the ABSOLUTE coordinate magnitude (E7). + const coincidence_scale = sphere_c.length() + box_c.length() + boxExtent(T, box_he); + const n_local = if (dist_sq > coincidenceEpsSq(T, coincidence_scale)) delta.scale(1.0 / dist) else fallbackLocalNormal(T, c_local); n_bs = box_rot.rotateVec3(n_local); base_penetration = r_sum - dist; } else { @@ -434,11 +448,14 @@ fn contactEdge(comptime T: type, c: math.Vec(3, T), ax: [3]math.Vec(3, T), he: [ /// Closest points between two segments `p1`–`q1` and `p2`–`q2` (Ericson RTCD /// §5.1.9, ALL degenerate branches). Returns `[closest_on_1, closest_on_2]`. A -/// segment whose squared length is ≤ `seg_eps` is treated as a POINT: if both -/// degenerate, `s = t = 0`; if only segment 1, `s = 0`, `t = clamp(f/e)` (project -/// its point onto segment 2); if only segment 2, `t = 0`, `s = clamp(−c/a)` -/// (project its point onto segment 1). A `half_height == 0` capsule reaches these -/// point branches; box edges never do (P1-1). +/// segment of EXACTLY zero length (`a == 0` / `e == 0`) is treated as a POINT: if +/// both degenerate, `s = t = 0`; if only segment 1, `s = 0`, `t = clamp(f/e)` +/// (project its point onto segment 2); if only segment 2, `t = 0`, `s = clamp(−c/a)` +/// (project its point onto segment 1). The test is EXACT zero — never an absolute +/// metric floor — so a genuinely resolvable but tiny segment (e.g. `a = 6.4e-11`) +/// takes the general branch (E7); a `half_height == 0` capsule gives `a == 0` +/// exactly and takes the point branch. Every sub-branch's denominator is +/// guarded (`a > 0`, `e > 0`, `denom > 0`) + clamped. fn closestSegSeg(comptime T: type, p1: math.Vec(3, T), q1: math.Vec(3, T), p2: math.Vec(3, T), q2: math.Vec(3, T)) [2]math.Vec(3, T) { const d1 = q1.sub(p1); const d2 = q2.sub(p2); @@ -446,20 +463,19 @@ fn closestSegSeg(comptime T: type, p1: math.Vec(3, T), q1: math.Vec(3, T), p2: m const a = d1.dot(d1); // |seg1|² const e = d2.dot(d2); // |seg2|² const f = d2.dot(r); - const seg_eps: T = if (T == f32) 1.0e-10 else 1.0e-20; var s: T = 0; var t: T = 0; - if (a <= seg_eps and e <= seg_eps) { - // Both segments are points. + if (a <= 0 and e <= 0) { + // Both segments are points (exact zero length). s = 0; t = 0; - } else if (a <= seg_eps) { + } else if (a <= 0) { // Segment 1 is a point ⇒ project it onto segment 2. s = 0; t = std.math.clamp(f / e, 0, 1); } else { const c = d1.dot(r); - if (e <= seg_eps) { + if (e <= 0) { // Segment 2 is a point ⇒ project it onto segment 1. t = 0; s = std.math.clamp(-c / a, 0, 1); @@ -513,8 +529,9 @@ fn capsuleCapsule( const r_sum = r_a + r_b; const coord_scale = cb.sub(ca).length() + ha + hb; if (dist - r_sum > contactMargin(T, coord_scale)) return .separated; - const coincidence: T = if (T == f32) 1.0e-12 else 1.0e-24; - const normal = if (dist_sq > coincidence) d.scale(1.0 / dist) else capsuleFallbackNormal(T, ay, by, cb.sub(ca)); + // Coincident witnesses scale with the ABSOLUTE coordinate magnitude (E7). + const coincidence_scale = ca.length() + cb.length() + ha + hb; + const normal = if (dist_sq > coincidenceEpsSq(T, coincidence_scale)) d.scale(1.0 / dist) else capsuleFallbackNormal(T, ay, by, cb.sub(ca)); return .{ .contact = .{ .normal = normal, .closest_a = cp[0], @@ -531,14 +548,20 @@ fn capsuleCapsule( /// pure collinear overlap), pick a deterministic perpendicular to the axis. Never /// returns a component along the axis for a parallel pair (P1-3). fn capsuleFallbackNormal(comptime T: type, axis_a: math.Vec(3, T), axis_b: math.Vec(3, T), dcentre: math.Vec(3, T)) math.Vec(3, T) { - const noise: T = if (T == f32) 1.0e-12 else 1.0e-24; + // `axis_a`, `axis_b` are UNIT, so `|axis_a × axis_b|² = sin²θ` is a + // DIMENSIONLESS parallelism test — a constant floor is correct here. + const sin2_floor: T = if (T == f32) 1.0e-12 else 1.0e-24; const cr = axis_a.cross(axis_b); const cr2 = cr.dot(cr); - if (cr2 > noise) return cr.scale(1.0 / @sqrt(cr2)); // crossed ⇒ mutual perpendicular + if (cr2 > sin2_floor) return cr.scale(1.0 / @sqrt(cr2)); // crossed ⇒ mutual perpendicular // Parallel axes: strip the axial component of `dcentre` → the radial direction. + // Collinear ⟺ the lateral fraction `lat2/dc2 = sin²(∠(dcentre, axis))` is + // negligible — a DIMENSIONLESS ratio (not an absolute length floor, E7). A + // pure collinear or coincident pair (`lat2 == 0`) picks a fixed perpendicular. const lateral = dcentre.sub(axis_a.scale(dcentre.dot(axis_a))); const lat2 = lateral.dot(lateral); - if (lat2 > noise) return lateral.scale(1.0 / @sqrt(lat2)); + const dc2 = dcentre.dot(dcentre); + if (lat2 > sin2_floor * dc2) return lateral.scale(1.0 / @sqrt(lat2)); return perpendicularTo(T, axis_a); // pure collinear ⇒ any axis-perpendicular } diff --git a/src/modules/forge/forge_3d/pipeline/narrowphase/manifold.zig b/src/modules/forge/forge_3d/pipeline/narrowphase/manifold.zig index f8d7afa..01e8f7b 100644 --- a/src/modules/forge/forge_3d/pipeline/narrowphase/manifold.zig +++ b/src/modules/forge/forge_3d/pipeline/narrowphase/manifold.zig @@ -187,9 +187,12 @@ pub fn collideOrderedGeneric( const sep = closest_b.sub(closest_a); const sep_len_sq = sep.dot(sep); // n = normalize(closest_b − closest_a); guard the dist ≈ 0 boundary (the - // shallow↔deep seam) with the centre-to-centre search direction. - const noise: T = if (T == f32) 1.0e-12 else 1.0e-24; - n_world = if (sep_len_sq > noise) sep.scale(1.0 / @sqrt(sep_len_sq)) else fallbackNormal(T, pos_a, pos_b); + // shallow↔deep seam) with the centre-to-centre search direction. The floor + // is SCALE-RELATIVE to the absolute witness magnitude (never an absolute + // metric constant, E7) — the scale the `closest_b − closest_a` subtraction + // rounds over. + const noise_scale = 8 * std.math.floatEps(T) * (closest_a.length() + closest_b.length()); + n_world = if (sep_len_sq > noise_scale * noise_scale) sep.scale(1.0 / @sqrt(sep_len_sq)) else fallbackNormal(T, pos_a, pos_b); base_penetration = r_sum - g.distance; } @@ -285,10 +288,14 @@ pub fn generateManifold( // world at the very end. var raw: [max_clip]Candidate(T) = undefined; var raw_n: usize = 0; - const keep_eps: T = if (T == f32) 1.0e-5 else 1.0e-10; for (clipped, 0..) |v, ci| { const s = v.sub(ref_pt).dot(rn); const pen = r_sum - s; + // Keep points on the boundary within float noise; SCALE-RELATIVE (never an + // absolute metric constant, E7): the rounding of `pen = r_sum − s` is + // `~floatEps·(r_sum + |v − ref_pt|)`, so a point more negative than that is + // genuinely outside and dropped. + const keep_eps = 16 * std.math.floatEps(T) * (r_sum + v.sub(ref_pt).length()); if (pen < -keep_eps) continue; // Surface points: reference face + r_ref outward; incident core − r_inc. const foot = v.sub(rn.scale(s)); @@ -671,7 +678,15 @@ fn faceCentroid(comptime T: type, face: support.Face(T)) math.Vec(3, T) { /// points are deduplicated first. Deterministic tie-breaks (strict `>`; first /// index wins). Writes the chosen `pts` indices into `idx`, returns the count. fn reduceToFour(comptime T: type, pts: []const Candidate(T), normal: math.Vec(3, T), idx: *[4]usize) usize { - const eps: T = if (T == f32) 1.0e-5 else 1.0e-10; + // Coincidence tolerance for the dedup: SCALE-RELATIVE to the point set's own + // A-frame extent (`k·floatEps·max|pos|`), never an absolute metric constant, + // so a manifold reduces identically at any scale (E7 — the class that broke + // small geometries). A-frame positions stay small even far from the world + // origin, so this also preserves the M1.1.3 far-from-origin reduction; at + // unit scale it is ≈ 1.6e-6 (f32), far below any distinct contact spacing. + var coord_scale: T = 0; + for (pts) |c| coord_scale = @max(coord_scale, c.pos.length()); + const eps: T = 16 * std.math.floatEps(T) * coord_scale; // Deduplicate coincident contacts (indices into `pts`). var uniq: [max_clip]usize = undefined; var un: usize = 0; diff --git a/src/modules/forge/forge_3d/tests/fast_paths_test.zig b/src/modules/forge/forge_3d/tests/fast_paths_test.zig index 13eb3fb..6eac582 100644 --- a/src/modules/forge/forge_3d/tests/fast_paths_test.zig +++ b/src/modules/forge/forge_3d/tests/fast_paths_test.zig @@ -604,6 +604,95 @@ test "collinear capsules use a radial normal (P1-3)" { for (0..m.count) |i| try testing.expectApproxEqAbs(@as(Real, 0.6), m.points[i].penetration, diff_tol); } +// --- E7: scale-invariance / small-geometry class (RED-first) --- + +test "capsule endpoint segment resolves at tiny scale (P1)" { + // P1 (E7): `closestSegSeg`'s degeneracy test must be EXACT zero, not an + // absolute `1e-10`. A capsule with `a = (2·4e-6)² = 6.4e-11 > 0` is a + // resolvable segment, but the old `seg_eps = 1e-10` collapsed it to a point. + // A (h=4e-6, r=1e-6) segment vs B (h=0, r=1e-6) point at y=5.5e-6 ⇒ closest + // core distance 1.5e-6 < r_sum 2e-6 ⇒ contact pen 5e-7, BOTH orders. + const a = capsuleShape(4e-6, 1e-6); + const b = capsuleShape(0, 1e-6); + const ab = ordered(a, vr(0, 0, 0), Quatr.identity, b, vr(0, 5.5e-6, 0), Quatr.identity) orelse return error.NoContactAB; + try testing.expectApproxEqAbs(@as(Real, 5e-7), ab.points[0].penetration, 1e-8); + const ba = ordered(b, vr(0, 5.5e-6, 0), Quatr.identity, a, vr(0, 0, 0), Quatr.identity) orelse return error.NoContactBA; + try testing.expectApproxEqAbs(@as(Real, 5e-7), ba.points[0].penetration, 1e-8); +} + +test "tiny spheres keep the center-to-center normal (P1)" { + // P1 (E7): the coincidence test on `dist_sq` must scale with the ABSOLUTE + // coordinate magnitude, not an absolute `1e-12`. Two r=3e-7 spheres 5e-7 apart + // in Y overlap (r_sum 6e-7 > 5e-7); the normal must be ±Y, not the +X + // coincidence fallback (`dist_sq = 2.5e-13` tripped the old absolute floor). + const s = sphereShape(3e-7); + const m = ordered(s, vr(0, 0, 0), Quatr.identity, s, vr(0, 5e-7, 0), Quatr.identity) orelse return error.NoContact; + try testing.expectEqual(@as(u8, 1), m.count); + const n = m.normal.toArray(); + try testing.expectApproxEqAbs(@as(Real, 0), n[0], diff_tol); // ⊥ X + try testing.expectApproxEqAbs(@as(Real, 0), n[2], diff_tol); // ⊥ Z + try testing.expectApproxEqAbs(@as(Real, 1), @abs(n[1]), diff_tol); // along ±Y +} + +/// Scale a support shape's core + radius by `s` (positions are scaled at the +/// call site) — for the scale-invariance class test. +fn scaleShape(sh: SupportShape, s: Real) SupportShape { + return switch (sh.core) { + .point => .{ .core = .point, .radius = sh.radius * s }, + .segment => |h| .{ .core = .{ .segment = h * s }, .radius = sh.radius * s }, + .box => |he| .{ .core = .{ .box = he.scale(s) }, .radius = sh.radius * s }, + }; +} + +/// Every fast manifold point of `scaled`, unscaled by `1/s`, matches a point of +/// the unit-scale `base` (position + penetration within `diff_tol` at unit +/// scale) — the equivariance check, robust at any `s` because both sides are +/// compared at unit scale. +fn manifoldsScaleEquivalent(base: ContactManifold, scaled: ContactManifold, s: Real) bool { + if (base.count != scaled.count) return false; + if (!base.normal.approxEql(scaled.normal, diff_tol)) return false; // normal is scale-INVARIANT + const inv = 1.0 / s; + for (0..scaled.count) |i| { + var matched = false; + for (0..base.count) |j| { + if (scaled.points[i].position.scale(inv).approxEql(base.points[j].position, diff_tol) and + @abs(scaled.points[i].penetration * inv - base.points[j].penetration) <= diff_tol) matched = true; + } + if (!matched) return false; + } + return true; +} + +test "fast paths are scale-invariant" { + // The guard the whole suite lacked (all other configs are ~unit scale): a + // fast manifold must be EQUIVARIANT under a uniform scale of positions AND + // sizes — normal invariant, positions + penetration scaled linearly, same + // count. Absolute metric thresholds (the P1 class) break this at ×1e-6 / ×1e6. + const zrot = Quatr.fromAxisAngle(Vec3r.unit_z, std.math.pi / 2.0); + const Cfg = struct { a: SupportShape, pa: Vec3r, ra: Quatr, b: SupportShape, pb: Vec3r, rb: Quatr }; + const cfgs = [_]Cfg{ + .{ .a = sphereShape(1), .pa = vr(0, 0, 0), .ra = Quatr.identity, .b = sphereShape(1), .pb = vr(1.2, 0, 0), .rb = Quatr.identity }, // sphere/sphere + .{ .a = sphereShape(0.5), .pa = vr(0, 0, 0), .ra = Quatr.identity, .b = boxShape(1, 1, 1), .pb = vr(0, 0, 1.3), .rb = Quatr.identity }, // sphere/box shallow + .{ .a = sphereShape(0.5), .pa = vr(0, 0, 0), .ra = Quatr.identity, .b = boxShape(1, 1, 1), .pb = vr(0, 0.3, 0), .rb = Quatr.identity }, // sphere/box deep + .{ .a = capsuleShape(1, 0.3), .pa = vr(0, 0, 0), .ra = Quatr.identity, .b = capsuleShape(1, 0.3), .pb = vr(0, 0, 0.5), .rb = zrot }, // capsule crossed + .{ .a = capsuleShape(1, 0.3), .pa = vr(0, 0, 0), .ra = Quatr.identity, .b = capsuleShape(1, 0.3), .pb = vr(0.5, 0, 0), .rb = Quatr.identity }, // capsule parallel + .{ .a = boxShape(1, 1, 1), .pa = vr(0, 0, 0), .ra = Quatr.identity, .b = boxShape(1, 1, 1), .pb = vr(0, 1.5, 0), .rb = Quatr.identity }, // box/box face + }; + const scales = [_]Real{ 1e-6, 1e6 }; + for (cfgs) |c| { + const base_ab = ordered(c.a, c.pa, c.ra, c.b, c.pb, c.rb).?; + const base_ba = ordered(c.b, c.pb, c.rb, c.a, c.pa, c.ra).?; + for (scales) |s| { + const sa = scaleShape(c.a, s); + const sb = scaleShape(c.b, s); + const m_ab = ordered(sa, c.pa.scale(s), c.ra, sb, c.pb.scale(s), c.rb).?; + try testing.expect(manifoldsScaleEquivalent(base_ab, m_ab, s)); + const m_ba = ordered(sb, c.pb.scale(s), c.rb, sa, c.pa.scale(s), c.ra).?; + try testing.expect(manifoldsScaleEquivalent(base_ba, m_ba, s)); + } + } +} + // --- E5: consolidated feature_id producer × pair × A/B-order matrix --- const fid_class_mask: u32 = 0xc000; From cd8ff7aa9a7dbb8700aee18193328c7fdd68854f Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Mon, 20 Jul 2026 22:49:13 +0200 Subject: [PATCH 19/24] docs(forge): log M1.1.4 E7 scale-invariance fixes --- briefs/M1.1.4-narrowphase-fast-paths.md | 49 +++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/briefs/M1.1.4-narrowphase-fast-paths.md b/briefs/M1.1.4-narrowphase-fast-paths.md index 913b279..b940940 100644 --- a/briefs/M1.1.4-narrowphase-fast-paths.md +++ b/briefs/M1.1.4-narrowphase-fast-paths.md @@ -354,6 +354,55 @@ code, then passes). No `gjk.zig`/`epa.zig` touched (out of scope). -Dphysics_f64=true` green debug + ReleaseSafe. Bench re-run GO (ratios sphere/sphere 0.84, sphere/box 0.26, box/box 0.27, capsule/capsule 0.13). +### E7 — scale-invariance: close the whole absolute-length-threshold class (2026-07-20) + +Codex round 3 (NO-GO) found 2 P1 confirmed, same ROOT: absolute metric length +thresholds break small geometries. **Principle** (from `gjk.zig` / spec §315, +which the fast paths must follow): no threshold on a length may be an absolute +metric constant — a length-based threshold is `k·floatEps(T)·coordScale`; a +DIMENSIONLESS threshold (`sin²θ` on unit vectors, a parametric `t ∈ [0,1]`) may +stay constant. Both P1 fixed RED-first. No `gjk.zig`/`epa.zig` touched. + +- **P1 (fast_paths.zig, 5 thresholds).** (1) `closestSegSeg` degeneracy → + EXACT zero (`a <= 0`, `e <= 0`), the divisions already guarded — a resolvable + `a = 6.4e-11` segment stays a segment, `h == 0` is a point. Repro `capsule + endpoint segment resolves at tiny scale`: A(h=4e-6) vs B(h=0) at y=5.5e-6 ⇒ pen + 5e-7 both orders (pre-fix null). (2) the THREE `dist_sq` coincidence tests — + `sphereSphere`, `sphereBox` shallow, `capsuleCapsule` → `coincidenceEpsSq(T, + coord_scale)` with `coord_scale = |ca| + |cb| + coreExtents` (the ABSOLUTE + coordinate magnitude the `cb − ca` subtraction rounds over — NOT `|Δcentres|`, + which vanishes near coincidence). Repro `tiny spheres keep the center-to-center + normal`: r=3e-7 spheres 5e-7 apart in Y ⇒ normal ±Y (pre-fix +X fallback). (3) + `capsuleFallbackNormal` `lat2` → relative `lat2 > sin2_floor · dc2` + (`lat2/dc2 = sin²∠(dcentre,axis)`, dimensionless); `cr2` stays a dimensionless + `sin²θ` floor. +- **Class guard — `fast paths are scale-invariant` (fast_paths_test.zig).** The + guard the whole suite lacked (all other configs are ~unit scale): a + representative sample of each fast pair, instantiated at unit scale, ×1e-6 and + ×1e6 (positions AND sizes), asserting equivariance — normal INVARIANT, positions + + penetration scaled linearly, count identical, both orders (compared at unit + scale by unscaling). This test would have caught both P1s. +- **Extension into the shared generator `manifold.zig` (flagged).** The + scale-invariance test at ×1e-6 exposed THREE more thresholds of the SAME class + in the M1.1.3 generator the fast paths route through — beyond Codex's 5-item + fast_paths list, fixed here to close the WHOLE class (per "fermer toute la + classe"): `reduceToFour`'s dedup `eps` (the confirmed culprit — merged the two + ~2e-6-apart parallel-capsule points at ×1e-6) → `16·floatEps·max|A-frame pos|`; + `generateManifold`'s `keep_eps` → `16·floatEps·(r_sum + |v − ref_pt|)` + per-point; `collideOrderedGeneric`'s shallow-fallback `noise` → + `(8·floatEps·(|closest_a|+|closest_b|))²`. A-frame magnitudes stay small even + far from the world origin, so the M1.1.3 far-from-origin reduction is preserved; + the whole M1.1.3 suite (routed through both fast and generic) stays green at + unit scale. Kept DIMENSIONLESS, unchanged: `mtv_floor`, `cr2`/`sin2_floor`, + `segmentsParallel`'s `parallel_rel`, `clipSegment`'s parametric `t` eps, + `face_face_min`. **Review note:** this is a genuine scope extension into + `manifold.zig` (only `segmentsParallel` was named off-limits there); it does NOT + touch `gjk.zig`/`epa.zig`. +- **Validation.** `zig fmt --check` / `zig build lint` / `zig build` clean; full + `zig build test` green debug + ReleaseSafe (f32); `zig build test-forge-3d + -Dphysics_f64=true` green debug + ReleaseSafe. Bench GO (ratios sphere/sphere + 0.57, sphere/box 0.28, box/box 0.27, capsule/capsule 0.12). + ## Recorded deviations - **E3 — box/box differential re-scoped + generic EPA frame-dependence From 1f7fca3fd1ef9ab914a5115158dcc1f57b4670c2 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Mon, 20 Jul 2026 23:25:03 +0200 Subject: [PATCH 20/24] fix(forge): enforce threshold-class invariance in narrowphase --- bench/results/forge_narrowphase.md | 8 +- .../pipeline/narrowphase/fast_paths.zig | 36 +++---- .../pipeline/narrowphase/manifold.zig | 43 +++++---- .../forge/forge_3d/tests/fast_paths_test.zig | 93 ++++++++++++++----- 4 files changed, 114 insertions(+), 66 deletions(-) diff --git a/bench/results/forge_narrowphase.md b/bench/results/forge_narrowphase.md index 0c7aeea..ff93897 100644 --- a/bench/results/forge_narrowphase.md +++ b/bench/results/forge_narrowphase.md @@ -6,10 +6,10 @@ | pair | dispatched (ns/pair) | generic (ns/pair) | ratio (disp/gen) | |---|---|---|---| -| sphere/sphere | 38.96 | 68.53 | 0.569 | -| sphere/box | 56.88 | 204.37 | 0.278 | -| box/box | 199.50 | 736.46 | 0.271 | -| capsule/capsule | 15.19 | 128.74 | 0.118 | +| sphere/sphere | 37.59 | 66.78 | 0.563 | +| sphere/box | 55.87 | 206.54 | 0.270 | +| box/box | 197.41 | 732.42 | 0.270 | +| capsule/capsule | 15.05 | 125.98 | 0.119 | **Verdict:** GO — the dispatched fast path must be strictly faster than the generic GJK/EPA oracle on every pair (ratio < 1). Absolute ns are only diff --git a/src/modules/forge/forge_3d/pipeline/narrowphase/fast_paths.zig b/src/modules/forge/forge_3d/pipeline/narrowphase/fast_paths.zig index c110166..2460df8 100644 --- a/src/modules/forge/forge_3d/pipeline/narrowphase/fast_paths.zig +++ b/src/modules/forge/forge_3d/pipeline/narrowphase/fast_paths.zig @@ -142,32 +142,21 @@ fn contactMargin(comptime T: type, coord_scale: T) T { return conv_k * std.math.floatEps(T) * coord_scale; } -/// Squared coincidence threshold: two witness points are numerically coincident -/// (their difference is pure float rounding, so the A→B normal is undefined) when -/// `dist² ≤ (noise_k · floatEps(T) · coord_scale)²`. `coord_scale` is the ABSOLUTE -/// coordinate magnitude the `cb − ca` subtraction rounds over — NEVER an absolute -/// metric constant, and NOT `|Δcentres|` (which vanishes near coincidence). This -/// is the length-based-threshold discipline of `gjk.zig` / spec §315 (E7). -fn coincidenceEpsSq(comptime T: type, coord_scale: T) T { - const noise_k: T = 8; - const eps = noise_k * std.math.floatEps(T) * coord_scale; - return eps * eps; -} - /// The sphere/sphere seed: cores are the two centres (radius excluded). Shallow /// for any non-zero centre distance (points are 0-D, never "deep" unless /// coincident); `.separated` past the inflated margin; a deterministic +X -/// fallback normal at coincidence (matching the generic `fallbackNormal`, where -/// the A→B axis is geometrically undefined — a measure-zero tie). +/// fallback normal ONLY at true coincidence. fn sphereSphere(comptime T: type, ca: math.Vec(3, T), ra: T, cb: math.Vec(3, T), rb: T) FastResult(T) { const d = cb.sub(ca); const dist_sq = d.dot(d); const dist = @sqrt(dist_sq); const r_sum = ra + rb; if (dist - r_sum > contactMargin(T, dist)) return .separated; - // Coincident ⟺ dist below the absolute-coordinate rounding scale (E7). - const coord_scale = ca.length() + cb.length(); - const normal = if (dist_sq > coincidenceEpsSq(T, coord_scale)) d.scale(1.0 / dist) else math.Vec(3, T).unit_x; + // `normalize(d)` is scale-EQUIVARIANT, so the only thing to guard is 0/0. The + // fallback fires ONLY at true coincidence (`dist² ≤ floatMin` — the type's + // underflow floor, NOT a geometric scale): translation- and scale-invariant by + // construction (E8, class A). + const normal = if (dist_sq > std.math.floatMin(T)) d.scale(1.0 / dist) else math.Vec(3, T).unit_x; return .{ .contact = .{ .normal = normal, .closest_a = ca, @@ -225,9 +214,9 @@ fn sphereBox( if (dist - r_sum > contactMargin(T, coord_scale)) return .separated; // Normal from the box surface toward the sphere centre; on the surface // (dist ≈ 0, the shallow↔deep seam) fall back to the least-penetration - // face axis. Coincidence scales with the ABSOLUTE coordinate magnitude (E7). - const coincidence_scale = sphere_c.length() + box_c.length() + boxExtent(T, box_he); - const n_local = if (dist_sq > coincidenceEpsSq(T, coincidence_scale)) delta.scale(1.0 / dist) else fallbackLocalNormal(T, c_local); + // face axis. `normalize(delta)` is scale-equivariant, so the fallback fires + // ONLY at true coincidence (`dist² ≤ floatMin`, E8 class A). + const n_local = if (dist_sq > std.math.floatMin(T)) delta.scale(1.0 / dist) else fallbackLocalNormal(T, c_local); n_bs = box_rot.rotateVec3(n_local); base_penetration = r_sum - dist; } else { @@ -529,9 +518,10 @@ fn capsuleCapsule( const r_sum = r_a + r_b; const coord_scale = cb.sub(ca).length() + ha + hb; if (dist - r_sum > contactMargin(T, coord_scale)) return .separated; - // Coincident witnesses scale with the ABSOLUTE coordinate magnitude (E7). - const coincidence_scale = ca.length() + cb.length() + ha + hb; - const normal = if (dist_sq > coincidenceEpsSq(T, coincidence_scale)) d.scale(1.0 / dist) else capsuleFallbackNormal(T, ay, by, cb.sub(ca)); + // `normalize(d)` is scale-equivariant ⇒ guard only 0/0: the fallback (radial / + // mutual-perpendicular) fires ONLY at true coincidence (`dist² ≤ floatMin`, + // e.g. collinear cores whose closest points coincide exactly) — E8 class A. + const normal = if (dist_sq > std.math.floatMin(T)) d.scale(1.0 / dist) else capsuleFallbackNormal(T, ay, by, cb.sub(ca)); return .{ .contact = .{ .normal = normal, .closest_a = cp[0], diff --git a/src/modules/forge/forge_3d/pipeline/narrowphase/manifold.zig b/src/modules/forge/forge_3d/pipeline/narrowphase/manifold.zig index 01e8f7b..4988421 100644 --- a/src/modules/forge/forge_3d/pipeline/narrowphase/manifold.zig +++ b/src/modules/forge/forge_3d/pipeline/narrowphase/manifold.zig @@ -186,13 +186,11 @@ pub fn collideOrderedGeneric( closest_b = g.closest_b; const sep = closest_b.sub(closest_a); const sep_len_sq = sep.dot(sep); - // n = normalize(closest_b − closest_a); guard the dist ≈ 0 boundary (the - // shallow↔deep seam) with the centre-to-centre search direction. The floor - // is SCALE-RELATIVE to the absolute witness magnitude (never an absolute - // metric constant, E7) — the scale the `closest_b − closest_a` subtraction - // rounds over. - const noise_scale = 8 * std.math.floatEps(T) * (closest_a.length() + closest_b.length()); - n_world = if (sep_len_sq > noise_scale * noise_scale) sep.scale(1.0 / @sqrt(sep_len_sq)) else fallbackNormal(T, pos_a, pos_b); + // n = normalize(closest_b − closest_a); `normalize` is scale-equivariant, + // so the only guard needed is 0/0 — fall back to the centre-to-centre + // direction ONLY at true coincidence (`|sep|² ≤ floatMin`, the underflow + // floor, never a geometric scale) — E8 class A. + n_world = if (sep_len_sq > std.math.floatMin(T)) sep.scale(1.0 / @sqrt(sep_len_sq)) else fallbackNormal(T, pos_a, pos_b); base_penetration = r_sum - g.distance; } @@ -678,21 +676,32 @@ fn faceCentroid(comptime T: type, face: support.Face(T)) math.Vec(3, T) { /// points are deduplicated first. Deterministic tie-breaks (strict `>`; first /// index wins). Writes the chosen `pts` indices into `idx`, returns the count. fn reduceToFour(comptime T: type, pts: []const Candidate(T), normal: math.Vec(3, T), idx: *[4]usize) usize { - // Coincidence tolerance for the dedup: SCALE-RELATIVE to the point set's own - // A-frame extent (`k·floatEps·max|pos|`), never an absolute metric constant, - // so a manifold reduces identically at any scale (E7 — the class that broke - // small geometries). A-frame positions stay small even far from the world - // origin, so this also preserves the M1.1.3 far-from-origin reduction; at - // unit scale it is ≈ 1.6e-6 (f32), far below any distinct contact spacing. - var coord_scale: T = 0; - for (pts) |c| coord_scale = @max(coord_scale, c.pos.length()); - const eps: T = 16 * std.math.floatEps(T) * coord_scale; + // Coincidence tolerance for the dedup: PER-AXIS relative to that axis's own + // A-frame coordinate scale (`eps[k] = 16·floatEps·max|pos[k]|`), NEVER an + // isotropic scalar (E8, class B). An isotropic eps driven by a large axis + // (e.g. a `he = (1.1e6, 1, 1)` box) would swamp a small axis and merge points + // genuinely separated along it. Two points coincide ⟺ `|Δ[k]| ≤ eps[k]` for + // ALL k — anisotropic-safe and coordinate-covariant. A-frame positions stay + // small far from the world origin, preserving the M1.1.3 reduction. + var coord_scale = [3]T{ 0, 0, 0 }; + for (pts) |c| { + const p = c.pos.toArray(); + inline for (0..3) |k| coord_scale[k] = @max(coord_scale[k], @abs(p[k])); + } + var eps: [3]T = undefined; + inline for (0..3) |k| eps[k] = 16 * std.math.floatEps(T) * coord_scale[k]; // Deduplicate coincident contacts (indices into `pts`). var uniq: [max_clip]usize = undefined; var un: usize = 0; outer: for (pts, 0..) |c, i| { + const cp = c.pos.toArray(); for (0..un) |j| { - if (c.pos.approxEql(pts[uniq[j]].pos, eps)) continue :outer; + const up = pts[uniq[j]].pos.toArray(); + var coincident = true; + inline for (0..3) |k| { + if (@abs(cp[k] - up[k]) > eps[k]) coincident = false; + } + if (coincident) continue :outer; } uniq[un] = i; un += 1; diff --git a/src/modules/forge/forge_3d/tests/fast_paths_test.zig b/src/modules/forge/forge_3d/tests/fast_paths_test.zig index 6eac582..556e641 100644 --- a/src/modules/forge/forge_3d/tests/fast_paths_test.zig +++ b/src/modules/forge/forge_3d/tests/fast_paths_test.zig @@ -644,30 +644,37 @@ fn scaleShape(sh: SupportShape, s: Real) SupportShape { }; } -/// Every fast manifold point of `scaled`, unscaled by `1/s`, matches a point of -/// the unit-scale `base` (position + penetration within `diff_tol` at unit -/// scale) — the equivariance check, robust at any `s` because both sides are -/// compared at unit scale. -fn manifoldsScaleEquivalent(base: ContactManifold, scaled: ContactManifold, s: Real) bool { - if (base.count != scaled.count) return false; - if (!base.normal.approxEql(scaled.normal, diff_tol)) return false; // normal is scale-INVARIANT +/// Equivariance of `m` (computed at scale `s` and global translation `t`) versus +/// the unit-scale/origin `base`: the normal is INVARIANT, and every point of `m`, +/// untranslated by `t` then unscaled by `1/s`, matches a `base` point (position + +/// penetration). Both sides are compared at unit scale, so the tolerance stays +/// meaningful; it widens with the untranslate/unscale amplification of the f32 +/// rounding at the absolute coordinate magnitude. +fn manifoldsEquivariant(base: ContactManifold, m: ContactManifold, s: Real, t: Vec3r) bool { + if (base.count != m.count) return false; + if (!base.normal.approxEql(m.normal, diff_tol)) return false; // normal is scale/translation-INVARIANT const inv = 1.0 / s; - for (0..scaled.count) |i| { + const t_mag = t.length(); + const eps = std.math.floatEps(Real); + const pos_tol = diff_tol + 64 * eps * (t_mag + @abs(s) * 2.5) * inv; + for (0..m.count) |i| { var matched = false; for (0..base.count) |j| { - if (scaled.points[i].position.scale(inv).approxEql(base.points[j].position, diff_tol) and - @abs(scaled.points[i].penetration * inv - base.points[j].penetration) <= diff_tol) matched = true; + if (m.points[i].position.sub(t).scale(inv).approxEql(base.points[j].position, pos_tol) and + @abs(m.points[i].penetration * inv - base.points[j].penetration) <= pos_tol) matched = true; } if (!matched) return false; } return true; } -test "fast paths are scale-invariant" { - // The guard the whole suite lacked (all other configs are ~unit scale): a - // fast manifold must be EQUIVARIANT under a uniform scale of positions AND - // sizes — normal invariant, positions + penetration scaled linearly, same - // count. Absolute metric thresholds (the P1 class) break this at ×1e-6 / ×1e6. +test "fast paths are scale and translation invariant" { + // The class guard the suite lacked (every other config is ~unit scale at the + // origin): a fast manifold must be EQUIVARIANT under a uniform scale of + // positions AND sizes AND a global translation — normal invariant, positions + // + penetration scaled/translated consistently, same count. The threshold + // classes broke this: an absolute floor (A) at extreme scale, a coord_scale + // guard (A) at large translation, an isotropic dedup (B) for anisotropy. const zrot = Quatr.fromAxisAngle(Vec3r.unit_z, std.math.pi / 2.0); const Cfg = struct { a: SupportShape, pa: Vec3r, ra: Quatr, b: SupportShape, pb: Vec3r, rb: Quatr }; const cfgs = [_]Cfg{ @@ -678,21 +685,63 @@ test "fast paths are scale-invariant" { .{ .a = capsuleShape(1, 0.3), .pa = vr(0, 0, 0), .ra = Quatr.identity, .b = capsuleShape(1, 0.3), .pb = vr(0.5, 0, 0), .rb = Quatr.identity }, // capsule parallel .{ .a = boxShape(1, 1, 1), .pa = vr(0, 0, 0), .ra = Quatr.identity, .b = boxShape(1, 1, 1), .pb = vr(0, 1.5, 0), .rb = Quatr.identity }, // box/box face }; - const scales = [_]Real{ 1e-6, 1e6 }; + const scales = [_]Real{ 1e-6, 1, 1e6 }; + const translations = [_]Real{ 0, 1e6 }; + const eps = std.math.floatEps(Real); for (cfgs) |c| { const base_ab = ordered(c.a, c.pa, c.ra, c.b, c.pb, c.rb).?; const base_ba = ordered(c.b, c.pb, c.rb, c.a, c.pa, c.ra).?; for (scales) |s| { - const sa = scaleShape(c.a, s); - const sb = scaleShape(c.b, s); - const m_ab = ordered(sa, c.pa.scale(s), c.ra, sb, c.pb.scale(s), c.rb).?; - try testing.expect(manifoldsScaleEquivalent(base_ab, m_ab, s)); - const m_ba = ordered(sb, c.pb.scale(s), c.rb, sa, c.pa.scale(s), c.ra).?; - try testing.expect(manifoldsScaleEquivalent(base_ba, m_ba, s)); + for (translations) |tm| { + // Skip combos where the geometry (feature scale `s`) is lost under + // the f32 resolution at the absolute magnitude (`|t| + s·extent`) — + // genuinely unrepresentable, not a defect. + if (@abs(s) <= 256 * eps * (tm + @abs(s) * 2.5)) continue; + const t = vr(tm, tm, tm); + const sa = scaleShape(c.a, s); + const sb = scaleShape(c.b, s); + const m_ab = ordered(sa, c.pa.scale(s).add(t), c.ra, sb, c.pb.scale(s).add(t), c.rb).?; + try testing.expect(manifoldsEquivariant(base_ab, m_ab, s, t)); + const m_ba = ordered(sb, c.pb.scale(s).add(t), c.rb, sa, c.pa.scale(s).add(t), c.ra).?; + try testing.expect(manifoldsEquivariant(base_ba, m_ba, s, t)); + } } } } +test "unit spheres far from the origin keep the center normal (P1 class A)" { + // Codex round-4 probe: `normalize(d)` is scale-EQUIVARIANT, so its 0/0 guard + // must fire only at TRUE zero, never a coord_scale floor. Two UNIT spheres at + // (1e6,1e6,1e6), 1.2 apart in Y: `d = cb − ca` is clean (≠ 0), so the normal + // must be ±Y. The E7 coord_scale threshold classed them coincident at this + // magnitude and returned +X (RED at f32). + const s = sphereShape(1); + const base = vr(1e6, 1e6, 1e6); + inline for (.{ true, false }) |ab| { + const m = if (ab) + ordered(s, base, Quatr.identity, s, base.add(vr(0, 1.2, 0)), Quatr.identity).? + else + ordered(s, base.add(vr(0, 1.2, 0)), Quatr.identity, s, base, Quatr.identity).?; + const n = m.normal.toArray(); + try testing.expectEqual(@as(u8, 1), m.count); + try testing.expectApproxEqAbs(@as(Real, 0), n[0], diff_tol); // ⊥ X + try testing.expectApproxEqAbs(@as(Real, 0), n[2], diff_tol); // ⊥ Z + try testing.expectApproxEqAbs(@as(Real, 1), @abs(n[1]), diff_tol); // along ±Y + } +} + +test "anisotropic box face-face keeps four points (P2 class B)" { + // Codex round-4 probe: the dedup tolerance must be PER-AXIS, not an isotropic + // scalar. Two he=(1.1e6,1,1) boxes stacked 0.5 in Y: the 4 contact corners + // differ by 2.2e6 in X and only 2 in Z. An isotropic eps (driven by X → ~2.1) + // merged the Z-separated pair → count 2 (RED); per-axis keeps all 4. + const box = boxShape(1_100_000, 1, 1); + const ab = ordered(box, vr(0, 0, 0), Quatr.identity, box, vr(0, 1.5, 0), Quatr.identity) orelse return error.NoContactAB; + try testing.expectEqual(@as(u8, 4), ab.count); + const ba = ordered(box, vr(0, 1.5, 0), Quatr.identity, box, vr(0, 0, 0), Quatr.identity) orelse return error.NoContactBA; + try testing.expectEqual(@as(u8, 4), ba.count); +} + // --- E5: consolidated feature_id producer × pair × A/B-order matrix --- const fid_class_mask: u32 = 0xc000; From eef16c07b51b4f82123500b4af379708a24c2507 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Mon, 20 Jul 2026 23:25:08 +0200 Subject: [PATCH 21/24] docs(forge): log M1.1.4 E8 threshold-class contract --- briefs/M1.1.4-narrowphase-fast-paths.md | 41 +++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/briefs/M1.1.4-narrowphase-fast-paths.md b/briefs/M1.1.4-narrowphase-fast-paths.md index b940940..9b42895 100644 --- a/briefs/M1.1.4-narrowphase-fast-paths.md +++ b/briefs/M1.1.4-narrowphase-fast-paths.md @@ -403,6 +403,47 @@ stay constant. Both P1 fixed RED-first. No `gjk.zig`/`epa.zig` touched. -Dphysics_f64=true` green debug + ReleaseSafe. Bench GO (ratios sphere/sphere 0.57, sphere/box 0.28, box/box 0.27, capsule/capsule 0.12). +### E8 — threshold-class invariance CONTRACT (E7 fix introduced 2 defects) (2026-07-20) + +Codex round 4 (verified numerically): the E7 fix itself introduced 2 defects — my +coord_scale-relative coincidence guard (P1) and my ratified per-set scalar dedup +(P2). Root cause: conflating a NORMALIZATION guard with a geometric proximity. +Closed DEFINITIVELY by a three-class contract; RED-first for both Codex probes +(stashed the fixes → both probes RED on the E7 code → restored → green). + +- **Contract (applied, not to be broken again):** **A. Normalization guard** + (avoid 0/0): fires ONLY at true zero — `dist² ≤ floatMin(T)` (the type's + underflow floor, never a geometric scale). `normalize` is scale-equivariant, so + there is nothing else to guard; translation- and scale-invariant by + construction. **B. Pair coincidence (dedup):** relative PER-AXIS to that axis's + own coordinate scale — never an isotropic scalar (anisotropy-safe). **C. + Separation margin:** `k·floatEps·coordScale` relative/A-frame — already correct + (`contactMargin`, `keep_eps`), untouched. +- **P1 (class A — 4 sites).** Replaced the E7 `coincidenceEpsSq` / `noise_scale²` + with a true-zero guard `dist_sq <= std.math.floatMin(T)` and DELETED + `coincidenceEpsSq`. Sites: `sphereSphere`, `sphereBox` n_local, `capsuleCapsule` + (fast_paths), and `collideOrderedGeneric`'s shallow seam (manifold.zig). Repro + `unit spheres far from the origin keep the center normal`: unit spheres at + (1e6,1e6,1e6), 1.2 apart in Y → normal ±Y both orders (E7 returned +X at f32 + because coord_scale ≈ 3.5e6 inflated the guard past `dist² = 1.4`). E6/E7 repros + stay green (collinear dist==0 → radial fallback; r=3e-7 spheres → dist²≠0 → +Y). +- **P2 (class B — `reduceToFour`).** Replaced the E7 scalar `16·floatEps·max|pos|` + with a PER-AXIS tolerance `eps[k] = 16·floatEps·max|pos[k]|`; two points coincide + ⟺ `|Δ[k]| ≤ eps[k]` for ALL k. Repro `anisotropic box face-face keeps four + points`: two `he=(1.1e6,1,1)` boxes stacked 0.5 in Y → count 4 (E7 scalar eps + ≈ 2.1, driven by X=1.1e6, merged the Z-separated (Δ=2) pair → count 2). + `keep_eps` untouched (already class C, translation-invariant via `|v−ref_pt|`). +- **Class-guard test.** `fast paths are scale and translation invariant` extended + to the full matrix {×1e-6, ×1, ×1e6} × {translation 0, +1e6} × {both orders}, + with a resolvability skip for combos where the geometry is below the f32 + resolution at the absolute magnitude (genuinely unrepresentable). The two Codex + probes are explicit RED-first tests. This matrix is the guard that was missing + every round. +- **Validation.** `zig fmt --check` / `zig build lint` / `zig build` clean; full + `zig build test` green debug + ReleaseSafe (f32); `zig build test-forge-3d + -Dphysics_f64=true` green debug + ReleaseSafe. Bench GO (ratios sphere/sphere + 0.56, sphere/box 0.27, box/box 0.27, capsule/capsule 0.12). + ## Recorded deviations - **E3 — box/box differential re-scoped + generic EPA frame-dependence From b4a506ca638a0cb55141373e49922d9bac7fa0f0 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Mon, 20 Jul 2026 23:57:11 +0200 Subject: [PATCH 22/24] fix(forge): dedup tolerance relative to candidate extent --- bench/results/forge_narrowphase.md | 8 +-- .../pipeline/narrowphase/manifold.zig | 25 +++++---- .../forge/forge_3d/tests/fast_paths_test.zig | 54 +++++++++++++------ 3 files changed, 58 insertions(+), 29 deletions(-) diff --git a/bench/results/forge_narrowphase.md b/bench/results/forge_narrowphase.md index ff93897..fe3fadb 100644 --- a/bench/results/forge_narrowphase.md +++ b/bench/results/forge_narrowphase.md @@ -6,10 +6,10 @@ | pair | dispatched (ns/pair) | generic (ns/pair) | ratio (disp/gen) | |---|---|---|---| -| sphere/sphere | 37.59 | 66.78 | 0.563 | -| sphere/box | 55.87 | 206.54 | 0.270 | -| box/box | 197.41 | 732.42 | 0.270 | -| capsule/capsule | 15.05 | 125.98 | 0.119 | +| sphere/sphere | 37.41 | 67.75 | 0.552 | +| sphere/box | 55.75 | 205.74 | 0.271 | +| box/box | 196.46 | 717.90 | 0.274 | +| capsule/capsule | 14.52 | 125.97 | 0.115 | **Verdict:** GO — the dispatched fast path must be strictly faster than the generic GJK/EPA oracle on every pair (ratio < 1). Absolute ns are only diff --git a/src/modules/forge/forge_3d/pipeline/narrowphase/manifold.zig b/src/modules/forge/forge_3d/pipeline/narrowphase/manifold.zig index 4988421..60e808a 100644 --- a/src/modules/forge/forge_3d/pipeline/narrowphase/manifold.zig +++ b/src/modules/forge/forge_3d/pipeline/narrowphase/manifold.zig @@ -676,20 +676,25 @@ fn faceCentroid(comptime T: type, face: support.Face(T)) math.Vec(3, T) { /// points are deduplicated first. Deterministic tie-breaks (strict `>`; first /// index wins). Writes the chosen `pts` indices into `idx`, returns the count. fn reduceToFour(comptime T: type, pts: []const Candidate(T), normal: math.Vec(3, T), idx: *[4]usize) usize { - // Coincidence tolerance for the dedup: PER-AXIS relative to that axis's own - // A-frame coordinate scale (`eps[k] = 16·floatEps·max|pos[k]|`), NEVER an - // isotropic scalar (E8, class B). An isotropic eps driven by a large axis - // (e.g. a `he = (1.1e6, 1, 1)` box) would swamp a small axis and merge points - // genuinely separated along it. Two points coincide ⟺ `|Δ[k]| ≤ eps[k]` for - // ALL k — anisotropic-safe and coordinate-covariant. A-frame positions stay - // small far from the world origin, preserving the M1.1.3 reduction. - var coord_scale = [3]T{ 0, 0, 0 }; + // Coincidence tolerance for the dedup: PER-AXIS relative to the candidates' + // own EXTENT on that axis (`eps[k] = 16·floatEps·(max−min) of pos[k]`), NEVER + // `max|pos[k]|` and NEVER an isotropic scalar (E9, class B). The extent is the + // only form invariant under all three: TRANSLATION (the contact-zone position + // in A's frame cancels in `max − min` — a small off-centre feature on a large + // body keeps a tiny eps), SCALE (covariant), and ANISOTROPY (per-axis). Two + // points coincide ⟺ `|Δ[k]| ≤ eps[k]` for ALL k; even if a distant duplicate + // is not merged, the count stays protected by the area-based reduction below. + var pmin = [3]T{ std.math.floatMax(T), std.math.floatMax(T), std.math.floatMax(T) }; + var pmax = [3]T{ -std.math.floatMax(T), -std.math.floatMax(T), -std.math.floatMax(T) }; for (pts) |c| { const p = c.pos.toArray(); - inline for (0..3) |k| coord_scale[k] = @max(coord_scale[k], @abs(p[k])); + inline for (0..3) |k| { + pmin[k] = @min(pmin[k], p[k]); + pmax[k] = @max(pmax[k], p[k]); + } } var eps: [3]T = undefined; - inline for (0..3) |k| eps[k] = 16 * std.math.floatEps(T) * coord_scale[k]; + inline for (0..3) |k| eps[k] = 16 * std.math.floatEps(T) * (pmax[k] - pmin[k]); // Deduplicate coincident contacts (indices into `pts`). var uniq: [max_clip]usize = undefined; var un: usize = 0; diff --git a/src/modules/forge/forge_3d/tests/fast_paths_test.zig b/src/modules/forge/forge_3d/tests/fast_paths_test.zig index 556e641..0b3820b 100644 --- a/src/modules/forge/forge_3d/tests/fast_paths_test.zig +++ b/src/modules/forge/forge_3d/tests/fast_paths_test.zig @@ -650,13 +650,15 @@ fn scaleShape(sh: SupportShape, s: Real) SupportShape { /// penetration). Both sides are compared at unit scale, so the tolerance stays /// meaningful; it widens with the untranslate/unscale amplification of the f32 /// rounding at the absolute coordinate magnitude. -fn manifoldsEquivariant(base: ContactManifold, m: ContactManifold, s: Real, t: Vec3r) bool { +fn manifoldsEquivariant(base: ContactManifold, m: ContactManifold, s: Real, t: Vec3r, ext: Real) bool { if (base.count != m.count) return false; if (!base.normal.approxEql(m.normal, diff_tol)) return false; // normal is scale/translation-INVARIANT const inv = 1.0 / s; const t_mag = t.length(); const eps = std.math.floatEps(Real); - const pos_tol = diff_tol + 64 * eps * (t_mag + @abs(s) * 2.5) * inv; + // Untranslate/unscale amplifies the f32 rounding at the absolute magnitude + // (~floatEps·(s·ext + t_mag)) back to unit scale → floatEps·(ext + t_mag/s). + const pos_tol = diff_tol + 64 * eps * (ext + t_mag * inv); for (0..m.count) |i| { var matched = false; for (0..base.count) |j| { @@ -676,14 +678,19 @@ test "fast paths are scale and translation invariant" { // classes broke this: an absolute floor (A) at extreme scale, a coord_scale // guard (A) at large translation, an isotropic dedup (B) for anisotropy. const zrot = Quatr.fromAxisAngle(Vec3r.unit_z, std.math.pi / 2.0); - const Cfg = struct { a: SupportShape, pa: Vec3r, ra: Quatr, b: SupportShape, pb: Vec3r, rb: Quatr }; + // `mf` = smallest half-extent/radius (the feature that must stay above the f32 + // resolution); `ext` = largest coordinate magnitude (positions + sizes). + const Cfg = struct { a: SupportShape, pa: Vec3r, ra: Quatr, b: SupportShape, pb: Vec3r, rb: Quatr, mf: Real, ext: Real }; const cfgs = [_]Cfg{ - .{ .a = sphereShape(1), .pa = vr(0, 0, 0), .ra = Quatr.identity, .b = sphereShape(1), .pb = vr(1.2, 0, 0), .rb = Quatr.identity }, // sphere/sphere - .{ .a = sphereShape(0.5), .pa = vr(0, 0, 0), .ra = Quatr.identity, .b = boxShape(1, 1, 1), .pb = vr(0, 0, 1.3), .rb = Quatr.identity }, // sphere/box shallow - .{ .a = sphereShape(0.5), .pa = vr(0, 0, 0), .ra = Quatr.identity, .b = boxShape(1, 1, 1), .pb = vr(0, 0.3, 0), .rb = Quatr.identity }, // sphere/box deep - .{ .a = capsuleShape(1, 0.3), .pa = vr(0, 0, 0), .ra = Quatr.identity, .b = capsuleShape(1, 0.3), .pb = vr(0, 0, 0.5), .rb = zrot }, // capsule crossed - .{ .a = capsuleShape(1, 0.3), .pa = vr(0, 0, 0), .ra = Quatr.identity, .b = capsuleShape(1, 0.3), .pb = vr(0.5, 0, 0), .rb = Quatr.identity }, // capsule parallel - .{ .a = boxShape(1, 1, 1), .pa = vr(0, 0, 0), .ra = Quatr.identity, .b = boxShape(1, 1, 1), .pb = vr(0, 1.5, 0), .rb = Quatr.identity }, // box/box face + .{ .a = sphereShape(1), .pa = vr(0, 0, 0), .ra = Quatr.identity, .b = sphereShape(1), .pb = vr(1.2, 0, 0), .rb = Quatr.identity, .mf = 1, .ext = 2.2 }, // sphere/sphere + .{ .a = sphereShape(0.5), .pa = vr(0, 0, 0), .ra = Quatr.identity, .b = boxShape(1, 1, 1), .pb = vr(0, 0, 1.3), .rb = Quatr.identity, .mf = 0.5, .ext = 2.3 }, // sphere/box shallow + .{ .a = sphereShape(0.5), .pa = vr(0, 0, 0), .ra = Quatr.identity, .b = boxShape(1, 1, 1), .pb = vr(0, 0.3, 0), .rb = Quatr.identity, .mf = 0.5, .ext = 1.5 }, // sphere/box deep + .{ .a = capsuleShape(1, 0.3), .pa = vr(0, 0, 0), .ra = Quatr.identity, .b = capsuleShape(1, 0.3), .pb = vr(0, 0, 0.5), .rb = zrot, .mf = 0.3, .ext = 1.8 }, // capsule crossed + .{ .a = capsuleShape(1, 0.3), .pa = vr(0, 0, 0), .ra = Quatr.identity, .b = capsuleShape(1, 0.3), .pb = vr(0.5, 0, 0), .rb = Quatr.identity, .mf = 0.3, .ext = 1.5 }, // capsule parallel + .{ .a = boxShape(1, 1, 1), .pa = vr(0, 0, 0), .ra = Quatr.identity, .b = boxShape(1, 1, 1), .pb = vr(0, 1.5, 0), .rb = Quatr.identity, .mf = 1, .ext = 2.5 }, // box/box face + // Off-centre small feature on a large body (Codex round-5 P2): the dedup + // extent (0.1), not the absolute position (55000), must set the eps. + .{ .a = boxShape(100000, 1, 100000), .pa = vr(0, 0, 0), .ra = Quatr.identity, .b = boxShape(0.05, 1, 0.05), .pb = vr(55000, 1.5, 0), .rb = Quatr.identity, .mf = 0.05, .ext = 100000 }, }; const scales = [_]Real{ 1e-6, 1, 1e6 }; const translations = [_]Real{ 0, 1e6 }; @@ -693,17 +700,19 @@ test "fast paths are scale and translation invariant" { const base_ba = ordered(c.b, c.pb, c.rb, c.a, c.pa, c.ra).?; for (scales) |s| { for (translations) |tm| { - // Skip combos where the geometry (feature scale `s`) is lost under - // the f32 resolution at the absolute magnitude (`|t| + s·extent`) — - // genuinely unrepresentable, not a defect. - if (@abs(s) <= 256 * eps * (tm + @abs(s) * 2.5)) continue; + // Skip a combo only when the geometric FEATURE (`s·mf`) drops below + // the f32 resolution at the absolute magnitude (`~floatEps·(|t| + + // s·ext)`) — genuinely unrepresentable, not a defect (the documented + // precision boundary). Comparing feature-size to resolution (not `s` + // alone) keeps every s=1 / t=1e6 cell running. + if (@abs(s) * c.mf <= eps * (tm + @abs(s) * c.ext)) continue; const t = vr(tm, tm, tm); const sa = scaleShape(c.a, s); const sb = scaleShape(c.b, s); const m_ab = ordered(sa, c.pa.scale(s).add(t), c.ra, sb, c.pb.scale(s).add(t), c.rb).?; - try testing.expect(manifoldsEquivariant(base_ab, m_ab, s, t)); + try testing.expect(manifoldsEquivariant(base_ab, m_ab, s, t, c.ext)); const m_ba = ordered(sb, c.pb.scale(s).add(t), c.rb, sa, c.pa.scale(s).add(t), c.ra).?; - try testing.expect(manifoldsEquivariant(base_ba, m_ba, s, t)); + try testing.expect(manifoldsEquivariant(base_ba, m_ba, s, t, c.ext)); } } } @@ -742,6 +751,21 @@ test "anisotropic box face-face keeps four points (P2 class B)" { try testing.expectEqual(@as(u8, 4), ba.count); } +test "off-center small feature on a large body keeps four points (P2 class B, E9)" { + // Codex round-5 probe: the dedup eps must be relative to the candidates' local + // EXTENT, never `max|pos|`. A small box (he = 0.05×1×0.05) resting on a large + // ground face (he = 100000×1×100000) at x = 55000: its 4 bottom corners span + // only 0.1 in X and Z. The E8 `max|pos| ≈ 55000` inflated eps_x to ~0.1 and + // merged them in the ground→box order (count 2, order-dependent); the extent + // (0.1) keeps eps ~ 1.9e-7, so all 4 survive in BOTH orders. + const ground = boxShape(100000, 1, 100000); + const small = boxShape(0.05, 1, 0.05); + const gb = ordered(ground, vr(0, 0, 0), Quatr.identity, small, vr(55000, 1.5, 0), Quatr.identity) orelse return error.NoContactGB; + try testing.expectEqual(@as(u8, 4), gb.count); + const bg = ordered(small, vr(55000, 1.5, 0), Quatr.identity, ground, vr(0, 0, 0), Quatr.identity) orelse return error.NoContactBG; + try testing.expectEqual(@as(u8, 4), bg.count); +} + // --- E5: consolidated feature_id producer × pair × A/B-order matrix --- const fid_class_mask: u32 = 0xc000; From 7ec9e47e089c8619ed9369c7a81ec8003b04ef09 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Mon, 20 Jul 2026 23:57:12 +0200 Subject: [PATCH 23/24] docs(forge): log M1.1.4 E9 extent-based dedup --- briefs/M1.1.4-narrowphase-fast-paths.md | 35 +++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/briefs/M1.1.4-narrowphase-fast-paths.md b/briefs/M1.1.4-narrowphase-fast-paths.md index 9b42895..445e800 100644 --- a/briefs/M1.1.4-narrowphase-fast-paths.md +++ b/briefs/M1.1.4-narrowphase-fast-paths.md @@ -444,6 +444,41 @@ Closed DEFINITIVELY by a three-class contract; RED-first for both Codex probes -Dphysics_f64=true` green debug + ReleaseSafe. Bench GO (ratios sphere/sphere 0.56, sphere/box 0.27, box/box 0.27, capsule/capsule 0.12). +### E9 — dedup extent (E8 fix was position-dependent) (2026-07-20) + +Codex round 5 (verified numerically): my E8 `reduceToFour` eps `16·floatEps·max|pos[k]|` +is POSITION-dependent — a small off-centre feature on a large body gets a +position-inflated eps and its corners merge in the order where that body is A. + +- **Fix (`reduceToFour`, class B — the RULE).** eps per axis is now relative to + the candidates' own EXTENT: `eps[k] = 16·floatEps·(max − min of pos[k])`. The + extent is the ONLY form invariant under all three at once — TRANSLATION (the + contact-zone position in A's frame cancels in `max − min`), SCALE (covariant), + ANISOTROPY (per-axis). Coincidence unchanged (`|Δ[k]| ≤ eps[k]` for all k). + Rule recorded (never break again): a dedup/coincidence tolerance is relative to + the candidate EXTENT, never `max|pos|` nor a constant. +- **Repro (RED-first, stash/pop).** `off-center small feature on a large body + keeps four points`: a `he=(0.05,1,0.05)` box on a `he=(100000,1,100000)` ground + at x=55000 → count 4 in BOTH orders (E8: 2 in ground→box because `max|pos|≈55000` + inflated eps_x to ~0.1 and merged the 0.1-apart corners; 4 in box→ground). Added + as a matrix config too. +- **Matrix skip tightened.** The E7/E8 skip `s ≤ 256·eps·(…)` wrongly excluded the + representable s=1 / t=1e6 cell. Now it compares FEATURE size to resolution — + skip iff `s·mf ≤ floatEps·(|t| + s·ext)` (`mf` = smallest radius/half-extent, + `ext` = largest coordinate) — so every s=1 / t=1e6 cell runs for all configs, + and only genuinely unresolvable cells (feature below the f32 noise at that + magnitude) are skipped. +- **Out of scope (documented precision boundary, NOT a defect).** Contact-point + POSITION precision far from body A's centre is f32-limited (noise ~floatEps·|pos|); + recentring the clip on the contact centroid is an M1.1.3-generator concern, not + M1.1.4. E9 repairs the CONTRACT (order-independence + count via extent); a + feature smaller than the f32 noise at its position has an intrinsically ambiguous + count — that is the boundary (the matrix skip excludes it), not a defect. +- **Validation.** `zig fmt --check` / `zig build lint` / `zig build` clean; full + `zig build test` green debug + ReleaseSafe (f32); `zig build test-forge-3d + -Dphysics_f64=true` green debug + ReleaseSafe. Bench GO (ratios sphere/sphere + 0.55, sphere/box 0.27, box/box 0.27, capsule/capsule 0.12). + ## Recorded deviations - **E3 — box/box differential re-scoped + generic EPA frame-dependence From 71da3c333493436bd4478363c613f44b1b76b63f Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 21 Jul 2026 00:09:58 +0200 Subject: [PATCH 24/24] docs(brief): close M1.1.4 --- CLAUDE.md | 13 +++--- briefs/M1.1.4-narrowphase-fast-paths.md | 56 +++++++++++++++++++++---- 2 files changed, 57 insertions(+), 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 678d329..98cbd6a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,10 +10,10 @@ knowledge base — see § Quick links spec. | Field | Value | |---|---| | Phase | 1 (Etch ↔ ECS) | -| Current milestone | M1.1.3 — Forge 3D narrowphase: EPA + contact manifold — code-complete, PR #56 open | -| Last released tag | `v0.11.3-narrowphase-epa-manifold` | -| Active branch | `phase-1/forge/narrowphase-epa-manifold` (PR #56 open, not merged) | -| Next planned milestone | M1.1.4 — Forge 3D narrowphase fast paths (analytic sphere/sphere, sphere/box, capsule/capsule, box/box; each reproduces the generic GJK/EPA manifold, faster) — fifth core sub-milestone of the M1.1 rigid arc (M1.1.0–15, `PhysicsModule` freeze at M1.1.15). M1.1.0 (foundations), M1.1.1 (broadphase multi-layer BVH), M1.1.2 (narrowphase GJK), and M1.1.3 (narrowphase EPA + contact manifold; `narrowphase.zig` promoted to the `pipeline/narrowphase/` package) are CLOSED. The M1.0 core-language series (M1.0.0–M1.0.18) is CLOSED — C1.6 tag `v0.10.18-extension-additive-warning`. Reserved (Tier-1-dependent, NOT core gaps): `future`, `override`, `quantize`. | +| Current milestone | M1.1.4 — Forge 3D narrowphase fast paths — code-complete, PR #57 open | +| Last released tag | `v0.11.4-narrowphase-fast-paths` | +| Active branch | `phase-1/forge/narrowphase-fast-paths` (PR #57 open, not merged) | +| Next planned milestone | M1.1.5 — Integration: semi-implicit Euler + gravity + damping — sixth core sub-milestone of the M1.1 rigid arc (M1.1.0–15, `PhysicsModule` freeze at M1.1.15). M1.1.0 (foundations), M1.1.1 (broadphase BVH), M1.1.2 (narrowphase GJK), M1.1.3 (narrowphase EPA + manifold), and M1.1.4 (narrowphase analytic fast paths ss/sb/cc/bb) are CLOSED. The M1.0 core-language series (M1.0.0–M1.0.18) is CLOSED — C1.6 tag `v0.10.18-extension-additive-warning`. Reserved (Tier-1-dependent, NOT core gaps): `future`, `override`, `quantize`. | ## Tags @@ -60,6 +60,7 @@ knowledge base — see § Quick links spec. | `v0.11.1-broadphase-bvh` | 2026-07-13 | M1.1.1 — Forge 3D broadphase: dynamic multi-layer BVH | Second M1.1 sub-milestone. **E0 directory flatten**: `solvers_3d/forge_3d/` → `forge_3d/` (7 pure `git mv` renames + 2 `build.zig` refs); the abandoned multi-solver wrapper is gone (`engine-directory-structure.md`/`engine-tier-interfaces.md` patched to the flat layout). `Aabb(T).surfaceArea()` added to `foundation/math` (SAH metric). `forge_3d/pipeline/broadphase.zig` (imports `foundation` math ONLY; scalar as comptime `T`, `user_data` an opaque `u32`): generic `Bvh(T)` — incremental dynamic AABB tree in the Box2D `b2DynamicTree` tradition (index-based `std.ArrayListUnmanaged(Node)` pool + LIFO free-list, no generation packing; fat AABBs from `BroadphaseConfig{margin=0.1}`; SAH best-cost-child descent; AVL-like rotation balancing; `validate()` invariant checker), `insert(gpa, aabb, ud) !u32` / `remove` / `update` (hysteresis, allocation-free) / `queryAabb(query, collector) u32` (visited-node counter). Multi-layer aggregate: `BroadphaseLayer` (static/dynamic/debris/trigger, `engine-physics-forge.md §1.2`), `default_layer_pairs` (4×4 symmetric `const` matrix; allowed = dyn×{dyn,static,debris,trigger} + debris×static), `Broadphase(T)` owning one `Bvh(T)` per layer + per-layer moved-log, `computePairs(gpa, out)` — moved-driven, self-excluded by `user_data`, sorted by packed `u64 (a<<32)|b` + adjacent-deduped, **zero hash containers** (determinism by construction, anticipates M1.1.14). M1.1.0 gap fixed: `Body.collision_layer: u8` stored by `BodyManager.addBody` + `collisionLayer(id) ?u8` getter (nothing consumes the layer yet — object-layer filtering is later). `forge_3d/root.zig` re-exports the pipeline at `Real`. Full suite green at f32 AND `-Dphysics_f64=true` (local). Deferred (not gaps): raycast/shapecast on the tree (M1.1.9–10), velocity-based AABB prediction (M1.1.5), object-layer `CollisionConfig` matrix wiring, trigger/debris `BodyDescriptor` assignment (M1.1.13). | | `v0.11.2-narrowphase-gjk` | 2026-07-18 | M1.1.2 — Forge 3D narrowphase: GJK convex intersection | Third M1.1 sub-milestone. `pipeline/narrowphase.zig` (imports `foundation` math ONLY; scalar comptime `T`, instantiated at `config.Real`): distance-based GJK on convex **cores** + inflation radius (Jolt convex-radius architecture). `SupportShape(T)` — `Core` union (point / segment:half_height / box:half_extents) + `radius` — with fixed-tie-break `support` (`dir.y == 0` → +Y endpoint; box component `== 0` → +half_extent). `RelativePose(T)` — frame-of-A precompute `rot_rel = conj(rot_a)·rot_b`, `pos_rel = conj(rot_a)·(pos_b − pos_a)` (conjugate, never `inverse()`) + `supportB`. `Simplex(T)` — triplet `Vertex (w, support_a, support_b)` + Ericson RTCD §5.1 Voronoi closest-origin solver over point/segment/triangle/tetrahedron (fixed region order; every division guarded → NaN-safe on duplicated/collinear/coplanar degeneracies). `gjk(T, shape_a, pose_a, shape_b, pose_b) GjkResult(T)` — bounded descent (`max_gjk_iterations = 32`, named relative progress tolerance, NEVER normalizes the search direction — squared distances, one `sqrt`), three regimes, classification hardened over six post-delivery correction cycles — NO classification decision depends on a geometric quantity (radius, shape size, `w`-vertex magnitude); every threshold is `k·floatEps(T)·coordScale` (coordScale = absolute support magnitude), absorbing only float noise: `separated` (`dist − r_sum` beyond `conv_k·floatEps·coordScale`, an ABSOLUTE additive margin, not `r_sum`-proportional — P1; `conv_k=16` bounds the pipeline's ACCUMULATED rounding so exact tangencies stay `.shallow` — P1b; `coord_scale = |Δpos| + coreExtent_a + coreExtent_b` is SYMMETRIC, so `.deep`/`.shallow`/`.separated` is invariant under an A/B swap — P1c) / `shallow` (else, cores disjoint; exact touch counts) both carry core distance + world closest points; `deep` (geometric ENCLOSURE — a non-degenerate tetra `count==4`, NO distance threshold on that path; plus a machine-epsilon numerical-NOISE floor `mach_eps=noise_k·floatEps` for degenerate coincident/collinear Minkowski configs scaled by the absolute support magnitude — P2, and an anti-cycling duplicate-support guard) carries the terminal simplex (EPA seed M1.1.3), no depth/normal. forge_3d integration: `shape.supportShape`, `BodyManager.rotation(id)` getter + `gjkPair(store,a,b)` (broadphase→narrowphase adapter, null on stale). Broadphase fat-AABB false positives classified `.separated`. `narrowphase.zig` 726 lines (brief-mandated single-file GJK stack, cf. `broadphase.zig` 686). Full suite green at f32 AND `-Dphysics_f64=true` (local). Out (later): EPA/manifold/depth/normal (M1.1.3), fast paths (M1.1.4). | | `v0.11.3-narrowphase-epa-manifold` | 2026-07-19 | M1.1.3 — Forge 3D narrowphase: EPA + contact manifold | Fourth M1.1 sub-milestone. narrowphase.zig → `pipeline/narrowphase/` package (pure relocation). EPA (`epa.zig`) on the cores in A's frame from the `.deep` simplex → `EpaResult{normal world A→B, core depth, closest_a/b}`. Manifold (`manifold.zig`): `collide()`/`collideOrdered()` → one generator both regimes (shallow from GJK closest points, deep from EPA); face-face by A-frame supporting-face clipping, segment by clipSegment, edge/vertex/point-core by a single witness contact along the EPA axis; per-point `penetration = r_sum − s`. `feature_id`: five class-tagged producers in disjoint ranges, each encoding its real sub-feature — kept-vertex (face,vertex), edge×plane (side-plane,edge), reference corner (ref-vertex,incident-face), clipSegment (face,capsule-endpoint), point-core/single-contact (real vertex-or-face,real vertex-or-face) — unique per simultaneous contact (131,751-manifold sweep + face_face_min + capsule-pair sweeps) and frame-stable via `collidePair`'s BodyId order; bare `collide` is pose-canonical (no inter-frame guarantee). `ContactManifold`/`ContactPoint` FROZEN. Order-independence via `collidePair` BodyId order + normal negation (RD-4 exception: bit-identical coincident shapes). Integration: `BodyManager.collidePair`, `Real` re-exports. feature_id contract hardened across an extended Codex review cycle (FIX-1..12). Note: P1d extreme-aspect box `.deep` = GJK classification limit upstream of EPA → M1.1.4. Fast paths M1.1.4. | +| `v0.11.4-narrowphase-fast-paths` | 2026-07-20 | M1.1.4 — Forge 3D narrowphase fast paths | Fifth M1.1 sub-milestone. Four analytic per-pair fast paths dispatched from `collideOrdered` before GJK — sphere/sphere, sphere/box, capsule/capsule, box/box (radius-0 box only; rounded box → generic). Each kernel computes only a `ContactSeed {normal A→B, core closest points, base_penetration}` fed to the UNCHANGED `generateManifold`, so feature_ids/clipping/≤4-reduction/order-independence/frame-stability are inherited by construction; the fast path replaces only the GJK descent + EPA expansion. `fastSeed` 3-state; `separated` mirrors gjk.zig (conv_k=16, coord_scale=|Δpos|+coreExtent). sphere/box deep = closed-form least-penetration face (point/box P1d fix, >200:1). box/box = 15-axis SAT, order-independent; separation on every non-zero raw edge×edge axis; edge-edge witness via full-degenerate Ericson closestSegSeg; fixes box/box P1d and bypasses the M1.1.3 deep-rotated EPA frame-dependence for box/box. capsule/capsule = closestSegSeg → 3 regimes via generateManifold (crossed via E1 fix; collinear/parallel radial, never axial, fallback normal). E1 fix: segment×segment non-parallel → single witness. Hardened through a 5-round external review to a threshold-class discipline, all RED-first in-milestone: normalization guards fire ONLY at true zero (`floatMin`, never a geometric scale); point-set dedup is relative to the candidates' PER-AXIS EXTENT (translation+scale+anisotropy invariant); separation margins are `k·floatEps·coordScale`. Defects closed: degenerate segment closest-points, SAT separating-axis completeness, collinear-capsule normal, and the length-threshold class (fast_paths + shared manifold.zig generator). Validation: differential vs `collideOrderedGeneric` (gated on oracle self-consistency), closed-form P1d/extreme-aspect, oracle-free deep+rotated box/box, feature_id producer×pair×order matrix, scale×translation×anisotropy×decentre×order invariance matrix. Bench (ReleaseFast, dispatched/generic): 0.55 ss / 0.27 sb / 0.27 bb / 0.12 cc. Full suite green f32 + `-Dphysics_f64=true`, debug + ReleaseSafe. No gjk.zig/epa.zig touched. | ### Hotfixes (untagged) @@ -117,6 +118,8 @@ Hotfix milestones are merged to `main` without a tag (Guy decision, - **M1.1.2 scope boundary (narrowphase GJK convex detection)**: `pipeline/narrowphase.zig` imports `foundation` (math) ONLY — never `weld_forge`/`body*`/`config`/`broadphase`; the scalar is the comptime `T` (forge_3d instantiates at `config.Real` via `root.zig` re-exports). GJK runs on the convex **cores** (point/segment/box) + an inflation radius (Jolt convex-radius architecture); touch = `dist(cores) ≤ r_a + r_b`, `separated` iff the core distance exceeds `r_a + r_b` beyond an ABSOLUTE noise-scaled contact margin `conv_k·floatEps(T)·coordScale` (exact touch → `shallow`, face-inclusive convention). Distance-based (NOT boolean) GJK: the simplex stores `(w, support_a, support_b)` triplets so closest points on A/B reconstruct from the barycentrics; the Voronoi simplex solver is Ericson (not Johnson, not signed-volumes). Determinism by construction (anticipates M1.1.14): no hash containers, no trig (dot/cross only), `max_gjk_iterations = 32`, named relative progress tolerance, fixed support tie-breaks; the search direction is NEVER normalized (squared distances, one `sqrt`); computation frozen in the frame of A (`rot_rel`/`pos_rel` precompute, conjugate-rotate never `inverse()`) — changing the frame after M1.1.14 would break bit-exactness. `GjkResult.deep` carries the terminal origin-enclosing simplex as the EPA seed — no depth/normal/manifold here. The forge_3d-side adapters (`shape.supportShape`, `BodyManager.gjkPair`) own the `Shape → SupportShape` conversion + the `BodyId`-level entry, mirroring the broadphase `user_data` discipline. **Classification robustness (six post-delivery correction cycles, Guy-directed — RESOLVES the E3 absolute-`deep_eps`, the Fix 3 vertex-relative residual, the P1/P2 remaining geometric dependencies, the P1b accumulated-rounding under-dimensioning, the P1c A/B-order asymmetry, AND the P1d anisotropic-tetra false-degeneracy; the extreme-aspect radius-0 box `.deep` residual is DOCUMENTED and deferred to M1.1.4/M1.1.3):** GUIDING PRINCIPLE — no `.deep`/`.shallow`/`.separated` decision depends on a GEOMETRIC quantity (radius, shape size, `w`-vertex magnitude); `.deep` = geometric enclosure; every remaining threshold is `k·floatEps(T)·coordScale` (coordScale = absolute support magnitude, `@sqrt(maxSupportMagSq)`), absorbing only float rounding noise. (a) `.deep` = a non-degenerate tetra enclosing the origin (`count==4`, degeneracy tested DIMENSIONLESSLY `|bary_det|² ≤ deg_rel²·(|b−a|²·|c−a|²·|d−a|²)` = squared normalized volume, aspect-ratio-invariant — P1d replaced the `maxEdgeSq³` normalization that rejected valid anisotropic tetra and read a sharp box's interior `.separated`), NO distance threshold on that path; (a2, Fix 3b→P2) the sole distance early-out (`degenerateOriginReached`) is a numerical-NOISE floor `closest·closest ≤ mach_eps²·maxSupportMagSq` (scaled by the ABSOLUTE support magnitude), with `mach_eps = noise_k·floatEps(T)` (P2 recalibrated from the 8×-eps `1e-6` literal — which was a visible geometric threshold, ~8.7e-5 at half-extent 50 — down to `noise_k`≈2 ULPs); the earlier vertex-relative `rel_deep²·maxVertexMag²` (Fix 3) reproduced the bug for large shapes and was removed; (b) an anti-cycling duplicate-support guard kills degenerate Minkowski tetrahedra at the source; (c, Fix 4→P1) `.separated iff dist − r_sum > conv_k·floatEps(T)·coordScale` — an ABSOLUTE additive margin (P1 removed the `r_sum·(1+contact_rel)` form, whose `contact_rel·r_sum` slack — 0.1 m at r_sum 1000 — was a collision margin beyond the core radius, out of scope; it mis-classified two r=500 spheres 5 cm apart as `.shallow`), honoring the frozen touch=shallow rule via the convergence-noise margin; (c2, P1b) `conv_k` = 16, not 2 — 2 ULP under-dimensioned the ACCUMULATED pipeline rounding (quaternion + Voronoi tetra + sqrt), so an exact tangency (Codex case: `dist − r_sum` = 7.15e-7 ≈ 2.1 ULP relative, zero convergence residue) tipped `.separated`; 16 ULP bounds it and stays at the noise level (the deep noise floor keeps `noise_k = 2`, a distinct point-noise floor — aligning them recreates a P1b/P2 false positive). Scale-robust, verified at O(1), half-extents 50–500, and at the f32 noise floor (~9 orders tighter in f64 — an f32 fundamental limit, not a geometric margin); a measure-zero EXACT tangency can still, in rare pathological configs (~1/20000 in sweep), tip `.separated` by one ULP — irreducible float limit, corrected next frame by penetration, no absolute guarantee claimed. (c3, P1c) the contact margin's `coord_scale` is SYMMETRIC by construction — `|pos_b − pos_a| + coreExtent(a) + coreExtent(b)` (`coreExtent` = the core's max local support magnitude, radius excluded: point→0, segment→half_height, box→`|half_extents|`) — replacing the A-frame terminal-support magnitude `@sqrt(maxSupportMagSq)`, which was frame-dependent and made one tangency classify `.separated` in one A/B order and `.shallow` in the other (a structural defect, not calibration). `.deep`/`.shallow`/`.separated` is therefore invariant under an A/B swap by construction, asserted by `gjk classification is order-independent`; `maxSupportMagSq` stays (anti-cycling guard + deep noise floor — `w`-magnitude, a distinct quantity). **File-length note:** `narrowphase.zig` is 726 lines (over the 500 Review guideline) — the brief mandates the whole GJK stack (support shapes + simplex solver + loop + result) in this single file (mirror of `broadphase.zig` at 686); conscious brief-driven overage. **OUT (not gaps; additive/later):** EPA + penetration depth + contact normal + manifold (M1.1.3 — in particular do NOT compute `depth = r_a+r_b−dist` or a normal for `.shallow`); pair-type fast paths ss/sb/cc/bb (M1.1.4); speculative-contact thresholds / margins beyond the core radius / box convex radius (box radius 0 here); `collision_layer`/`CollisionConfig` matrix wiring; stepping/`PhysicsWorld`/`PhysicsModule` instantiation (M1.1.15); raycast/shapecast/point queries (M1.1.9–10); shapes beyond sphere/box/capsule (`SupportShape.Core` stays additively extensible); batched/SIMD pair processing; `forge_2d` (M1.8); robust `.deep` for radius-0 box cores of EXTREME aspect ratio (>~50:1) — a GJK-f32 limit on sharp cores (tetra degeneracy at extreme anisotropy + premature anti-cycling termination before the enclosure tetra forms), reliable to ~30:1, deferred to M1.1.4 analytic box fast paths / M1.1.3 EPA (P1d). - **Extension-conflict semantics (last-wins vs reject) — RESOLVED (M1.1.1-HF4)**: the dedicated post-HF3 spec session ratified **reject** as the normative, permanent policy for additive extension conflicts, superseding the former "last-extension-wins" prose. The invariant is **{base components} ∪ {active extensions' components} conflict-free**; three rejected forms: (a) two extensions declare the same component; (b) an extension declares a component already carried by the base (or an earlier extension); (c) the same extension is listed twice. Static conflict → **fatal cook error** `E1797 ExtensionAdditiveConflict` (all three forms, distinct static message; no output; `weld check` fails in CI), guaranteeing `cooked ⇒ loadable`; dynamic activation → runtime `error.ExtensionComponentConflict` (a/b) / `error.ExtensionAlreadyActive` (c), atomic (entity unchanged). Specs corrected (`engine-scene-serialization.md` additive-conflict note §695–706, `etch-reference-part2.md §30.4–30.5`, `etch-diagnostics.md §18.3` E1797 row + the W1791→E1797 reclassification note); code comments cite `engine-scene-serialization.md` (the authority), not the HF3 brief. Deactivation needs no provenance — the reject invariant means no two active declarants ever share a component. Cook detection: form (c) is a resolver-independent lexical pass (so the public `cook` null-resolver path rejects duplicates); forms (a)/(b) need the prefab resolver and are `validate.structure`-guarded before any accessor getter. Alternative examined and rejected in the session: last-wins-with-provenance (masking + shadowed-byte side table + out-of-order deactivate + `.sav` shadow-stack serialization — ≈5–10× the code, touches the ECS reserve-then-mutate invariant and the serialization format, undoes the HF3 deactivate simplification — to support a design error). `@exclusive_with` remains a future, unimplemented escape hatch. - **Scope boundary M1.1.3** — Deferred to M1.1.4: extreme-aspect (>~50:1) radius-0 box `.deep` classification (GJK-f32 limit upstream of EPA), and analytic fast paths superseding the generic clipper for common pairs. Deferred until the shapes ship (convex hull/compound/mesh, ~M1.1.19-20, co-designed with M1.1.6 warm-start): generic-clipper feature ids for non-fast-path convex shapes. In M1.1.3 the feature_id contract is fully met for box/sphere/capsule (all producers class-tagged, unique, frame-stable via collidePair). **Closed for M1.1.3 scope.** +- **M1.1.4 — generic EPA deep-rotated frame-dependence (M1.1.3 DEFECT, tracked).** `collideOrderedGeneric` (GJK/EPA in the frame of A) is order-dependent for a DEEP, ROTATED convex pair even at 1:1 aspect (sweep config: pen 1.047 forward, pen 0/count 1 reversed) — violates the frozen order-independence contract (`engine-physics-forge.md §303`), independent of the P1d aspect limit. The box/box SAT fast path is order-independent by construction and neutralizes the exposure FOR box/box; **residual: capsule/box + sphere/capsule stay on the generic EPA path** (demo pairs). Deferred to a dedicated hotfix `m1.1.3-hf-epa-frame-dependence` (out of M1.1.4 scope — gjk.zig/epa.zig untouched). Detected + recorded at M1.1.4 E3. +- **M1.1.4 scope boundary — fast paths.** Four frozen pairs only (ss/sb/cc/bb), radius-0 box. OUT (generic, correct): capsule/box, sphere/capsule (capsule/box a post-demo additive candidate after profiling). OUT (later): warm-start (M1.1.6), stepping/solver (M1.1.5+), Plane/Mesh (M1.1.11). `collideOrdered` count>1 point-set is fixed-order; full order-independence via `collide`/`collidePair` (frozen M1.1.3 contract, inherited). **Precision boundary (out of scope):** contact-point POSITION accuracy far from body A's centre is f32-limited (absolute noise ~floatEps·|pos|); order-independence + count are correct (extent-based dedup), but a feature below f32 resolution at its position has an inherently ambiguous count — a `generateManifold` precision characteristic (would need clip-recentring), noted for a future precision hotfix. **Closed for M1.1.4 scope.** ## Non-negotiable rules @@ -250,4 +253,4 @@ The `briefs/` directory is the source of truth for milestone state. The brief's --- -Last updated: 2026-07-19 +Last updated: 2026-07-20 diff --git a/briefs/M1.1.4-narrowphase-fast-paths.md b/briefs/M1.1.4-narrowphase-fast-paths.md index 445e800..b27139a 100644 --- a/briefs/M1.1.4-narrowphase-fast-paths.md +++ b/briefs/M1.1.4-narrowphase-fast-paths.md @@ -1,12 +1,12 @@ # M1.1.4 — Forge 3D narrowphase fast paths -> **Status:** PLANNED +> **Status:** CLOSED > **Phase:** 1 > **Branch:** `phase-1/forge/narrowphase-fast-paths` > **Planned tag:** `v0.11.4-narrowphase-fast-paths` > **Dependencies:** M1.1.3 (narrowphase EPA + contact manifold — `pipeline/narrowphase/` package, FROZEN `ContactManifold`/`ContactPoint`, class-tagged `feature_id` scheme) > **Opened:** 2026-07-20 -> **Closed:** — +> **Closed:** 2026-07-20 --- @@ -514,13 +514,55 @@ position-inflated eps and its corners merge in the order where that body is A. (`gjk.zig`/`epa.zig` are out of scope for M1.1.4).** - **Flag for review:** concrete evidence that the analytic box/box fast path is more ROBUST than the generic EPA, not only faster. +- **E7–E9 — length-threshold fix extended into the shared `manifold.zig` + generator (ratified).** Beyond the 5 fast_paths thresholds Codex listed, the + scale/translation/anisotropy/decentre invariance matrix exposed same-class + thresholds in the `generateManifold` path the fast paths traverse: + `reduceToFour`'s dedup was made per-axis EXTENT-relative (`max − min`), and + `collideOrderedGeneric`'s shallow-seam normalization guard was made a + true-zero (`floatMin`) guard; `keep_eps` was already relative + (`|v − ref_pt|`). Same threshold-class discipline (A true-zero / B extent + per-axis / C `k·floatEps·coordScale`). Ratified as in-scope: they are in the + generator the fast paths route through (or same file/class) and are required + for fast-path invariance — DISTINCT from the deferred generic-EPA defect + (separate file, deep fix, bypassed by the fast paths) and from the + far-from-centre position-accuracy boundary (documented, out of scope). A-frame + magnitudes stay small → the M1.1.3 far-from-origin reduction is preserved; the + full M1.1.3 suite (routed through both the fast and generic paths) stays green. ## Blockers encountered +None. Each of the five external-review rounds (E5→E9) relayed exact defects + +fixes; applied in-gate, RED-first for every functional defect, per the addendum. + ## Closing notes -- **What worked:** -- **What deviated from the original spec:** -- **What to flag explicitly in review:** -- **Final measurements** (per-pair dispatched/generic ratios, compile time, whatever is relevant): -- **Residual risks / tech debt left intentionally:** +- **What worked:** the seed architecture — every fast path feeds the UNCHANGED + `generateManifold`, so feature_ids / clipping / reduction / order-independence / + frame-stability were inherited for free; the fast path only supplies a + better/faster/robuster `(normal, witnesses, depth)`. RED-first (incl. stash/pop + for the later rounds) proved every functional defect before its fix. The + gated-oracle + oracle-free + invariance-matrix test stack ultimately pinned the + behaviour where the generic EPA and absolute thresholds could not. +- **What deviated from the original spec:** the brief's "au bit près" is + superseded by geometric equivalence within named tolerances (Claude.ai §303 + reformulation). The differential is gated on generic-oracle self-consistency + (the generic EPA is unreliable for deep-rotated pairs). The E7–E9 fix extended + into `manifold.zig` (only `segmentsParallel` was named off-limits there). All + recorded above. +- **What to flag explicitly in review:** (1) the generic-EPA deep-rotated + frame-dependence is a tracked M1.1.3 DEFECT (hotfix `m1.1.3-hf-epa-frame-dependence`), + with residual exposure on capsule/box + sphere/capsule; (2) the threshold-class + contract (A true-zero / B extent-per-axis / C k·floatEps·coordScale) is now the + standing rule; (3) the far-from-centre contact-POSITION accuracy is an f32 + precision boundary (generator concern), out of scope — count + order-independence + are correct. +- **Final measurements** (bench ReleaseFast, dispatched/generic ratio): sphere/sphere + 0.55, sphere/box 0.27, box/box 0.27, capsule/capsule 0.12 — dispatched strictly + faster on every pair (1.8×–8.3×). Full suite green at f32 and `-Dphysics_f64=true`, + debug + ReleaseSafe. +- **Residual risks / tech debt left intentionally:** the deferred EPA + frame-dependence hotfix (capsule/box + sphere/capsule exposure until then); the + far-from-centre position-accuracy precision boundary (future generator hotfix, + would need clip-recentring). Both are Open decisions in `CLAUDE.md`; neither + blocks the demo (arena pairs are dominated by the four fast paths).