Phase 1 / Forge / Shapes: infinite plane (half-space) - #65
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
ShapeStoreowned 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:
ShapeClasswith two variants and noelsearm 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, theundefinedsleep 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
overlapsHalfSpaceinfoundation/math, RD-2 the two result types relocated tosupport.zigso a class-dispatching adapter returns one type, RD-3 pairing direction (2) pruning rather than enumerating, RD-4 a sixthBodyManageradapter, RD-5 the harness, RD-6 the files outside the list, RD-7broadphase.zigat 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 thequery/root.zigfaç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. Fullzig build test: 264/264 steps, 1521/1538 tests passed, 17 skipped. The eleven inherited M1.1.5–M1.1.10 envelope quantities re-measured againstmainat712e4b5through 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
signedDistancegrows likefloatEps(T)·|p|, the structural worldspace limit-Dphysics_f64answers, characterised in §1.11.15 and not masked. A back-face field, the fourth ordering-key term,ShapeStoreowned memory and the thirdShapeClassvariant 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_idstays 0 until compounds (M1.1.20). The 2D symmetry waits onPhysicsModule2Dat M1.8.11.Validation checklist
-Dphysics_f64=true, debug and ReleaseSafezig buildcleanzig build testgreen — 264/264 steps, 1521/1538 tests, 17 skippedzig fmt --checkcleanzig build lintclean on the tree, not the hook712e4b5..HEAD), not per gate — language audit in real UTF-8 decoding: zero non-ASCII letters in any.zigfile, 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 orphansaabb.zig, RD-2support.zig+shapecast.zig, RD-5solver_test.zig, RD-6 the two benches + their two regenerated results +contact_cache.zig) plus the brief itselfCLAUDE.mdpatch included in this PR, not after the merge —Current state, the M1.1.11 tag row, twoSafeguardsentries; content supplied by Guy and verified byte-identicalsafetyforced 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) andengine-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 againstengine-physics-forge.mdmd58599409c5ad11c8526f66e7d6db2e5b5(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, retiringAand insertingDiteratesD, 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 andcomputePairsby 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 liveProxyholds, 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.lennever 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 becauseaddBodyrejects 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