Skip to content

Phase 1 / Forge / Shapes: infinite plane (half-space) - #65

Merged
guysenpai merged 19 commits into
mainfrom
phase-1/forge/plane-halfspace
Jul 30, 2026
Merged

Phase 1 / Forge / Shapes: infinite plane (half-space)#65
guysenpai merged 19 commits into
mainfrom
phase-1/forge/plane-halfspace

Conversation

@guysenpai

Copy link
Copy Markdown
Contributor

Milestone M1.1.11 — Forge 3D shapes: the infinite plane (half-space). Twelfth M1.1 sub-milestone. Brief: briefs/M1.1.11-plane-halfspace.md (Status CLOSED, code-complete).

The plan row that grouped Plane with MeshShape is SPLIT: the mesh half becomes M1.1.11.1, because it carries a rigid-solver change (several contact constraints per body pair), an internal-edge policy and ShapeStore owned memory, none of which a half-space needs.

Closing notes

What worked. The gate decomposition (E1 taxonomy → E2 static-only rejection → E3 the refusal moved to the query entries → E4 the analytic kernels → E5 the broadphase lists → E6 the contact path → E7 closure) held, each gate RED-first, and the taxonomy above the support map is what made the shape cheap rather than special: ShapeClass with two variants and no else arm anywhere turns M1.1.11.1 into a compile error at each site that owes a decision. Putting unbounded shapes outside the trees, and asking a half-space a PREDICATE rather than a box, avoided the reference’s tuning constant entirely. Four measurements settled questions that argument would have got wrong: the NaN centre of an infinite box, the undefined sleep radius reading as a plausible small number in ReleaseFast, the transported dot product landing at exactly one ULP from zero, and the bench delta sitting below the noise floor with an unstable sign.

What deviated from the original spec. Seven Recorded deviations, each with its written resolution — RD-1 overlapsHalfSpace in foundation/math, RD-2 the two result types relocated to support.zig so a class-dispatching adapter returns one type, RD-3 pairing direction (2) pruning rather than enumerating, RD-4 a sixth BodyManager adapter, RD-5 the harness, RD-6 the files outside the list, RD-7 broadphase.zig at 1224 lines. One contradiction inside the FROZEN SECTION is recorded and not corrected: Scope line 40 asks for an “insertion-ordered” list, a property the corrected §1.11.15 contract neither holds nor requires. Two files in the list were not touched and neither needed to be (query/cast.zig — the entries live in the query/root.zig façade; tests/manifold_test.zig — the plane manifold tests belong next to the plane kernels), both accounted for under RD-6.

Final measurements. 356/356 forge-3d tests green at f32 AND -Dphysics_f64=true, debug AND ReleaseSafe. Full zig build test: 264/264 steps, 1521/1538 tests passed, 17 skipped. The eleven inherited M1.1.5–M1.1.10 envelope quantities re-measured against main at 712e4b5 through a worktree with the same probe compiled in both trees, both precisions: zero movement, digit for digit. Benches reported, never gated; no envelope registered for a quantity below the noise floor.

Residual, intentional. The far-field residue of signedDistance grows like floatEps(T)·|p|, the structural worldspace limit -Dphysics_f64 answers, characterised in §1.11.15 and not masked. A back-face field, the fourth ordering-key term, ShapeStore owned memory and the third ShapeClass variant belong to M1.1.11.1. The ECS authoring surface for plane and mesh is deferred together, the mesh variant needing an asset handle that does not exist before M1.6. subshape_id stays 0 until compounds (M1.1.20). The 2D symmetry waits on PhysicsModule2D at M1.8.11.

Validation checklist

  • 356/356 forge-3d tests green at the four corners — f32 and -Dphysics_f64=true, debug and ReleaseSafe
  • zig build clean
  • zig build test green — 264/264 steps, 1521/1538 tests, 17 skipped
  • zig fmt --check clean
  • zig build lint clean on the tree, not the hook
  • §3.6.1 audits run on the WHOLE branch (712e4b5..HEAD), not per gate — language audit in real UTF-8 decoding: zero non-ASCII letters in any .zig file, two lines in the brief, one being the single verbatim French spec citation in the FROZEN SECTION (intact) and one a quoted grep pattern; drift audit over the 29 symbols the milestone introduces: every one defined and referenced, zero orphans
  • Scope diff against the FROZEN “Files to create or modify”: 20 changed files in the list, 10 outside it — all 10 covered by a Recorded deviation (RD-1 aabb.zig, RD-2 support.zig + shapecast.zig, RD-5 solver_test.zig, RD-6 the two benches + their two regenerated results + contact_cache.zig) plus the brief itself
  • CLAUDE.md patch included in this PR, not after the merge — Current state, the M1.1.11 tag row, two Safeguards entries; content supplied by Guy and verified byte-identical
  • Leak check proven in both directions (safety forced true fires on a deliberate 4 KiB leak; the default reports “no leaks” on the same leak)

Spec patches are OUTSIDE this PR

engine-physics-forge.md (§1.11.15, §1.11.16 and the amendments to §1.11.1, §1.11.3, §1.11.4, §1.11.7, §1.11.12, §2), engine-tier-interfaces.md (0.3 → 0.4), engine-c-api.md (WeldQueryStatus), engine-phase-1-plan.md (the split row) and engine-phase-1-criteria.md (C1.1’s shape list, nine of twelve → twelve) are patched by Claude.ai in the knowledge base. They are not in this repository and therefore not in this PR. The repo prose was read against engine-physics-forge.md md5 8599409c5ad11c8526f66e7d6db2e5b5 (1671 lines) and one divergence was found and corrected.

Review notes (non-blocking)

The dense list is deliberately not built. The per-layer unbounded list retires a slot in place and recycles it LIFO, so iteration follows the slot INDEX: after A, B, C, retiring A and inserting D iterates D, B, C. That is the wanted behaviour — what §1.11.14 requires is that the order be a deterministic function of the operation sequence, not insertion order, and no observable result depends on it since queries sort by the §1.11.14 key and computePairs by the canonical pair key with adjacent dedup. Making the list dense means compacting on removal, which moves a surviving slot’s index — and that index is what a live Proxy holds, so every held proxy would have to be rewritten or the list would need a second level of indirection. What it buys is iteration in O(live) rather than O(peak). The trigger for paying it: a real scene that CHURNS half-spaces, creating and destroying them during play rather than at load. None exists.

The bound is the PEAK of simultaneously live slots, per layer — not the live count and not the total ever created. items.len never decreases, so a layer that once held nine half-spaces at once keeps nine slots with one alive. What the free-list removed is the growth with the total ever created, which was the pathology: 64 create/destroy cycles leaving one live shape produced 64 entries before, and 1 after (measured with one probe compiled both ways). Acceptable because addBody rejects any non-static body carrying a half-space, so the population is authored level geometry whose peak the scene author controls — measured at 1 in every scene in this repository, both benches included.

🤖 Generated with Claude Code

guysenpai added 19 commits July 27, 2026 20:16
Frozen brief for the infinite plane (half-space) sub-milestone,
committed verbatim as the first commit of the branch.
E1 of M1.1.11. The narrowphase gains a taxonomy ABOVE the support map
(`engine-physics-forge.md` §1.11.15): a half-space `{x : n·x <= d}` has an
UNBOUNDED support map, so GJK, EPA and the cast kernel — all built on that
map — do not apply to it, and the category must be chosen before a shape is
converted into a `SupportShape`.

- `ShapeClass` with exactly TWO variants, `convex` and `half_space`, plus
  `Shape.class()`. The mesh is the third category and arrives at M1.1.11.1;
  every switch on the class is exhaustive with no `else` arm anywhere, so
  that addition is a compile error at each site owing a decision. `class()`
  itself is exhaustive over `ShapeType` and names each non-constructible
  variant individually rather than sweeping them behind an `else`.
- `Shape` gains the half-space `normal` and `distance`. `local_aabb` and
  `unit_inertia` are NOT VALID there and are `undefined`, documented on the
  fields; their readers assert the class (the `worldAabb`/`bodyAabb` and
  `computeSleepRadius`/`computeMotion` asserts land at E2).
- `ShapeDescriptor.plane` receives its payload — `normal: Vec3` (unit),
  `distance: f32` — a pre-freeze extension of the union permitted by
  `engine-tier-interfaces.md` §1 (v0.4). The query family is untouched.
- `createShape` builds a plane, asserting the descriptor normal is unit at
  `f32` tolerance and normalising the widened value ONCE, so no call site
  re-normalises: the `Body.rotation` pattern verbatim, and for the same
  reason — an f32-unit vector widened to f64 is off by ~6e-8 in its squared
  norm, and this normal is the sole source of the plane's contact normal.
- `supportShape` becomes the CONVEX ARM with an asserted precondition
  instead of a total function of the store.

The unit-normal guard is proven in BOTH senses: `descriptorNormalIsUnit` is
a named predicate exercised on accepted and rejected inputs including its
exact boundary, and the assert was observed to fire once on a non-unit
normal before the temporary probe was removed. The stored normal is pinned
with TWO bounds, not one: the LENGTH tight at the solver scalar (measured
0.21 ulp at f32, 1.0 ulp at f64) and the ORIENTATION at one ulp of `f32`
(measured 0.107 ulp at f32, 0.077 ulp at f64), because the direction can
only carry the resolution its f32 descriptor had.

Audit §3.6.1: two claims E1 falsified are corrected in passing — the
`ShapeType` doc in `api/types.zig` and the `worldAabb` comment asserting the
store admits only sphere/box/capsule. Four further occurrences of "the three
shapes the store builds" belong to the gates that reshape their entries
(`query/root.zig` and `shapecast.zig` at E3, `broadphase.zig` at E5,
`overlap_test.zig` at E4).

313/313 forge tests green at f32 AND `-Dphysics_f64=true`, debug AND
ReleaseSafe (306 on main + 7 new).
E2 of M1.1.11, with the three E1 review corrections folded into the same
commit.

`addBody` refuses a dynamic or kinematic body carrying a half-space with
`error.ShapeMustBeStatic` (`engine-physics-forge.md` §1.11.15). The name is
the INVARIANT, not the shape: `MeshShape` is static-only too (§2), so
M1.1.11.1 reuses this error rather than minting a second one. The refusal
sits immediately after the shape resolution — which it depends on — and
before every quantity the body literal derives from a local AABB or an
inertia. That ORDER is load-bearing and is now observed, not asserted: moved
below the literal, the rejection test panics at `computeMotion`'s class
assert instead of returning, and the counter-factual was run.

Class preconditions on all four readers, each one observed to fire on a
plane: `computeSleepRadius` (its sole `local_aabb` reader), `computeMotion`
on its DYNAMIC PATH ONLY — a static half-space is legal and takes the early
return without touching `unit_inertia`, so an entry assert would refuse the
one body type the shape is allowed — `worldAabb`, which asserts instead of
falling through to `unreachable` because the class is the information the
failure should carry, and `bodyAabb` at the body grain. The sleep radius in
the body literal becomes an exhaustive switch on the CLASS, so the mesh is a
compile error there.

F1 — `local_aabb` and `unit_inertia` carry NaN, not `undefined`. The two
mechanisms are complementary rather than redundant: a `std.debug.assert` is
compiled OUT of ReleaseFast, the mode E7 runs the benches in with a plane in
the scene, so the class asserts protect nothing there. MEASURED on the E1
commit, an `undefined` `local_aabb` gave a plane a sleep radius of 5.2510e-13
at f32 and 6.4444e-104 at f64, and the Debug 0xAA fill reads as −3.0316e-13
— all finite, small and plausible enough to go unnoticed. NaN survives
ReleaseFast and propagates loudly. Verified safe first: nothing compares or
hashes a `Shape` by value (34 `store.get` sites all read individual fields,
`shapes.items` is touched only inside `shape.zig`), which is the one thing
`NaN != NaN` would have broken. A static half-space body's `sleep_radius` is
NaN for the same reason and is read by nothing — both `sleep.updateWindows`
and the island seeding skip a non-dynamic body before touching it.

F2 — two measurement claims in `plane_test.zig` were wrong and are replaced
by values measured in the build, at both precisions. "0.21 ulp at f32" and
"0.107 ulp of f32 at f32" do not reproduce: both are EXACTLY ZERO, because
at f32 the normalisation is the identity on an already-f32-unit input (the
squared length rounds to exactly 1) and the comparison target is
bit-identical to what was stored. The f64 leg is 1 ulp of f64 on the length
and 0.0765 ulp of f32 on the direction. The bounds themselves were never in
question; the measurement assertion was.

F3 — the French verbatim spec citation in `api/types.zig` is paraphrased in
English. Also corrected: my own E1 doc named `worldAabb` and `bodyAabb` as
readers of `local_aabb`, which they are not — they compute a world box per
primitive and never touch the field; they assert the same class for a
different reason, now stated as such.

Audit §3.6.1: `addBody`'s doc now enumerates its three typed failures in
test order, and the `sleep_radius` field and getter docs carry the NaN case.
Language: zero French in the E2 diff. One PRE-EXISTING French citation
survives elsewhere in `src/` (`src/etch/parser.zig:498`),
outside this milestone's terms and deferred to the global audit per §3.6.2.

317/317 forge tests green at f32 AND `-Dphysics_f64=true`, debug AND
ReleaseSafe.
E3 of M1.1.11. The typed refusal of an inadmissible shape moves to where a
CALLER can provoke it, and the ray path stops carrying an error no input
could reach (`engine-physics-forge.md` §1.11.3, §1.11.7).

RED-first, in two steps, because the defect is a conflation rather than a
missing behaviour. First the conflation asserted as it stood — and it stood
on `main`: `shapeCast` with a stale probe handle returned `null` and with a
live probe aimed at empty space returned `null`; `overlapShape` returned `0`
in both cases. Byte-identical, so no caller could tell a malformed query from
a negative answer. Then the fixed form, seen red at the type level (`expected
error union type, found '?query.root.CastHit'`).

`shapeCast` and `overlapShape` now separate THREE outcomes: a stale handle is
`error.InvalidShape` — the same name and meaning as `addBody`'s, not a second
vocabulary — an inadmissible probe is `error.UnsupportedShape`, and a real
miss stays `null` / `0`. A ZERO DIRECTION also stays a miss, deliberately: it
is a degenerate query with an empty answer, not a malformed one, and folding
it into the channel would make a legal query look broken.

`query.Error` is RESHAPED, not extended: `error{ InvalidShape,
UnsupportedShape }`, both members reachable from a public entry by a caller
mistake the caller can then diagnose, and one-for-one with the frozen
`WeldQueryStatus` of `engine-c-api.md`. Before E3 it sat on the three RAY
entries, where its single member came from the kernel's rounded-box latch and
was reachable through no store shape at all.

Probe admissibility is one named predicate, an exhaustive switch on the class
with no `else`, tested BEFORE any use of the record in both entries — and the
order is not stylistic. The two entries touch `worldAabb` and `supportShape`
in OPPOSITE orders, so no single downstream guard covers both; and those
guards are `std.debug.assert`, compiled out of ReleaseFast, so without this
check a plane probe there is not a panic but undefined behaviour, `worldAabb`
falling through to its `unreachable`. That is what makes this gate
non-deferrable.

The rounded-box latch in `rayShape` becomes the asserted precondition
`raySupportsShape`, exposed as a predicate so the refusal provably belongs to
the SHAPE — it takes no origin — and so callers can decide admissibility
without relying on a debug assert. 32 code lines of error mechanism removed
across four files (the latch, three collector `err` fields with their `catch`
blocks, three entry `if (collector.err)` returns, and four signatures), plus
122 now-meaningless `try` removals in tests and benches and 29 additions on
the two entries that gained the channel.

Guards proven in both senses. `probeAdmissible` bites in the tests
themselves, refusing a plane on both entries and admitting a sphere that then
answers. `raySupportsShape`'s assert was observed to fire at all THREE
origins — interior, on a core face, exterior — at `raycast.zig:107`, before
the solid-membership test, which is the placement the interior case depends
on.

Test changes declared, three:

  1. `raycast_test.zig` "a rounded box fails loud instead of missing
     silently" → "…is refused by the ray kernel's precondition, whatever the
     origin". OLD: three `expectError(error.UnsupportedShape, rayShape(…))`
     at three origins, plus a radius-0 positive case. NEW: the same refusal
     read off `raySupportsShape`, whose signature has no origin — so the
     origin-independence the three samples argued is now structural — the
     positive case kept, and three claims the old test did NOT make: a point
     and a segment core are admissible at ANY radius, so the condition is
     specific to the box rather than a blanket radius test. The three origins
     are covered by the reported bite proof.
  2. `shapecast_test.zig` "sphere cast against a box does not error…" →
     "…answers where the ray kernel's precondition refuses". Clause (1)
     `expectError` → `!raySupportsShape`; clause (2) untouched. Same claim.
  3. `raycast_test.zig` "no reachable shape makes a query fail, and the error
     channel is not dead code" → "every shape the store can build answers a
     ray query, and the ray path is total". Its PREMISE is what this
     milestone closed: it recorded a dated unreachability whose date was
     M1.1.11. The three assertions are identical, now without `try` — which
     is itself the observable — and the record of the unreachability is
     replaced by the record of its closure.

Also `overlap_test.zig`'s `runEntry` helper becomes fallible (`!u32`, nine
call sites gaining `try`) so a stale or inadmissible probe fails the calling
test instead of reading as an empty answer.

The frozen-signature pin is EXTENDED, never weakened: the eight solver
entries keep their field-by-field type equality, and a third clause states
WHICH entries carry an error in the test's own vocabulary — six total, two
fallible, the error set pinned member by member and its cardinality pinned at
two. An absence pinned only by a type equality reads as an accident; pinned
as an absence, a later widening is a deliberate act.

Audit §3.6.1: the E1-deferred wordings this gate owns are patched — the
`shapecast.zig` header on the rounded box, its iteration-ceiling note (three
CORES, still three, a half-space having none), and `query/root.zig`'s cast
doc. Language: zero French in the E3 diff.

319/319 forge tests green at f32 AND `-Dphysics_f64=true`, debug AND
ReleaseSafe; both edited benches compile and run.
E4 of M1.1.11. The six kernels of `engine-physics-forge.md` §1.11.15's table,
in a new `pipeline/narrowphase/plane.zig` (301 lines, importing `std`,
`foundation` math and the sibling `support.zig` and nothing else), plus the
plane arm of the five `BodyManager` adapters.

Closed forms throughout, and therefore CHEAPER than GJK rather than costlier:
one support call and a division at most, no descent, no simplex, no restart,
no iteration ceiling. The sign of the separation IS the classification,
exactly — §3's three-band regime is not copied in, there being no accumulated
rounding to absorb. Exactly TWO guards, both at TRUE ZERO and both on `n·dir`
(ray and cast), each rejecting the RECEDING and the PARALLEL case with one
comparison; no geometric epsilon anywhere in the file.

The `− r_b` term is carried, and the SPHERE is the test that discriminates it:
a unit sphere whose CENTRE lies exactly on the boundary is penetrating by its
whole radius, `sep = −1`. The radius-free form is computed in the same test
and asserted to give `0` — "touching", the refuted answer — so the test can
tell the two apart rather than merely agreeing with one. A companion test
asserts that for a BOX the two forms are EXACTLY equal, `r_b` being 0, which
is why a box-only suite would pass with the term missing.

`LocalHit` and `CastHit` move to `support.zig`. Not cosmetic: `plane.zig` may
import only `foundation` and `support.zig`, and the adapter that dispatches
between a convex and a half-space by shape CLASS must return ONE type, not
two structurally identical ones. `narrowphase.plane` is exported as a
NAMESPACE rather than six flat names — `containsPoint`, `rayShape` and
`castShape` each have a bounded-convex namesake in the package, and
`narrowphase.plane.rayShape` says which category it serves.

RD-1 — `Aabb(T).overlapsHalfSpace` lands in `foundation/math/aabb.zig`, with
the eight-sign-pattern test beside it. Same first-consumer placement as
`surfaceArea`, `rayInterval` and `inflate`: pure box geometry, no threshold,
no physical semantics, and two callers need it — the broadphase, which imports
only `foundation`, and this file. The doc states the CLOSED half-space
`{ x : n·x <= d }`. The test checks the per-axis closed form against an
ENUMERATION of all eight corners, for all eight sign patterns of the normal,
on a box that is neither centred nor cubic, sweeping `distance` across the
whole projected extent so every pattern sees both verdicts and the exact
boundary case.

G1 — the placement of `rayShape`'s precondition is REDUNDANTLY protected, and
that is now written at both sites. `containsPoint`'s box arm carries its own
unconditional `assert(r == 0)`, taken for any point, so moving the
precondition below the membership test would panic one frame deeper at every
origin rather than reintroduce a silent miss. That is a structural argument
where mine was a sampled observation. `containsPoint`'s assert is marked DO
NOT DELETE AS REDUNDANT, since it is what makes the other line's position
unable to fail silently.

G2 — the precedence "inadmissible probe outranks degenerate direction" is
pinned: a plane handle AND a zero direction gives `error.UnsupportedShape`,
never `null`. It was correct in the code and argued in a comment with nothing
holding it.

MEASURED, and reported as it came out. A ray PARALLEL to the boundary in
WORLD, against a rotated plane body, does NOT miss: the true-zero guard is
exact in the frame it is evaluated in, and a rigid transform does not preserve
exact orthogonality, so the transported dot product comes out at ONE ULP of
the scalar and the kernel correctly reports a crossing at
`sep / floatEps(Real)` — 8.388612e7 m at f32, 4.503600e16 m at f64. What
rejects such a ray is the query entry's finite `max_distance`, which §1.11.4
requires to be finite, not the kernel inventing an epsilon. The
exactly-parallel case, where the guard does fire, is the kernel-level test.
This closed form is asserted at both precisions rather than the case being
quietly dropped.

Far field: for a half-space §1.11.4 bis splits differently than for the ray
kernels. The normal is the STORED `n` returned with no arithmetic, so BOTH its
length and its orientation are exact at any range — asserted as BIT-EQUALITY,
not a tolerance, out to 5e4 m. What carries the residue is the scalar
`signedDistance`, whose absolute error grows like `floatEps(T)·|p|`; its bound
is scale-relative for that reason.

Guards proven in both senses, and every one was observed to fire:
`supportShape` on a plane (`shape.zig:190`), `halfSpace` on a convex
(`shape.zig:235`), `worldAabb` on a plane (`body_manager.zig:911`),
`computeSleepRadius` on a plane (`body.zig:153`), `HalfSpace.assertUnit` on a
non-unit normal (`plane.zig:103`). The two `n·dir` guards bite inside the
tests themselves, receding and parallel both answering miss while the closing
case answers a hit.

E2 correction: its rejection test claimed the ordering counter-factual "was
run once by hand". It had not been — the gate signal never went out — so it
was run now: moving the refusal below the `Body` literal makes the dynamic
half-space panic at `computeMotion`'s class assert (`body.zig:185`). The claim
in that comment is verified as written, and nothing in it changed.

Scope respected: the harness is UNTOUCHED and no plane body goes through it,
`harness.World.addBody` calling `bodyAabb` to insert a proxy. The adapter
tests drive `bm.addBody` and the five adapters directly, against a
hard-coded world-frame oracle derived once in a comment — a plane body at
(10, 20, 30) rotated +90° about +Z carrying local `{ y <= 0 }` gives the world
half-space `x >= 10` — never against a value recomputed with the same rotation
call the implementation makes.

Audit §3.6.1 clean, language audit clean on 887 added lines. 332/332 forge
tests green at f32 AND `-Dphysics_f64=true`, debug AND ReleaseSafe; full suite
1499/1516 (17 skipped).
E5 of M1.1.11. An unbounded AABB does not degrade the BVH, it destroys it
(`engine-physics-forge.md` §1.11.15): the centre of an infinite box is
`(−inf + inf)·0.5`, i.e. NaN — and that centre is the ray origin a shape cast
derives from a box — the surface area is infinite so the SAH cost is infinite
at every candidate, and the union propagates the infinity to the root, after
which every query visits every node. The finite substitute box is refused too:
it is a tuning constant that changes a query's answer. So a half-space is
never asked for a box. It is asked the corner PREDICATE, and it lives in a
flat insertion-ordered list per broad layer, with no hashed container on the
path.

`Proxy` gains `kind: ProxyKind` — every consumer switches on it exhaustively,
so a third structure would be a compile error at each site rather than a
mis-index into the wrong pool. `remove` dispatches; a slot is RETIRED IN
PLACE, never compacted, because a live `Proxy` holds a list index. `update`
ASSERTS `.tree`: a half-space forces a static body, so it cannot move, and
asking it to is a caller error rather than a no-op to absorb.

BOTH pairing directions, and the second is the one that matters:

  (1) a moved bounded proxy is crossed with the unbounded lists of the layers
      it may pair with — without it a body created after a plane collides
      with nothing;
  (2) an inserted unbounded shape is crossed with the TREES of those same
      layers — without it a plane created after the bodies collides with
      nothing, and THE OMISSION IS INVISIBLE in any scene that creates the
      plane first, which is every naively written test.

The direction-(2) test was seen RED before the insertion path existed, and its
discrimination was then proven by counter-factual: disabling direction (2)
fails exactly that one test and no other, so the direction-(1) test really
does cover the other half independently.

Two half-spaces are NOT crossed with each other. No narrowphase kernel exists
for it — §1.11.15's table is a half-space against a bounded convex — and the
only reachable combination is forbidden anyway, a half-space forcing a static
body and `default_layer_pairs` having static×static false. A dated
unreachability, named at the site rather than left to be discovered.

`Bvh.queryHalfSpace` is `queryAabb` with one predicate swapped, and
`queryAabb` / `queryRay` / `queryCast` all visit the lists. The visited-node
count is deliberately NOT incremented by a list entry: that metric attests the
TREES' logarithmic property, and folding an explicitly linear per-layer cost
into it would make a logarithmic claim unreadable. The discrimination guard is
the RESULT instead — the query scenes hold exactly one unbounded shape and NO
tree proxy at all, so a collected id can have come from nowhere else — and the
negative control is there too, a box entirely above the plane collecting
nothing. `shouldStop()` is honoured for the lists as it is between the trees.

Item 6, deferred from E2 and closed: `overlapAabb`'s collector called
`bodyAabb` on every candidate, which asserts on a half-space and in
ReleaseFast falls through `worldAabb`'s `unreachable`. It goes through the new
`aabbOverlapsBody` (RD-4), whose convex arm is still the body's TIGHT world
box — the fat leaf box would make the answer a function of a tuning constant —
and whose half-space arm is the corner predicate. MEASURED: reverting the
collector to `bodyAabb` panics at `body_manager.zig:481` in two tests, so the
fix is load-bearing and not defensive.

Item 7: the harness learns the half-space (RD-5), which is what unblocks the
eight query entries at ENTRY grain rather than body grain. All eight are
exercised against a ground plane with closed forms, plus the shared
obligations — the object mask, the exclusions, a sleeping body that answers
and stays asleep, invariance under creation-order permutation with the plane
created first in one ordering and last in the other, and two bit-identical
runs.

H1 — the brief gains its LIVING SECTION, overdue since E1. Seven Recorded
deviations: RD-1 `Aabb.overlapsHalfSpace` in `foundation/math`, RD-2
`LocalHit`/`CastHit` into `support.zig` (both were reported at their gate but
not written down), RD-3 direction (2) prunes on the predicate instead of
enumerating every leaf, RD-4 the sixth adapter, RD-5 the harness, RD-6 the
mechanical E3 propagation into `shapecast.zig` and the two benches, RD-7
`broadphase.zig` 981 → 1176 lines with the itemisation and the reason no split
was made. Plus the execution log for E1–E5. The FROZEN SECTION is
byte-identical to the file as delivered, verified by diff.

H2 — the two parenthetical glosses are replaced by what was MEASURED. They
read `10 / 1.19e-7` and `10 / 2.22e-16`, which do not recompute to the figures
beside them. The transported dot product is EXACTLY `−floatEps(Real)` on both
legs (measured ratio 1.000000000), the returned parameters are 8.388612e7 and
4.503599627370497e16, and the pure `10 / floatEps(Real)` values are 8.388608e7
and 4.503599627370496e16 — the excess being `sep`'s own rounding, the
transported signed distance being 10 m only to the precision the same rotation
carries. Both figures now recompute from the table in the comment.

H3 — NOT DONE, and the reason is recorded as blocker B1: the amended §1.11.15
did not arrive with the gate message. Everything its three paragraphs describe
is implemented, measured and asserted; what is pending is only aligning the
prose on the KB's formulation, which is not invented here.

342/342 forge tests green at f32 AND `-Dphysics_f64=true`, debug AND
ReleaseSafe; full suite 1509/1526 (17 skipped); language audit clean on 965
added lines.
E6 of M1.1.11. `collidePair` gains its half-space arm and the manifold
generator its plane arm, which closes the residual E5 named: `world.step()`
with a plane body panicked in `collidePair` the moment the broadphase emitted
the pair, the plane reaching `supportShape` whose precondition is the convex
class.

The dispatch is a NESTED exhaustive switch on the pair of categories, no
`else` on either level, so the mesh (M1.1.11.1) is a compile error at all four
arms. The half-space arm is written with the PLANE as A, the orientation
§1.11.15's formulas are stated in; when the plane is B — reachable, the order
being the body-id order and the plane creatable either side — the pair is
computed the other way and the normal negated, which is what `collidePair`
already does one level up. Plane against plane is `unreachable`: no kernel
exists for it, and the broadphase cannot emit it, a half-space forcing a static
body and the layer matrix having static×static false.

NO CLIPPING RUNS, and it is said at the site to be a property rather than an
omission: the clip cuts an incident polygon against the reference face's SIDE
PLANES, and a half-space has none. `clipIncident`, the ≤4 reduction, the
reference/incident selection and the `faceNormalA` alignment test all have
nothing to do — the supporting face already has at most four vertices and they
are already the answer.

`keep_eps` is DELIBERATELY NOT COPIED from the generic generator, and the
reason is the difference between the two producers. There, each candidate is a
solved crossing of two planes, so `pen = r_sum − s` accumulates the rounding of
that solve and a point genuinely on the boundary can land either side of zero.
Here a vertex's separation is one dot product and one subtraction on a support
point: nothing accumulates, so the sign IS the classification, exactly, as
§1.11.15 states. Two things follow and both are stated — `overlapShapeBody`'s
exact `separation(...) <= 0` and this generator agree to the bit on whether a
pair touches, and the 3-versus-4 count at exactly zero penetration stays inside
§3's documented topological-flip band instead of being papered over.

`position` is the midpoint of `core_vertex − r_b·n` and its projection, which
is §3 verbatim: with `penetration = −sep` and the normal A→B,
`position + ½·pen·n` is the PLANE's surface point and `position − ½·pen·n` the
convex's — verified against `contact_constraint.prepare`'s own
`surface_a = position + ½·penetration·normal` before being written, and
asserted per contact in the test. No case for this shape in the position pass.

`feature_id` uses a FOURTH class tag, `class_plane = 0xC000`, rather than a
free pair of the existing three — and the pair `(class_a, class_c)` was
already the single-witness producer's, so a free pair was not free. The
disjointness is now STRUCTURAL and asserted BY MASK: a comptime block proves
the four tags pairwise distinct and each exactly a class value, the producers
OR one of them into each half and nothing else, so the test masks the
reference half instead of enumerating which pairs happen to be taken. The
converse is measured too — a convex pair through the same entry never carries
that class.

MEASURED, both legs, on the full cycle: the box settles at centre_y
0.495073940 (f32) / 0.495074006 (f64), penetration 0.004926056 / 0.004925994,
vy −3.7252903e-9 / 6.9388939e-18, and the settled manifold carries FOUR
contacts and one constraint. Worth recording: it settles just BELOW the slop
where M1.1.7's RD-1 measured a box on a BOX settling just above it
(0.00500059 at f32) — not a discrepancy, the correction factor being 0.2 so the
last step lands within one step either side, but the plane's `sep` is a dot
product against a stored unit normal with no clip behind it, so nothing pushes
it to one side. The test bound is stated on the slop and one correction step,
not as a window a box halfway through its fall would also pass.

Guards bitten, both new ones: plane-vs-plane at `body_manager.zig:948`,
`collidePlane`'s unit-normal domain at `plane.zig:103`.

THREE CORRECTIONS TO MY OWN TESTS, all of the same class and all caught by the
f64 leg, which is the argument for running it during a gate and not at its end.
A body's position comes from an `f32` DESCRIPTOR (§1.11.8), so a centre of 0.4
is stored as 0.4000000059604645 and the penetration is 0.09999999403953552,
not 0.1 — which passes at f32 where the tolerance is coarse and fails at f64
where it is not. Two tests were rewritten on DYADIC inputs (3/8, 1/2, 1/8,
1/16, 7/8) so the closed forms are exact at both precisions: that removes the
quantisation instead of widening a bound to tolerate it. The third was the
oblique normal compared against the exact `f64` rational at `floatEps(Real)`,
which is the very rule E1 established and I failed to apply — the orientation
can only carry its `f32` input's resolution, so it is pinned at
`floatEps(f32)` and cross-referenced to the E1 test that measured it.

`zig build lint` caught four public constants carrying `//` instead of `///`.
Noting the mechanism, because it matters: the `pre-commit` hook lints STAGED
files only, so it would not have seen them — the tree-wide `zig build lint` is
what does.

B1 is STILL OPEN, and the gate message declared it resolved. Re-checked: the
spec file on disk is byte-identical to the copy read at E1 (1663 lines, md5
`2d4ae6f45eb4ce46e27c33e9648fe47d`, §1.11.15 spanning 660–707 and still ending
on "Sous-shapes"), and none of the three new topics appears in it. The prose
alignment is carried again rather than invented; the brief's B1 entry records
the re-check.

347/347 forge tests green at f32 AND `-Dphysics_f64=true`, debug AND
ReleaseSafe; full suite 1514/1531 (17 skipped); `zig build`, `zig fmt --check`
and `zig build lint` clean; language audit clean on 475 added lines.
E7 phase 1 of M1.1.11. No behaviour change beyond the doc alignment and the
seven `subshape_id` doc corrections; everything else is measurement.

B1 RESOLVED, and the fault was my search, not the delivery. The amended
`engine-physics-forge.md` is `Devlab/weldengine/weld-spec/` — 1669 lines, md5
`7f2143299c709e28f93155fe959d0693` — and `diff` against the copy read at E1
shows a PURE APPEND of three paragraphs after line 704, nothing renumbered. It
had been on disk since the E5→E6 gate. Both earlier reports searched only
`Downloads/`, where every previous milestone's bundle had been, and concluded
"not attached" from the absence there rather than from a search of the tree.
Each individual check was accurate and the conclusion was still wrong: the
right move was to widen the search, not to report a negative from a narrow one.
`plane.zig` and `plane_test.zig` are aligned on that formulation — a true-zero
guard's exactness does NOT COMPOSE across frames, the `sep / floatEps(T)`
traversal with both figures, the corollary that a test expecting `null` from a
world-parallel ray tests a property the model does not promise, the §1.11.12
refusal that forbids fixing it with an epsilon, and the conditioning
decomposition that moves the whole far-field residue into `signedDistance`.

I1 — the bit-agreement E6 CLAIMED is now EXERCISED. 15 configurations
bracketing the contact (box, sphere, capsule × one ulp above / exactly on / one
ulp below / two controls, `nextAfter` walking f32's real neighbours):
`overlapShapeBody(...) == (collidePlane(...) != null)` everywhere, at both
precisions. The sample straddles — 6 separated, 9 touching, both verdicts per
shape — so the equality is not vacuous. No topological-flip band appeared on
this sample, so none is named.

Item 3 — the stale `subshape_id` wording: 40 occurrences before, 38 after,
SEVEN patched, all in source. `api/types.zig` (RaycastHit's type doc,
ShapeCastHit's field, the frozen-family test comment), `query/root.zig` (the
three mirror types), and `rigid/contact_cache.zig` — the last said "Sub-shape
INDEX", which is exactly what §1.11.16 refutes. Two occurrences of the phrase
remain in source, both deliberate: one quotes the superseded wording to name
it, the other is about the layer PREDICATE and is a different, still-true
claim. The other 29 are field-type pins with no semantic claim, or CLOSED
RECORDS in CLAUDE.md tag rows and frozen briefs — the rule that kept the M0.7
plan line un-patched at M1.1.9.

Item 4 — the bench delta, and THE RESULT IS THE REPEATABILITY. Both benches
gained a `with_plane` scene and a delta block measuring both scenes in the SAME
process, back to back, since a delta across runs would carry thermal drift and
a differently compiled path. Five raycast runs and three shapecast runs of the
same binaries: closest +1.5/−3.5/+16.2/+4.6/+27.9 ns on a 759–794 base, any
+11.2/−7.7/+15.1/+0.7/+38.2 on 552–582, all +25.2/−21.2/−10.4/−33.3/−14.4 on
1490–1585, sphere cast +15.1/+11.9/−7.2 on 1170–1209, point query and
overlapAabb at 0.0 ± 0.5. **Every mode shows BOTH SIGNS across runs, so the
cost of one half-space in a per-layer list is below this bench's noise floor
and its sign is not stable.** Reported as such rather than by picking the tidy
run. No envelope is pre-registered, and none should be for a quantity below the
noise. The two cheap entries' ~0 ns is the corner predicate rejecting probes
that sit above the plane, which is what it should cost.

A real defect in my own delta harness, found by running it: a shape handle is
PER STORE, and passing `scene`'s probe handle to `scene_plane`'s store resolved
the same slot index to a different shape — the plane — so the entry answered
`error.UnsupportedShape`. That is the E3 channel doing exactly its job on a
caller mistake, and it is why the probe is now created in the store it is used
against.

Item 5 — the eleven inherited M1.1.5–M1.1.10 envelope quantities re-measured
against `main` at `v0.11.10-queries-shapecast-overlap` (`712e4b5`) through a
worktree, the SAME probe compiled in both trees, both precisions: ZERO
MOVEMENT, digit for digit, all eleven (table in the brief's closing notes).
Corroborated structurally: nine of the inherited test files are BYTE-IDENTICAL
to the tag, and the only one this milestone touched — the harness — has no
changed line containing a digit.

Item 6 — the leak check proven in BOTH directions: with `safety` forced true a
deliberate 4 KiB leak printed `LEAK DETECTED`; with `DebugAllocator(.{})` the
SAME leak printed `no leaks`. The default is not a weaker check, it reports
success unconditionally in ReleaseFast. Both edits reverted.

I2 recorded in the brief: `zig build lint` caught four public constants
carrying `//` instead of `///`, and the `pre-commit` hook runs `weld-lint` on
STAGED FILES ONLY — so an uncommitted change is invisible to it and a change
staged in one commit is invisible to the next. The tree-wide `zig build lint`
is what catches this class and no hook runs it. The CLAUDE.md *Safeguards*
entry is not written here: Guy supplies that content at phase 2, together with
the `Current state` block and the tag row.

The brief gains its Closing notes and an E7 log row; RD-6 is extended to cover
the bench parameterisation the FROZEN Benchmarks section mandates. The FROZEN
SECTION remains byte-identical as a prefix, verified by diff.

348/348 forge tests green at f32 AND `-Dphysics_f64=true`, debug AND
ReleaseSafe; full suite 1515/1532 (17 skipped); `zig build`, `zig fmt --check`
and `zig build lint` clean; language audit clean on 495 added lines.
J1 of E7, and it changes behaviour, so it lands alone and before the closing
measurements.

`plane.castShape` returned `plane.normal` at initial overlap whatever the sweep
direction. A cast aimed OUT of the solid — along `+n`, to leave the half-space
— therefore answered `normal · direction = +1`, breaking the invariant
`shapecast.zig`'s `terminal` states in as many words: `−direction` is "the only
one keeping `normal · direction <= 0` on every hit". All four kernels now say
the same thing at a zero parameter: `raycast.zig` for an origin inside a
convex, `plane.rayShape` for an origin inside the half-space, `shapecast.zig`
when its own axis has collapsed, and this one.

Why no information is lost, written at the site: a cast does not measure
penetration geometry. It answers WHEN two shapes touch, and at an initial
overlap there is no time of impact and no unique separating axis to report —
§1.11.11 says as much, deferring the deepest point at a zero parameter to an
EPA it deliberately does not run. The question `n` answers is the MANIFOLD's,
and `collidePlane` answers it exactly, with `n` and a penetration. For this
shape the caller can also always recover `n`, it being a stored field; the
invariant, by contrast, is what every consumer relies on and cannot
reconstruct. The witness still lies on the boundary along `n` while the normal
comes from the invariant — the same shape of answer `shapecast.zig` gives when
its axis collapses, and stated as such.

`castShapeBody` needed no change, and that was verified rather than assumed: it
only ROTATES the kernel's normal into world, and a rotation preserves the dot
product with the equally rotated direction, so the invariant is carried
exactly. The fix is local to the kernel.

THE TEST THAT DID NOT EXIST is the outward one. The existing initial-contact
test asserted the distance, the witness and that the witness is not the cast
origin — but never the normal, which is why flipping the returned normal moved
no test at all. Three tests now: five sweep directions from inside (straight
out, straight in, along the boundary, obliquely out, obliquely in) each
asserting the invariant AND `−direction` AND that the witness still lies on the
boundary; a four-kernel agreement test; and an adapter-grain test that the
world transport preserves the invariant. Counter-factual run: restoring
`plane.normal` fails all three and nothing else.

351/351 forge tests green at f32 AND `-Dphysics_f64=true`, debug AND
ReleaseSafe.
A NaN plane distance was accepted at creation and then read by two
consumers that disagree about it. Measured identically at both
precisions: collidePlane reported one contact point for a unit sphere
1000 m OUTSIDE the solid, because `sep > 0` is false when sep is NaN and
the skip never fires; overlapsHalfSpace returned false for a box at the
origin and for a box 5000 m INSIDE, because `<=` against NaN is false in
the other direction. One malformed field, two opposite answers.

HalfSpace.assertUnit becomes assertDomain — unit normal and finite
distance — at its seven call sites, and buildShape's plane arm asserts
the descriptor's distance finite beside the existing normal check. Both
halves bit: removing the descriptor-side assert reaches the kernel for a
NaN distance, removing the kernel-side one panics in plane.zig.
remove cleared a slot's live flag and left it in the list forever, so the
per-layer unbounded list was bounded by the total ever created and not by
the live count. Measured with one probe compiled both ways: 64
create/destroy cycles leaving one live shape produced a list of 64
entries without the free-list and 1 with it.

The slot is retired in place, exactly as Bvh retires a node: the dead
slot's user_data carries the next-free index — Bvh reuses parent the same
way — and null_slot terminates the chain. The doc no longer says the slot
is kept; it says what keeping the slot defends, which is its IDENTITY and
not its existence, since a stale index held by a moved-log entry must
never resolve to a live shape it does not name. Reuse is bounded and the
argument is written at the site: a reused slot's stale moved entry names
the NEW occupant, which was itself logged at insertion, so the only
effect is a duplicate the existing sort-and-dedup removes.
J1 the cast normal at an initial overlap, with why no information is lost
and the outward case that did not exist before. J2 the finite-distance
domain gate, with both contradictory consequences measured. J3 the LIFO
free-list, with 64 slots measured before and 1 after. J4 the back-face
bound: six mentions classified, an empty patch set, and the note that the
announced spec md5 is on no reachable copy so the KB-side check was not
performed. RD-7's line accounting updated for J3's 48 lines.
ensureUnusedCapacity(gpa, 1) guarantees room for len + 1, so at
len == capacity it GROWS the list. The comment claimed it was a no-op
there, and that one false sentence was what the atomicity argument rested
on: an insertion the free-list was about to serve for free could fail
with OutOfMemory.

moved_unbounded stays unconditional — it always receives an entry — and
unbounded is reserved only when the free-list is empty. The head is read
before either reserve and reading mutates nothing, so both fallible steps
still precede every mutation.

Also restates the list's real bound. It is the PEAK of simultaneously
live slots per layer, not the live count: items.len never decreases, so
nine half-spaces alive at once keep nine slots forever. What the
free-list removed is the growth with the total ever created, which was
the pathology. Acceptable because a half-space forces a static body, so
the population is authored geometry whose peak the scene author
controls — measured at 1 in every scene here. The ordered dense list that
would make iteration O(live) while keeping the normative insertion order
costs O(n) removal and is deliberately not built; a swap-remove would be
O(1) and would destroy that order. Trigger: a real scene that churns
half-spaces.
The public payload documented only the unit normal. It now carries one
"Domain, asserted at creation" clause covering both fields — normal unit,
distance finite — and says why distance earns its own line: nothing
downstream catches a non-finite one, and the two measured consequences
contradict each other.

The brief records K1 (no Etch target fails at the tag or on the branch;
the failed-command line is a build-runner diagnostic emitted for every
step whose passing tests write to stderr, read on the Zig 0.16.0 source
and proven by injection), L1, L2, L3, and L4 stopping because the
announced spec md5 is on no reachable copy.
Two claims were false from the free-list onward, and the second leaned on
the first: the field declared the list insertion-ordered, and the
dense-list paragraph invoked an insertion order as normative. LIFO
recycling gives D, B, C after A, B, C, retire A, insert D.

The contract is three clauses: a slot's index never moves while a proxy
holds it, a retired slot is recycled LIFO, iteration follows the index.
M1.1.14 requires not insertion order but that the order be a
deterministic function of the operation sequence, which LIFO recycling
satisfies exactly — the same argument the free-list comment already made
about index reuse, never carried to the field. And no observable result
depends on it: queries sort by the (distance, entity, BodyId) key, pairs
by the canonical packed key with adjacent dedup.

Four sites corrected plus the brief record that rested on the same false
premise. The dense-list note now states the real cost, which is that
compaction moves an index a live Proxy holds. No dense list.

The test pins D, B, C as WANTED, then the requirement: an identical
sequence iterates identically, and a different sequence reaching the same
live set gives A, D, C — without that leg the identity assertion would
pass on any implementation. Disabling LIFO reuse makes insertion the order
and fails it.
Current state moves to M1.1.11 with M1.1.10 closed at 712e4b5 / tag
v0.11.10-queries-shapecast-overlap, and Next planned becomes M1.1.11.1,
the split mesh half with what it carries.

Two Safeguards entries. The pre-commit hook lints staged files only, so
the tree-wide zig build lint is what catches a class it cannot see. And
`failed command:` does not mean a command failed — it is printed for every
Run step whose tests pass but write to stderr, seven of them here, so a
build is judged on --summary all and the exit code.

Content supplied by Guy, applied verbatim.
Content supplied by Guy, applied verbatim after the v0.11.10 row. Row
well-formed: exactly five pipes, so no cell carries an unescaped one.

The four resting figures it cites are bit-for-bit the ones recorded at
plane_test.zig:1324-1325, including the f32 delta that accounts for the
apparent gap between 0.5 - centre_y and the measured penetration.
Confirmation reading against engine-physics-forge.md
8599409c5ad11c8526f66e7d6db2e5b5, 1671 lines. Three of the four amendments
were already concordant; one diverged, on the surface the spec singles
out.

shape.zig already said the normal must be ALREADY unit to f32 tolerance
because it IS f32, and plane.zig carries the full account of the two
contradictory behaviours a NaN distance produces. The public payload said
only "must be unit" — dropping the already-unit framing and its precision
qualifier, which is the distinction that matters when the descriptor is
f32 and the solver scalar may be f64 — and did not name the analogy the
spec closes on, the typed rejection of a collision_layer outside [0, 32).

Brief records the reading: the frame-locality and conditioning halves
concordant clause for clause with the spec's own two figures, the
slot-stable contract concordant with a difference of scope on the
dense-list paragraph reported rather than reconciled, and zero
occurrences of the old back-face bound tree-wide.
Status CLOSED, Closed 2026-07-30 (code-complete, PR open; merge and tag
are Guy's) — the M1.1.6 header form. Both lines sit above the FROZEN
SECTION marker, so the frozen body is byte-unchanged.

Closing notes complete what the later gates added: the K2 to K4 CLAUDE.md
patch applied verbatim inside the PR, with the note that its content took
three emissions and was never reconstructed; a summary block in the M1.1.6
five-bullet shape; and forward pointers on the two STOP records so neither
reads as unresolved on its own.

RD-6 now names the two files the scope diff surfaced — contact_cache.zig,
doc only and required by the new subshape_id section, and the two
regenerated bench results — and records the two listed files that were not
touched because neither needed to be: the query entries live in the
root.zig facade, and the plane manifold tests belong next to the plane
kernels.
@guysenpai
guysenpai merged commit 09bd386 into main Jul 30, 2026
10 checks passed
@guysenpai
guysenpai deleted the phase-1/forge/plane-halfspace branch July 30, 2026 11:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant