diff --git a/CLAUDE.md b/CLAUDE.md index 40151a1..983ef41 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,10 +10,10 @@ knowledge base — see § Quick links spec. | Field | Value | |---|---| | Phase | 1 (Etch ↔ ECS) | -| Current milestone | M1.1.10 — queries: shapecast + overlap + point query + closest point — code-complete, PR open. M1.1.9 is CLOSED, squash-merged to `main` (commit `dd7fa1f`, tag `v0.11.9-queries-raycast`). | -| Last released tag | `v0.11.9-queries-raycast` (posted by Guy after merge) | -| Active branch | `phase-1/forge/queries-shapecast-overlap` (PR open, not merged) | -| Next planned milestone | M1.1.11 — core shapes: infinite Plane + static MeshShape. M1.1.0–M1.1.9 CLOSED. | +| Current milestone | M1.1.11 — Forge 3D shapes: the infinite plane (half-space) — code-complete, PR open. M1.1.10 is CLOSED, squash-merged to `main` (commit `712e4b5`, tag `v0.11.10-queries-shapecast-overlap`). | +| Last released tag | `v0.11.10-queries-shapecast-overlap` (posted by Guy after merge) | +| Active branch | `phase-1/forge/plane-halfspace` (PR open, not merged) | +| Next planned milestone | M1.1.11.1 — core shapes: static MeshShape. The plan row that grouped Plane and MeshShape is SPLIT (`engine-phase-1-plan.md`): the mesh half carries a rigid-solver change (several contact constraints per body pair — `ContactConstraint` identity, the warm-start cache key, island constraint ordering), an active/internal-edge policy, per-triangle `raycastAll` results with the fourth ordering-key term §1.11.14 would then need, `ShapeStore` owned memory, back-face mode on the two query structs, and the third `ShapeClass` variant. M1.1.0–M1.1.10 CLOSED. | ## Tags @@ -67,6 +67,7 @@ knowledge base — see § Quick links spec. | `v0.11.8-islands-sleep` | 2026-07-25 | M1.1.8 — Island manager + sleep detection | Ninth M1.1 sub-milestone — the first consumer of the `solveRange`/`solvePositionRange` index-range seams, both now driven once per island. New `pipeline/island.zig`: branch-neutral union-find over OPAQUE indices (`std` only, same dependency discipline as broadphase/narrowphase), path compression + union by size with a fixed tie-break on the smaller root index, so `link` is symmetric in tree SHAPE (pinned); the representative is NOT canonical (it depends on the link sequence) and is documented as never usable as an identity or ordering key. New `rigid/island_manager.zig` adapter: seeds every AWAKE DYNAMIC body, singletons included (Jolt `IslandBuilder::Finalize(inActiveBodies…)` lineage), links dynamic↔dynamic ONLY — statics and kinematics excluded for Jolt's stated reason that a constraint cannot affect an infinite-mass body's velocity, and linking through the ground would fuse the scene — each constraint attached to its dynamic endpoint; ranks by MINIMUM MEMBER `BodyId` (unique by disjointness, no tie-break exists), constraints ordered by the composite key `(rank, pair_key)` so contiguity never rests on sort stability; `BodyId` → dense index by slot-indexed scratch, zero hash containers anywhere on the path. Per-island solving is BIT-EXACTLY equivalent to a global range on disjoint islands — structural (disjoint body writes, relative order preserved, an early-out on a converged range cannot open a gap) and pinned with a discrimination guard proving the two strategies really ran different iteration counts. New `pipeline/sleep.zig`: the criterion is a CONSERVATIVE DISPLACEMENT BOUND over a window against a stored reference pose, `d = ‖Δx‖ + 2·sleep_radius·‖vec(Δq)‖`, the rotational term being the exact `2·sin(θ/2)` chord (trig-free, §1.5) and the sum a triangle bound; `SleepConfig{allow_sleeping=true, point_velocity_threshold=0.03 m/s, time_before_sleep=0.5 s}` with the named derived `maxDisplacement()` = 15 mm — physical constants, same class as `restitution_threshold`, every numerical guard still at true zero. Window per BODY (island identity is rebuilt each tick and cannot carry a timer), decision per ISLAND by AND over members (Jolt `Body::UpdateSleepStateInternal` + `CheckSleepAndUpdateBounds` lineage), evaluated POST-SOLVE at step 11 — the only point in the cycle where a body sleeps and where its velocities are zeroed EXACTLY. `BodyFlags` gains `can_sleep`/`sleeping` from `_reserved` (byte size unchanged), `Body` four sleep columns, `BodyDescriptor.can_sleep` closes the gap where the public `RigidBody` carried the flag and the descriptor dropped it (same class as the M1.1.6 friction/restitution drop). WRITE-INTENT SEPARATION (§1.8.4), without which nothing in contact could ever sleep since both passes write velocity and pose every tick: `setLinearVelocity`/`setAngularVelocity`/`setPosition`/`setRotation` are NON-ACTIVATING by contract (the solver's own write path), `addForce`/`addTorque`/`addImpulse` are ACTIVATING (external by construction, zero solver call sites), `wakeBody`/`setCanSleep` are the primitives and the M1.1.15 interface tier composes wake + write. `build` becomes a WAKE FIXPOINT over the retained candidate set (`bm: *const` → `*`, no call site changed textually): pairs whose two endpoints are non-awake are deferred and cost no narrowphase, any sleeping endpoint is woken as soon as a manifold appears WHATEVER the other endpoint's kind — which is what wakes a sleeper on a kinematic platform, W3 protecting only members — and the deferred list is re-scanned while a pass wakes someone, so a five-deep sleeping stack wakes ENTIRELY in the impact tick with its internal contacts rebuilt, with NO persistent sleeping-group structure: the retained pair set (§1.7 step 2) IS the wake graph, which makes retention a correctness condition and not just warm-start persistence. Awake test written on MOTION, not on the flag (a static never carries `sleeping`). New `solveRangeReport` returning `VelocitySolveResult{iterations_run}` plus the velocity-pass EARLY-OUT at TRUE ZERO — bit-exactly equivalent, `solveRange` unchanged in signature. **The M1.1.7 record that Jolt derives the velocity budget per island is REFUTED on the source**: `PhysicsSettings.h` carries global defaults 10/2 and `CalculateSolverSteps.h` is a MAX OVER AUTHOR-SUPPLIED OVERRIDES falling back on them — no topology heuristic of any kind, a deep stack gets the global default. Measured early-out yield, reported as it came out: 11 of 16 at rest at f32, ZERO at f64 (the true-zero predicate fires only once `Δλ` underflows, so the yield is precision-dependent), 1 of 3 for the position pass at both precisions. Anti-masking MEASURED: at 8 velocity iterations with sleeping ON, the five-box stack sleeps at tick 118 while moving at 0.061 m/s — twice the threshold — and freezes its lateral offset at 0.0177 where the same stack left awake walks to 0.1737, so `allow_sleeping = false` on every convergence measurement is normative (`World.initNoSleep`), and the M1.1.7 claim that a 0.047–0.075 m/s residual was unmaskable is settled false. Eleven inherited envelope quantities re-measured against `main` at `8517d8d` through a worktree, both precisions: ZERO movement, digit for digit. RD-1: the wake envelope's clause 2 is REPLACED by a differential against a never-slept control (which fails the absolute form HARDER, warm cache, `misses = 0`) plus an extinction check; clause 1 passes with 3× margin and the woken island beats the awake control because `putToSleep` zeroed its velocities. RD-2: the NGS resting fixed point grows with chain length — PRE-EXISTING, exposed here, to characterise before joints. Documented limit: a rotation aliased to a whole turn per tick is invisible to any pose-sampled criterion (Jolt's tracked points alias identically), mitigated by the per-island AND. OUT (later): job-parallel islands and large-island splitting, per-body iteration override channel, `step()`/`PhysicsWorld`/`PhysicsModule`/ECS `Transform` sync and the production W4 wiring (M1.1.15), `PhysicsDebugProvider` and `IslandDebugInfo` (§1.10), joints (M1.1.16–18), queries (M1.1.9–10), CCD/speculative, `forge_2d`. | | `v0.11.9-queries-raycast` | 2026-07-26 | M1.1.9 — Queries: raycast | Tenth M1.1 sub-milestone — the first that INTERROGATES the world rather than advancing it. Normative model authored for it: `engine-physics-forge.md` §1.11, appended to the §1 series so nothing renumbers, plus §1.11.4 bis on far-field conditioning added mid-milestone. `Aabb(T).rayInterval` in `foundation/math` — slab interval, per-axis guard at TRUE ZERO falling back to origin-in-slab containment (the reference's absolute `1.0e-20f` parallel guard deliberately NOT reproduced), and an exact `NaN → 0` repair for the one NaN the product can make (`0 · inf`, a subnormal component against an origin on a face plane; unrepaired, `@min`/`@max` drop the NaN and the lane silently loses the box), under a finite-origin / non-zero non-NaN-reciprocal precondition a domain assert enforces. `Bvh(T).queryRay` + `Broadphase(T).queryRay` — branch and bound, additive on the `queryAabb` collector form, visited-node accounting identical (each node's slab test runs exactly once: the parent computes both children's intervals for ordering AND pruning). The collector contract gains TWO methods, non-interchangeable: `maxDistance()` re-read before every descent, and `shouldStop()` read before every descent, between the four layer trees and before each root — a zero bound still admits every node whose interval contains the origin and says nothing about trees not yet walked, so `raycastAny`'s advertised "terminates at the first candidate" was a spec/code divergence, found and closed here. Descent is NEAR-FIRST, pinned by counter-factual at two scales. New `pipeline/narrowphase/raycast.zig` — analytic ray↔core kernels on the core + inflation-radius convention, `fast_paths.zig` dependency discipline: sphere, box (a ROUNDED box fails loud, rejected BEFORE the membership test since a shape rejection cannot depend on where the ray starts), capsule (infinite cylinder ∩ `\|y\| <= h` then the two cap spheres, the `RayCapsule.h` decomposition). Solid convexes, boundary included: an origin inside is a hit at distance zero with normal `−direction`, the only choice preserving `normal · direction <= 0`. Both quadratics are written CANCELLATION-FREE — discriminant `r² − \|w\|²` from the perpendicular offset, hit point `w − √disc·d` — because `b² − c` and `o + t·d` each subtract near-equal large quantities: measured at f32, a radius-1 sphere 5 000 m away returned `t = 5000`, short by exactly the radius, with a normal of length ZERO. The returned normal is NORMALISED rather than divided by the radius, which makes its LENGTH a structural invariant (0–0.5 ulp of unit out to 1e9 radii, both precisions, sphere and capsule wall) while only its ORIENTATION carries the far-field residue: a degenerate normal is therefore always a defect and never an effect of distance, and the suite asserts the norm tight everywhere and bounds the orientation only inside the envelope where the scalar still resolves the geometry. What conditioning cannot recover is recorded in §1.11.4 bis and was MEASURED, not argued: the general quadratic in f64 on the same f32 inputs gives 0.999999999, the same in f32 gives 0.4999512, and correcting by `d · d` in f32 changes nothing because that factor itself rounds to one — the information is in the inputs and f32 arithmetic does not extract it; recovering it needs a compensated intermediate, which belongs to the milestone that owns precision. New `forge_3d/query.zig` — stateless `Real`-bound orchestration taking `(bp, bm, store)`: the shared `Filter` (32-bit OBJECT-layer mask + per-body exclusions, predicate specified per SUB-SHAPE so compounds extend it instead of rewriting it), the three collectors (`closest` tightens TO the hit distance so an exact tie still reaches the tie-break, `any` stops, `all` keeps the nearest `out.len` by replacing its worst so a truncated answer stays creation-order invariant), the exact-distance tie-break on the smaller `BodyId`, and the direction normalised ONCE by reduction by its largest component — component-wise division, never `1 / scale`, so neither `(1e20, 0, 0)` overflowing its square nor a denormal underflowing it can break it; the previous `d · d == 0` guard was a SILENT MISS for a usable denormal direction, and §1.11.4 was corrected to match. `BodyManager.raycastBody` transports by the inverse pose and does NOT re-normalise the local direction: a quaternion conjugate rotation costs a few ULPs, which the kernel's unit assert budgets, and re-normalising would cost a sqrt per body and mask real drift. The COMPLETE public query family FREEZES in `api/types.zig`, verbatim mirror of `engine-tier-interfaces.md` §1 — `PhysicsQueryFilter` (named so, distinct from §6 AIModule's `QueryFilter`), `RaycastQuery`, `ShapeCastQuery` (replaces `sphereCast`: one shape-handle cast serves sphere/box/capsule, the three Etch forms of §13 become wrappers), `OverlapQuery`, `RaycastHit` (+ `subshape_id`, through which the service derives `physics_material`; the solver result carries no material, the `CastResult.h` construction), `ShapeCastHit`, `ClosestPointResult` — because a comptime strategy interface cannot gain a method after its M1.1.15 freeze without breaking every Tier 3 solver; `raycastAny` and `overlapAabb` were added to the family for that reason (the first because Cortex line of sight reads only whether something blocked, the second because C1.1 lists an AABB query and `Bvh.queryAabb` already implements it). The five deferred entries keep those frozen signatures with NO error channel and `@panic` bodies naming M1.1.10 — `null` or `0` would be a lie in the caller's own vocabulary — pinned at comptime as a change detector on `api/types.zig`. `collision_layer_count = 32` becomes the domain's single source and `addBody` rejects beyond it with the typed `error.InvalidCollisionLayer` before any mutation, since the mask is 32 bits and such a body would be invisible to every query with no diagnostic. **Pre-existing defect exposed and corrected here:** `Body.rotation` is UNIT at the solver precision permanently — `addBody` normalises the widened descriptor rotation at BOTH sites (`rotation` and `sleep_ref_rotation`, one value, or the first window sweep reports a phantom displacement) under a descriptor domain assert; an f32-unit quaternion widened to f64 is off by `3.4e-8`, so the invariant held by accident for a dynamic body from its first tick and NEVER for a static or kinematic one, scaling a static collider's frame by `1 ± 3.4e-8`, i.e. 0.34 mm at 10 km, the regime `-Dphysics_f64` exists for; the M1.1.5 pin was re-expressed on the in-repo `position_solver_test` form (capture before the pass, bit-unchanged after), isolating the integration claim from the creation claim instead of mixing them. Eleven inherited M1.1.5–M1.1.8 envelope quantities re-measured against `main` at `d60b19b` in both legs and both precisions: ZERO movement, digit for digit. Bench `bench/forge_3d_raycast.zig` REPORTED, not gated: closest / any / all-32 / 5 m-bounded over 10 000 static bodies, plus a traversal-locality pair on the SAME rays — swept cell by cell against permuted, a stable ~1.4× penalty for losing spatial order; a first attempt permuted a randomly-aimed set and measured nothing, those rays having no spatial order to destroy. The bench runs under a leak-checking allocator with `safety` FORCED true: its default is `std.debug.runtime_safety`, false in ReleaseFast, so the check was vacuous and reported "no leaks" against a deliberately reintroduced leak. Fix-as-you-go outside the module: the four wall-clock latency assertions of `tests/ipc/crash_recovery.zig` are removed and every behavioural one kept — they measured kernel scheduling on a machine the suite loads itself, one fired on this branch, and a post-hoc assertion is not a hang guard since it runs only once the blocking receive has returned; the figures stay in `validation/s6-go-nogo.md`, the closed M0.7 plan line is NOT retro-patched, and the residual (no bounded receive in Tier 0 IPC, and the IPC test targets are built by a loop that does not wire `test_watchdog`) is recorded with its owner. Green at f32 AND `-Dphysics_f64=true`, debug AND ReleaseSafe. OUT (later, NOT debt): shapecast / overlap / point query / closest point bodies (M1.1.10, signatures frozen here); the f32→`Real` widening of the public surface, one decision over `BodyDescriptor`, the interface pose, the query results and the ECS `Transform` together (M1.1.15); Plane and MeshShape (M1.1.11), with which `error.UnsupportedShape` becomes reachable end to end, and back-face handling, vacuous on a solid convex until triangles; `step()`/`PhysicsWorld`/`PhysicsModule` and the Tier 1 `physics_query` service with its Etch wrappers (M1.1.15); the ECS authoring source of `collision_layer` (M1.1.13); a `treat_convex_as_solid` toggle (purely additive, a defaulted field with zero call sites); broad-layer query filtering (an additive optimisation, never a substitute for the object mask); `src/c-api/` (spec mirrored, service lands with its first plugin consumer). | | `v0.11.10-queries-shapecast-overlap` | 2026-07-27 | M1.1.10 — Queries: shapecast, overlap, point query, closest point | Eleventh M1.1 sub-milestone — the second and last that interrogates the world, and the one that replaces the five `@panic` bodies M1.1.9 froze. Normative model authored for it: `engine-physics-forge.md` §1.11.10 to §1.11.14, appended to the §1.11 series so nothing renumbers, plus a corollary at the end of §1.11.8 and a corrected `physics_overlap_aabb` comment in §13. **A shape cast is not expressible over the ray kernels**: the cast of A along `d` against B is a raycast against the Minkowski difference of the two CORES inflated by `r_a + r_b`, so a sphere cast against a box is a ray against a ROUNDED box — the one shape `rayShape` rejects. New `pipeline/narrowphase/shapecast.zig` (490 lines): the van den Bergen configuration-space ray walk, `GJKClosestPoint::CastShape` lineage, reusing `Simplex(T)`'s Voronoi solver and `support.zig` but never the `gjk()` loop (`epa.zig` dependency precedent, RD-2 records that the frozen brief's two clauses could not both be read literally). `A ⊖ B` and not `B ⊖ A` is deliberate: it is the difference `minkowskiSupport` already computes, so the `Vertex{w, support_a, support_b}` semantics and the `Σ λ_i · support_b_i` witness reconstruction carry over, at the cost of one negation on the direction. Because the support map covers every BOUNDED convex, the kernel has no shape to reject and needs no error channel — the frozen signature carrying none is evidence for the design rather than a constraint on it. **The restart budget is per ADVANCE, not per call**, and the literal reading was measurably wrong: for a POINT core the Minkowski difference is a single point, so every sample is a legitimate duplicate and a once-per-call budget made a closed-form sphere cast of 7 exit `restart_exhausted` at `6.952526`; refreshed at each advance — the reference's own placement, set immediately after the `x` shift with the comment that a rebuild is allowed once after `x` changes — the same scene converges `6.214835 → 6.952526 → 6.999764 → 7.000001`. Termination is a seven-variant `CastExit` mirroring §1.11.11's table exactly, neither padded nor truncated, with the zero-direction exit an eighth variant explicitly labelled as domain and fired before the walk. The named ceiling exhausts into a HIT at the current parameter, never a miss: the parameter grows from zero and is at every step a LOWER BOUND of the true time of impact, so a truncated answer is a contact announced early — the safe failure direction for the character controller — and the reference's two non-convergence exits do the same. The reference has no ceiling at all, which M1.1.14 forbids. RD-1: §1.11.11 as authored said the parameter "reaches or exceeds" `max_distance` is a miss, which contradicts its own closed-interval line three rows above and the brief's own required test; STRICT exceedance is implemented and the KB is patched, the reference's `>=` and its half-open interval deliberately not followed. Guards at TRUE ZERO throughout: the "no longer approaching" test never reaches its division, a denormal denominator overflows the step to infinity hence the parameter past the bound hence a miss through the existing test, and the numerator is strictly positive at that branch so no NaN is reachable — the reference's `-1.0e-18f` is not reproduced, same substitution as §1.11.2 against `RayAABox.h`. `Aabb(T).inflate` in `foundation/math` and `Bvh(T).queryCast` + `Broadphase(T).queryCast`: the swept traversal is additive on `queryRay` in the strict sense — same collector contract, same near-first descent, same visit accounting, `rayInterval` untouched — the only difference being that the node's stored box is inflated by the extent before the slab test, which is the exact Minkowski sum of two AABBs. `queryRay` becomes `queryCast` at a ZERO extent and must stay bit-identical; the `-0.0 + 0.0 = +0.0` sign flip is argued at the re-expression site over all five downstream consumers. A single swept AABB fed to the overlap traversal was REJECTED: it loses the bound entirely and turns a sub-linear query linear in the corridor's proxies. The ray starts at the CENTRE of the cast shape's initial world AABB, not at its position — the two agree only because the three stored shapes have origin-centred local boxes, a property of those shapes and not of the model. **The frozen solver-side signatures of the five entries moved to `Real`**, with mirror types `CastQuery` / `CastHit` / `OverlapRequest` / `ClosestPointHit` and `[]BodyId` outputs; `overlapAabb` gained `store`, without which its exact kernel is unreachable. An entry typed `f32` inside the solver would narrow the time of impact and the contact point before leaving the kernel and widen them again at the interface tier — two conversions, one invisible, and the loss of exactly what `-Dphysics_f64` buys. `engine-tier-interfaces.md` §1 and `api/types.zig` are UNTOUCHED: they are the frozen surface, wrapped in one place at M1.1.15 for all eight entries at once. The M1.1.9 pin had recorded that one of the two halves would have to move here or at the freeze. Doing so removed the last references to the public types, so the change detector would have gone SILENT while staying green until the freeze five sub-milestones away; it now pins `api/types.zig` field by field, which a field rename was observed to break. `query.zig` becomes the `query/` package — `root.zig` façade, `ray.zig` moved textually unchanged (git reads it as a rename; every line verified present verbatim in the pre-move file bar the eight imports the split imposes), `cast.zig`, `overlap.zig`. RD-3 records that `root.zig` at 555 lines breaks the brief's own Note, the frozen allocation of the eight entries to the façade being what produces it; kept against the two conscious overages already merged next door at 981 and 938. **`BodyId` cannot order a query result** and this was measured, not argued: it is a slot index, so it encodes creation order. On `main`, at f32, two unit spheres at `(20, ±0.5, 0)` against a ray from the origin along `+X` both return `19.133974` — bit-identical, the squared perpendicular offset being `0.25` either side, closed form `20 − √0.75` — and swapping the two creation orders changes the ENTITY returned, by `raycast` as much as by `raycastAll` truncated to one slot. The key becomes `(distance, entity, BodyId)` across the family and `(entity, BodyId)` for the three overlaps, written once as `keyLess` so the two families cannot drift, with `BodyId` surviving only as the final tie-break between two bodies of the same entity — a residual named in §1.11.14 rather than hidden, and pinned. **This supersedes the M1.1.9 record of a tie-break on the smaller `BodyId`**, which was exact when written; §1.11.6 asserted the same superseded key and was reconciled mid-milestone, the contradiction having been found by grep and not by a test. `BodyManager` gains `entity()` — the column existed since M1.1.0 and had never been exposed — and four stale-safe adapters returning WORLD space through `BodyCastHit` / `BodyClosestPoint`: `raycastBody` returns the body's local frame but `gjkPair` already returns world, two of the three named precedents disagree, and the cast kernel's native frame is A's, which is no body's. Distinct types rather than a quiet reinterpretation. §1.11.12 and §1.11.13 held at first contact with code: `overlapAabb` tests the TIGHT world AABB and never the leaf's fat box, since otherwise a tuning constant would change a query's answer, and the rejection is observed on the traversal by a counting collector rather than deduced from box arithmetic; `overlapShape` introduces NO threshold, its predicate being that the GJK regime is not `separated`; `closestPoint` measures distance to the SOLID with membership tested upstream of any classification, and `.shallow` is NOT an interior but a real separation absorbed by the numeric margin — a counter-factual reading it as one fails exactly the test written for it, and the probe is placed in ULPs of the coordinate scale because the band is a few ULPs wide by construction. **The `.deep` band was a defect and the external review found it:** GJK classifies `.deep` at `dist <= conv_k · floatEps(T) · coordScale` on the CORE distance, so for a hard core a point genuinely outside the solid lands there, and the entry answered distance 0 at the QUERIED POINT — an interior answer for a point exact membership had just placed outside, and a hit even at `max_distance == 0`. The comment defending it asserted that `.deep` means the cores intersect, which `gjk.zig` itself contradicts three hundred lines away (`A false-deep on a true near-touch, cores actually disjoint`) while documenting that in `.deep` the closest points are UNSPECIFIED — they are the zero vector, so using them answers the world origin. A justification the callee explicitly disclaims is the costliest defect class there is: it survives review by resembling an argument. Fixed inside what `.deep` does specify: the terminal simplex, whose vertices carry `support_b`, re-solved for its barycentrics and recombined — the reconstruction `shapecast.zig` already performs on the same data — then mapped out of A's frame. `closest_a` needed no regime split at all: a point core IS the queried point everywhere, which shortened the formula rather than lengthening it, and the three regimes now share one projection. The band is `16 · floatEps(T) · coordScale` and `coordScale` is RELATIVE geometry (`\|pos_b − pos_a\| + coreExtent(a) + coreExtent(b)`, the probe's extent being zero), so it does NOT grow with distance from the origin: constant at `5.211e-6` at f32, `9.706e-15` at f64, or 43.7 ULP of unit. What grows is `ulp(coordinate)`, which bounds the defect's REACHABILITY — five representable points fall strictly inside it at 1 m, none at 100 m or beyond, the first float off the face already clearing it. Unreachable is not absent, and both the first probe written for it and the reviewer's own had the same blind spot: a step sized as a fraction of the coordinate is already coarser than the band at 100 m. Two independent probes bracket the frontier identically, 32 ULP defective and 64 ULP correct. Initial contact returns distance 0 and the witness on B from the same loop, no EPA; `position = cast.origin` is REFUTED and the test asserts the cast origin is demonstrably outside the hit body, without which it could not tell the two rules apart. The domain assertion of §1.11.11 was honoured on `max_distance` alone: `shapeCast` and `closestPoint` asserted the bound and the other three asserted nothing, and handle resolution ran FIRST, so a stale handle short-circuited validation entirely and a NaN pose reached the kernel unremarked at the first call carrying a live one. All five now assert origin, direction and rotation finite and the rotation UNIT before touching the store, through shared `assertFiniteVec` (NaN caught with the infinities, `@abs(NaN) < inf` being false) and `assertUnitRotation`. Not cosmetic: these rotations serve as inverses BY CONJUGATION, and a conjugate inverts only a unit quaternion — the same class of defect M1.1.9 corrected on `addBody`, where an f32-unit quaternion widened to f64 was off by `3.4e-8` and scaled a static collider's frame. **`overlapAabb` rejects an INVERTED query box explicitly at the entry**, returning zero without traversing: a component with `min > max` denotes the empty set on that axis, hence the empty region. The first arbitrage here was wrong and was let through on reasoning rather than measurement — an inverted box was called a well-defined query with an empty answer, and it is not: the overlap predicate is written for well-formed boxes and accepts any body enclosing both bounds, so against a `[−2, 2]³` body the box `min = (1,1,1)`, `max = (−1,−1,−1)` returns ONE body, `min = (9,9,9)`, `max = (−9,−9,−9)` returns zero, and an inversion on two axes only returns one — the answer follows the amplitude and the axes of the malformation. An assertion would not do: it holds in debug only and would leave the answer arbitrary where the engine runs, on an entry that returns a `u32` with no error channel. The test is strict `>`, a DEGENERATE box being a legal region — a point, a slice — and the non-strict counter-factual takes down the face-inclusive test written in E6 for an unrelated reason. §1.11.12 carries the rule. `overlapAabb` is the only entry taking caller bounds; the other four build their own box, and `closestPoint`'s well-formedness depended implicitly on `max_distance >= 0`, now stated where the box is built. Bench `bench/forge_3d_shapecast.zig` REPORTED, not gated, ReleaseFast over the same 10 000-body grid as the raycast bench: sphere 1300.8 ns, box 1344.7, capsule 1244.0, shape overlap 231.5, and a point cast at radius 0 against a raycast on the SAME rays — 1314.1 against 827.1, a 1.59× cost isolated to the GJK walk since the traversal is bit-identically `queryRay`, with an identical 0.89 hit rate confirming the two paths agree on what they touch. Leak check proven in BOTH directions: a deliberate 4 KiB leak fires with `safety` forced true and reports "no leaks" with the default, the default being not a weaker check but one that reports success unconditionally. Eleven inherited M1.1.5–M1.1.9 envelope quantities re-measured against `main` at `dd7fa1f` through a worktree, both precisions: ZERO movement, digit for digit. A language audit run with a byte-wise accent class reported a clean tree over files that demonstrably contain French and was redone authoritatively — zero French prose, twelve verbatim spec citations counted. 306/306 green at f32 AND `-Dphysics_f64=true`, debug AND ReleaseSafe. OUT (later, NOT debt): analytic swept fast paths (the M1.1.4 pattern, owing a geometric-equivalence proof against the generic path); the EPA deepest point at a zero time of impact (additive, a defaulted field with zero call sites, gated behind a setting in the reference too); a bounded nearest-neighbour descent for `closestPoint` (additive, `max_distance` already bounds the caller's set); Plane and MeshShape (M1.1.11) — the support-map kernel extends to any BOUNDED convex so ConvexHull (M1.1.19) is nearly free, but an infinite half-space has an UNBOUNDED support map and a non-convex mesh needs a per-triangle traversal, neither free nor here; the f32→`Real` widening of the public surface, one decision over `BodyDescriptor`, the interface pose, the query results and the ECS `Transform` together (M1.1.15); far-field conditioning, characterised and not fixed (§1.11.4 bis); CCD and speculative contacts, which a shape cast is not; `step()`/`PhysicsWorld`/`PhysicsModule` and the Tier 1 `physics_query` service with its Etch wrappers, which owes the entity-level deduplication the solver deliberately does not do (M1.1.15); character controller (M1.1.12); compounds and `subshape_id` beyond the constant 0 (M1.1.20); `forge_2d`. | +| `v0.11.11-plane-halfspace` | 2026-07-30 | M1.1.11 — Forge 3D shapes: the infinite plane (half-space) | Twelfth M1.1 sub-milestone, and 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. New normative spec, appended so nothing renumbers: `engine-physics-forge.md` §1.11.15 (half-space internal model) and §1.11.16 (`subshape_id` as an opaque path decoded by the root shape, root in the LOW bits per `SubShapeID.h`, zero bits for a shape without sub-shapes, so the frozen `0` default survives and no sentinel is needed). Amended: §1.11.1 point 3 (queries visit the unbounded lists too), §1.11.3 (three narrowphase categories; where the refusal lives), §1.11.4 (the back-face bound is MeshShape, not the end of Phase 1 — MeshShape is itself Phase 1), §1.11.7 (fourth signature principle: a probe shape can be refused and the refusal is typed), §1.11.12, §2 (shape table reconciled to the twelve frozen `ShapeType` variants; `RotatedTranslated` recorded as absorbed by `CollisionShape.offset` / `rotation_offset` and will never exist). `engine-phase-1-criteria.md` C1.1's shape list carried nine of twelve and now carries all twelve; `engine-tier-interfaces.md` 0.3 to 0.4; `engine-c-api.md` gains `WeldQueryStatus` and loses a stale `raycast_all` sort comment. THE NARROWPHASE GAINS A TAXONOMY ABOVE THE SUPPORT MAP. A half-space has an UNBOUNDED support map, so GJK, EPA and the M1.1.10 cast kernel do not apply to it; the category is chosen BEFORE a shape becomes a `SupportShape`, and that conversion stops being a total function of the store and becomes an asserted precondition of the convex arm. `ShapeClass` has TWO variants deliberately (the mesh is the third) and every switch on it is exhaustive with no `else`, so M1.1.11.1 is a compile error at each site that owes a decision. The plane's kernels are analytic and CHEAPER than GJK: `sep = n·supportCore_B(−n) − r_b − d`, one support call, closed form, no iteration, no threshold, and the SIGN OF `sep` IS the classification, so §3's three-band regime does not apply and must not be copied in. The `− r_b` term is the failure mode of the whole shape: `support` returns the support of the CORE, so a sphere whose centre lies exactly on the plane penetrates by its radius, and a box-only suite would pass with the term missing — both forms are computed in the same test and the radius-free one is asserted to the refuted answer. AN UNBOUNDED AABB DOES NOT DEGRADE THE BVH, IT DESTROYS IT, measured on the structure: the centre of an infinite box is 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 best-cost-child descent degenerates, and the union propagates the infinity to the root after which every query visits every node. The bounded substitute box is REFUSED: the reference takes it (`PlaneShape.h`, default half-extent 1000 m) and its own class comment states that no collision is returned outside that box and that collision at its edge is inconsistent — a tuning constant that changes a query's answer, which §1.11.12 already refuses for the broadphase margin. Unbounded shapes therefore live OUTSIDE THE TREES in a per-layer flat list, and a half-space is never asked for a box: it is asked whether it overlaps one, which `Aabb(T).overlapsHalfSpace` answers exactly by the lowest corner along `n` read component by component, with no infinity and no constant (RD-1, tested against an enumeration of the eight corners on a box neither centred nor cubic, `distance` swept so each of the eight normal sign patterns sees both verdicts and the exact boundary). Slot indices are STABLE, retired slots are recycled LIFO, and ITERATION FOLLOWS THE INDEX — so after A, B, C, retiring A and inserting D iterates D, B, C. An earlier §1.11.15 sentence and four code sites called the list insertion-ordered; that was FALSE and is superseded: what M1.1.14 requires is that the order be a deterministic function of the operation sequence, which slot-stable LIFO satisfies exactly, 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. The bound on list length is the PEAK of simultaneously live slots per layer, not the live count and not the total ever created; the dense ordered list that would give O(live) is recorded with its trigger and NOT built, the peak being measured at 1 in every scene in the repository because a half-space forces a static body. Pair generation runs in BOTH directions, and omitting either makes the other silently wrong: a bounded proxy entering the moved log is crossed with the unbounded lists, and inserting an unbounded shape confronts the existing leaves — by PRUNING on the corner predicate rather than enumerating (RD-3), which cannot lose a pair because a node box is FAT and contains its descendants, so a body whose tight box later reaches the half-space must first escape its fat box and re-enter the moved log. `addBody` rejects a non-static body carrying a half-space by `error.ShapeMustBeStatic` — named on the INVARIANT so M1.1.11.1 reuses it for the mesh — ordered BEFORE any computation derived from a local AABB, which `computeSleepRadius` performs with no branch on body type. `local_aabb` and `unit_inertia` are NaN rather than `undefined`, and the reason is measured: with `undefined`, `computeSleepRadius(plane)` returned 5.2510e-13 at f32 and 6.4444e-104 at f64, finite and plausible and unnoticeable, and `std.debug.assert` is compiled OUT of ReleaseFast, the mode the benches run in. THE DATED UNREACHABILITY OF `error.UnsupportedShape` IS CLOSED BY MOVING THE REFUSAL, NOT THE DATE: the rounded-box latch inside `rayShape` becomes an asserted precondition and the error leaves the ray path entirely (32 lines of mechanism deleted across four files), while the two entries that take a caller-supplied shape handle gain an error channel that separates three outcomes a single `null` conflated ON `main` — a stale handle, an inadmissible probe, and a real miss. The six handle-free entries stay total and that absence is pinned by a named predicate rather than by one type equality. At an INITIAL OVERLAP the cast returns `−direction`, not `n`: all four kernels now agree, returning `n` broke outright the invariant `shapecast.zig` documents as the reason for its own fallback, and the outgoing-cast test did not exist because the suite only swept inward, where the old value satisfied the invariant by accident. Descriptor domain, asserted at creation: `normal` already unit, `distance` FINITE — a NaN distance produced two contradictory silent behaviours, measured, reporting contact for a sphere 1000 m outside while making the same shape invisible to the broadphase. Contact path: the supporting face in direction `−n` gives up to four core vertices, NO CLIPPING runs because a half-space is unbounded, and the returned position is the midpoint of the convex surface point and its projection so the position solver reconstructs both anchors without a special case; `feature_id` uses a FOURTH class tag `0xC000`, free on both halves, so disjointness from the four existing producer pairs is structural and asserted by mask rather than enumerated. A box dropped on a plane rests on four contacts with centre_y 0.495073940 at f32 and 0.495074006 at f64, penetration 0.004926056 and 0.004925994, just UNDER the slop where M1.1.7 RD-1 measured a box on a box just above — not a divergence, and the plane's `sep` is a dot product against a stored unit normal with no clipping behind it, so nothing pushes it either way. FAR FIELD, and §1.11.4 bis splits differently here: the contact normal is the STORED `n` returned verbatim, so length AND orientation are exact at any range and assert as bit equality, and the whole residue moves into `signedDistance` whose error grows like `floatEps(T)·abs(p)`. And a true-zero guard's exactness is FRAME-LOCAL and does not compose — a ray parallel to the boundary in WORLD against a rotated plane body arrives with a transported dot of exactly `−floatEps(Real)`, so the kernel correctly reports a crossing at 8.3886120e7 m at f32 and 4.5035996e16 m at f64; what rejects such a ray is the entry's finite `max_distance`, which §1.11.4 already requires, and NOT an epsilon the kernel would invent. Benches: both raycast and shapecast measured on their existing scenes and on the same scenes with one plane, in the same process back to back, and 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, no envelope registered for a quantity below the noise. The eleven inherited M1.1.5 to 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, corroborated by nine inherited test files being byte-identical to the tag. Leak check proven both ways: with `safety` forced true a deliberate 4 KiB leak printed LEAK DETECTED, and with the default the SAME leak printed no leaks. 356 forge tests green at f32 AND `-Dphysics_f64=true`, debug AND ReleaseSafe. Seven recorded deviations (RD-1 `overlapsHalfSpace` in foundation math, RD-2 `LocalHit` and `CastHit` moved to `support.zig` so the class-dispatching adapter returns ONE type, RD-3 pruning, RD-4 a sixth `BodyManager` adapter, RD-5 the harness, RD-6 the benches, RD-7 `broadphase.zig` 981 to 1224) plus B1, a delivery blocker whose root cause was a search narrowed to one directory reported as a negative. Out: everything MeshShape drags in, a back-face field with no consumer, the fourth ordering-key term, `ShapeStore` owned memory, the ECS authoring surface for plane and mesh (deferred together, the mesh variant needing an asset handle that does not exist before M1.6), the 2D symmetry (`PhysicsModule2D` freezes at M1.8.11), a bit-packed `SubShapeID` creator, and the M1.1.15 group. External review by Codex over four rounds; every finding verified against source before acting, and four sections authored by Claude.ai were refuted — by the code or by that review, none by re-reading. | ### Hotfixes (untagged) @@ -268,6 +269,36 @@ The `briefs/` directory is the source of truth for milestone state. The brief's old-protocol baselines, the first measurement compliant with the thermal-aware protocol is a more robust candidate than the inherited value. +### `pre-commit` lint scope is narrower than its reputation + +`zig build lint` caught four public constants carrying `//` where the project +requires `///`; the `pre-commit` hook could not have. The hook runs `weld-lint` +on STAGED FILES ONLY, so an unstaged change is invisible to it and a file staged +in one commit is invisible to the next commit's run. The tree-wide +`zig build lint` is what catches this class, and no hook runs it. Run +`zig build lint` on the tree before every gate signal, not the hook. + +### `failed command:` does not mean a command failed + +`zig build` prints `failed command: ...` for every `Run` step whose tests PASS +but write to stderr. Read on the Zig 0.16.0 source, not inferred: +`Build/Step/Run.zig:1540-1541` sets `result_failed_command` BEFORE the spawn +("If an error occurs, it's caused by this command:") and nothing clears it on +success; `compiler/build_runner.zig:1381` gates printing on a condition whose +own comment is "No matter the result, we want to display error/warning +messages.", satisfied by `result_stderr.len > 0` alone; line 1516 then prints +the field. Proven both ways at `v0.11.10`: `test-forge-3d` gives `306 pass` / +`success` with NO such line, and adding one `std.debug.print` to the first +passing test keeps `306/306`, `success` and exit 0 while the line APPEARS. +Seven emitters exist and existed before M1.1.11 — cook-consolidate, hot-reload, +the `rule r_*` corpus, plugin_loader, the EBNF harness, event drop-saturation, +and the `ref500` median. This supersedes the earlier characterisation of the +phenomenon as a macOS flake on plugin_loader / events / etch-cache: it is +neither spurious, nor macOS-specific, nor variable — it is deterministic, and +what varied was the length of the `tail` used to look at it. Judge a build on +`--summary all` step counts and the exit code, never on the presence of that +line, and never on a `tail`. + --- Last updated: 2026-07-27 diff --git a/bench/forge_3d_raycast.zig b/bench/forge_3d_raycast.zig index 832c51f..a60bb19 100644 --- a/bench/forge_3d_raycast.zig +++ b/bench/forge_3d_raycast.zig @@ -93,7 +93,12 @@ const Scene = struct { /// A 22 × 22 × 21 grid (10 164 cells, truncated to `n_bodies`) of alternating /// spheres / boxes / capsules, spaced 3 m apart — all STATIC, which is the scene a /// query cares about: the broadphase tree is built once and never moved. -fn buildScene(gpa: std.mem.Allocator) !Scene { +/// Build the bench scene. With `with_plane`, one static half-space `{ y <= 0 }` joins the +/// SAME scene — the M1.1.11 delta measurement. The grid starts at y = 0, so the plane is +/// genuinely in contact with its lowest layer; and an unbounded list has no box to prune +/// on, which is precisely why it is not in a tree, so EVERY ray is offered to it and the +/// exact kernel runs on every one. That is the worst case, and the honest one to report. +fn buildScene(gpa: std.mem.Allocator, with_plane: bool) !Scene { var scene = Scene{ .bp = Broadphase.init(.{}) }; const sphere = try scene.store.createShape(gpa, .{ .sphere = .{ .radius = 0.6 } }); const box = try scene.store.createShape(gpa, .{ .box = .{ .half_extents = av3(0.5, 0.5, 0.5) } }); @@ -128,9 +133,63 @@ fn buildScene(gpa: std.mem.Allocator) !Scene { } } } + if (with_plane) { + const plane = try scene.store.createShape(gpa, .{ .plane = .{ .normal = av3(0, 1, 0), .distance = 0 } }); + const id = try scene.bm.addBody(gpa, &scene.store, .{ + .shape = plane, + .body_type = .static, + .entity = .{ .index = n_bodies, .generation = 0 }, + }); + // The body is at the DEFAULT pose — identity rotation, origin position — so the + // world half-space IS the local one and no transport is needed. Stated rather than + // silently relied on: a posed plane needs + // `shape.halfSpace(...).transformed(rotation, position)`, which is what the test + // harness does. + _ = try scene.bp.insertUnbounded(gpa, .static, .{ .normal = Vec3r.unit_y, .distance = 0 }, id); + } return scene; } +/// One ray SELECTION MODE, timed on one scene — the M1.1.11 delta harness. +/// +/// One function rather than three copied loops, and both scenes measured through it in the +/// same process, back to back: a delta between two separate runs would carry the machine's +/// thermal drift and a differently compiled code path, which is not the quantity asked +/// for. The only difference between the two scenes is the unbounded list. +const RayMode = enum { closest, any, all }; + +fn timeMode( + mode: RayMode, + scene: *Scene, + origins: []const Vec3r, + directions: []const Vec3r, + checksum: *f64, +) i64 { + var buf: [32]query.RayHit = undefined; + var best_ns: i64 = std.math.maxInt(i64); + for (0..n_reps) |_| { + const t0 = nowNs(); + for (origins, directions) |o, d| { + const q = query.RayQuery{ .origin = o, .direction = d, .max_distance = 200 }; + switch (mode) { + .closest => if (query.raycast(&scene.bp, &scene.bm, &scene.store, q)) |hit| { + checksum.* += @floatCast(hit.distance); + }, + .any => if (query.raycastAny(&scene.bp, &scene.bm, &scene.store, q)) { + checksum.* += 1; + }, + .all => { + const n = query.raycastAll(&scene.bp, &scene.bm, &scene.store, q, &buf); + if (n > 0) checksum.* += @floatCast(buf[0].distance); + }, + } + } + const dt = nowNs() - t0; + if (dt < best_ns) best_ns = dt; + } + return best_ns; +} + const Measure = struct { name: []const u8, ns_per_ray: f64, @@ -173,7 +232,7 @@ pub fn main(init: std.process.Init) !void { std.debug.print("warning: build mode is {s}; absolute ns are only meaningful in ReleaseFast\n", .{@tagName(builtin.mode)}); } - var scene = try buildScene(gpa); + var scene = try buildScene(gpa, false); defer scene.deinit(gpa); std.debug.assert(scene.bm.count() == n_bodies); @@ -211,7 +270,7 @@ pub fn main(init: std.process.Init) !void { const t0 = nowNs(); for (origins, directions) |o, d| { const q = query.RayQuery{ .origin = o, .direction = d, .max_distance = 200 }; - if (try query.raycast(&scene.bp, &scene.bm, &scene.store, q)) |hit| { + if (query.raycast(&scene.bp, &scene.bm, &scene.store, q)) |hit| { hits += 1; checksum += @floatCast(hit.distance); } @@ -230,7 +289,7 @@ pub fn main(init: std.process.Init) !void { const t0 = nowNs(); for (origins, directions) |o, d| { const q = query.RayQuery{ .origin = o, .direction = d, .max_distance = 200 }; - if (try query.raycastAny(&scene.bp, &scene.bm, &scene.store, q)) { + if (query.raycastAny(&scene.bp, &scene.bm, &scene.store, q)) { hits += 1; checksum += 1; } @@ -250,7 +309,7 @@ pub fn main(init: std.process.Init) !void { const t0 = nowNs(); for (origins, directions) |o, d| { const q = query.RayQuery{ .origin = o, .direction = d, .max_distance = 200 }; - const n = try query.raycastAll(&scene.bp, &scene.bm, &scene.store, q, &buf); + const n = query.raycastAll(&scene.bp, &scene.bm, &scene.store, q, &buf); if (n > 0) { hits += 1; checksum += @floatCast(buf[0].distance); @@ -280,7 +339,7 @@ pub fn main(init: std.process.Init) !void { const t0 = nowNs(); for (inside, directions) |o, d| { const q = query.RayQuery{ .origin = o, .direction = d, .max_distance = 5 }; - if (try query.raycast(&scene.bp, &scene.bm, &scene.store, q)) |hit| { + if (query.raycast(&scene.bp, &scene.bm, &scene.store, q)) |hit| { hits += 1; checksum += @floatCast(hit.distance); } @@ -339,7 +398,7 @@ pub fn main(init: std.process.Init) !void { const t0 = nowNs(); for (swept_origins, swept_dirs) |o, d| { const q = query.RayQuery{ .origin = o, .direction = d, .max_distance = 200 }; - if (try query.raycast(&scene.bp, &scene.bm, &scene.store, q)) |hit| { + if (query.raycast(&scene.bp, &scene.bm, &scene.store, q)) |hit| { hits += 1; checksum += @floatCast(hit.distance); } @@ -358,7 +417,7 @@ pub fn main(init: std.process.Init) !void { const t0 = nowNs(); for (order) |ix| { const q = query.RayQuery{ .origin = swept_origins[ix], .direction = swept_dirs[ix], .max_distance = 200 }; - if (try query.raycast(&scene.bp, &scene.bm, &scene.store, q)) |hit| { + if (query.raycast(&scene.bp, &scene.bm, &scene.store, q)) |hit| { hits += 1; checksum += @floatCast(hit.distance); } @@ -370,6 +429,28 @@ pub fn main(init: std.process.Init) !void { } } + // --- M1.1.11: the cost of one half-space in the scene, REPORTED, never gated --- + // + // The same 10 000 rays, the same code, the same process — once against the grid alone + // and once against the grid plus one static half-space in the layer's unbounded list. + // No envelope is pre-registered: it is a measurement, and what it measures is that an + // unbounded list has no box to prune on, so the exact kernel runs on EVERY ray. + { + var scene_plane = try buildScene(gpa, true); + defer scene_plane.deinit(gpa); + std.debug.assert(scene_plane.bm.count() == n_bodies + 1); + std.debug.print("\n half-space delta (10k rays, same process, best of {d}):\n", .{n_reps}); + for ([_]RayMode{ .closest, .any, .all }) |mode| { + const without = timeMode(mode, &scene, origins, directions, &checksum); + const with = timeMode(mode, &scene_plane, origins, directions, &checksum); + const ns_without = @as(f64, @floatFromInt(without)) / @as(f64, n_rays); + const ns_with = @as(f64, @floatFromInt(with)) / @as(f64, n_rays); + std.debug.print(" {s: <18} {d: >9.1} ns -> {d: >9.1} ns delta {d: >8.1} ns ({d: >5.2}x)\n", .{ + @tagName(mode), ns_without, ns_with, ns_with - ns_without, ns_with / ns_without, + }); + } + } + const frame_ns: f64 = @as(f64, std.time.ns_per_s) / 60.0; std.debug.print("\nforge_3d raycast bench ({s}, {d} static bodies, {d} rays x {d} reps, best rep)\n", .{ @tagName(builtin.mode), n_bodies, n_rays, n_reps }); std.debug.print(" {s:<22} {s:>12} {s:>14} {s:>16} {s:>9}\n", .{ "mode", "ns/ray", "rays/s", "rays/frame @60Hz", "hit rate" }); diff --git a/bench/forge_3d_shapecast.zig b/bench/forge_3d_shapecast.zig index 1400db6..efbdf3a 100644 --- a/bench/forge_3d_shapecast.zig +++ b/bench/forge_3d_shapecast.zig @@ -91,7 +91,10 @@ const Scene = struct { /// 22 × 22 × 21 grid truncated to `n_bodies`, alternating spheres / boxes / capsules /// 3 m apart, all STATIC — which is the scene a query cares about, the tree being /// built once and never moved. -fn buildScene(gpa: std.mem.Allocator) !Scene { +/// With `with_plane`, one static half-space `{ y <= 0 }` joins the SAME scene — the +/// M1.1.11 delta measurement (see `bench/forge_3d_raycast.zig` for the reasoning: an +/// unbounded list has no box to prune on, so every query is offered it). +fn buildScene(gpa: std.mem.Allocator, with_plane: bool) !Scene { var scene = Scene{ .bp = Broadphase.init(.{}) }; const sphere = try scene.store.createShape(gpa, .{ .sphere = .{ .radius = 0.6 } }); const box = try scene.store.createShape(gpa, .{ .box = .{ .half_extents = av3(0.5, 0.5, 0.5) } }); @@ -126,6 +129,16 @@ fn buildScene(gpa: std.mem.Allocator) !Scene { } } } + if (with_plane) { + const plane = try scene.store.createShape(gpa, .{ .plane = .{ .normal = av3(0, 1, 0), .distance = 0 } }); + const id = try scene.bm.addBody(gpa, &scene.store, .{ + .shape = plane, + .body_type = .static, + .entity = .{ .index = n_bodies, .generation = 0 }, + }); + // Default pose ⇒ the world half-space is the local one, no transport needed. + _ = try scene.bp.insertUnbounded(gpa, .static, .{ .normal = Vec3r.unit_y, .distance = 0 }, id); + } return scene; } @@ -172,7 +185,7 @@ pub fn main(init: std.process.Init) !void { std.debug.print("warning: build mode is {s}; absolute ns are only meaningful in ReleaseFast\n", .{@tagName(builtin.mode)}); } - var scene = try buildScene(gpa); + var scene = try buildScene(gpa, false); defer scene.deinit(gpa); std.debug.assert(scene.bm.count() == n_bodies); @@ -230,7 +243,7 @@ pub fn main(init: std.process.Init) !void { .direction = d, .max_distance = 200, }; - if (query.shapeCast(&scene.bp, &scene.bm, &scene.store, q)) |hit| { + if (try query.shapeCast(&scene.bp, &scene.bm, &scene.store, q)) |hit| { hits += 1; checksum += @floatCast(hit.distance); } @@ -249,7 +262,7 @@ pub fn main(init: std.process.Init) !void { for (0..n_reps) |_| { const t0 = nowNs(); for (centres) |c| { - const n = query.overlapShape(&scene.bp, &scene.bm, &scene.store, .{ + const n = try query.overlapShape(&scene.bp, &scene.bm, &scene.store, .{ .shape = cast_sphere, .position = c, }, &out); @@ -281,7 +294,7 @@ pub fn main(init: std.process.Init) !void { .direction = d, .max_distance = 200, }; - if (query.shapeCast(&scene.bp, &scene.bm, &scene.store, q)) |hit| { + if (try query.shapeCast(&scene.bp, &scene.bm, &scene.store, q)) |hit| { hits += 1; checksum += @floatCast(hit.distance); } @@ -298,7 +311,7 @@ pub fn main(init: std.process.Init) !void { const t0 = nowNs(); for (origins, directions) |o, d| { const q = query.RayQuery{ .origin = o, .direction = d, .max_distance = 200 }; - if (try query.raycast(&scene.bp, &scene.bm, &scene.store, q)) |hit| { + if (query.raycast(&scene.bp, &scene.bm, &scene.store, q)) |hit| { hits += 1; checksum += @floatCast(hit.distance); } @@ -309,6 +322,87 @@ pub fn main(init: std.process.Init) !void { measures[5] = report("raycast (same rays)", best_ns, n_queries, hits / n_reps); } + // --- M1.1.11: the cost of one half-space in the scene, REPORTED, never gated --- + // + // The same queries, the same code, the same process — the grid alone against the grid + // plus one static half-space in the layer's unbounded list. Both measured here rather + // than across two runs, so the delta carries neither thermal drift nor a differently + // compiled path. Three entries, one per structure the list touches: a shape CAST (the + // swept traversal), a shape OVERLAP (the AABB traversal), and a POINT QUERY (the + // cheapest entry, where a fixed per-query cost shows up most clearly). + { + var scene_plane = try buildScene(gpa, true); + defer scene_plane.deinit(gpa); + std.debug.assert(scene_plane.bm.count() == n_bodies + 1); + std.debug.print("\n half-space delta ({d} queries, same process, best of {d}):\n", .{ n_queries, n_reps }); + + // A shape handle is PER STORE: the probe must be created in the store it is used + // against. Passing `scene`'s handle to `scene_plane`'s store resolved the same slot + // index to a DIFFERENT shape — the plane — and the entry answered + // `error.UnsupportedShape`, which is the E3 channel doing exactly its job on a + // caller mistake. Found by running it, not by reading it. + const plane_probe = try scene_plane.store.createShape(gpa, .{ .sphere = .{ .radius = 0.5 } }); + var out_ns: [2]f64 = .{ 0, 0 }; + // (a) sphere cast + for ([_]bool{ false, true }, 0..) |with, slot| { + const target = if (with) &scene_plane else &scene; + const probe = if (with) plane_probe else cast_sphere; + var best_ns: i64 = std.math.maxInt(i64); + for (0..n_reps) |_| { + const t0 = nowNs(); + for (origins, directions) |o, d| { + const q = query.CastQuery{ .shape = probe, .origin = o, .direction = d, .max_distance = 200 }; + if (try query.shapeCast(&target.bp, &target.bm, &target.store, q)) |hit| checksum += @floatCast(hit.distance); + } + const dt = nowNs() - t0; + if (dt < best_ns) best_ns = dt; + } + out_ns[slot] = @as(f64, @floatFromInt(best_ns)) / @as(f64, n_queries); + } + std.debug.print(" {s: <18} {d: >9.1} ns -> {d: >9.1} ns delta {d: >8.1} ns ({d: >5.2}x)\n", .{ + "sphere cast", out_ns[0], out_ns[1], out_ns[1] - out_ns[0], out_ns[1] / out_ns[0], + }); + + // (b) point query — the cheapest entry, so a fixed per-query cost is most visible. + var ids: [32]api.BodyId = undefined; + for ([_]bool{ false, true }, 0..) |with, slot| { + const target = if (with) &scene_plane else &scene; + var best_ns: i64 = std.math.maxInt(i64); + for (0..n_reps) |_| { + const t0 = nowNs(); + for (origins) |o| { + checksum += @floatFromInt(query.pointQuery(&target.bp, &target.bm, &target.store, o, .{}, &ids)); + } + const dt = nowNs() - t0; + if (dt < best_ns) best_ns = dt; + } + out_ns[slot] = @as(f64, @floatFromInt(best_ns)) / @as(f64, n_queries); + } + std.debug.print(" {s: <18} {d: >9.1} ns -> {d: >9.1} ns delta {d: >8.1} ns ({d: >5.2}x)\n", .{ + "point query", out_ns[0], out_ns[1], out_ns[1] - out_ns[0], out_ns[1] / out_ns[0], + }); + + // (c) world-AABB overlap — the entry whose exact kernel the half-space arm replaces. + for ([_]bool{ false, true }, 0..) |with, slot| { + const target = if (with) &scene_plane else &scene; + var best_ns: i64 = std.math.maxInt(i64); + for (0..n_reps) |_| { + const t0 = nowNs(); + for (origins) |o| { + const lo = o.sub(Vec3r.splat(1)); + const hi = o.add(Vec3r.splat(1)); + checksum += @floatFromInt(query.overlapAabb(&target.bp, &target.bm, &target.store, lo, hi, .{}, &ids)); + } + const dt = nowNs() - t0; + if (dt < best_ns) best_ns = dt; + } + out_ns[slot] = @as(f64, @floatFromInt(best_ns)) / @as(f64, n_queries); + } + std.debug.print(" {s: <18} {d: >9.1} ns -> {d: >9.1} ns delta {d: >8.1} ns ({d: >5.2}x)\n", .{ + "overlapAabb", out_ns[0], out_ns[1], out_ns[1] - out_ns[0], out_ns[1] / out_ns[0], + }); + } + const frame_ns: f64 = @as(f64, std.time.ns_per_s) / 60.0; std.debug.print("\nforge_3d shapecast/overlap bench ({s}, {d} static bodies, {d} queries x {d} reps, best rep)\n", .{ @tagName(builtin.mode), n_bodies, n_queries, n_reps }); std.debug.print(" {s:<26} {s:>12} {s:>14} {s:>18} {s:>9}\n", .{ "mode", "ns/query", "queries/s", "queries/frame @60Hz", "hit rate" }); diff --git a/bench/results/forge_3d_raycast.md b/bench/results/forge_3d_raycast.md index becae1a..abee430 100644 --- a/bench/results/forge_3d_raycast.md +++ b/bench/results/forge_3d_raycast.md @@ -3,16 +3,16 @@ - Build mode: ReleaseFast - Scene: 10000 STATIC bodies (spheres / boxes / capsules on a 3 m grid) - Rays: 10000 per rep, 10 reps, best rep reported -- Anti-DCE checksum: 10635397.257 +- Anti-DCE checksum: 24596700.872 | mode | ns/ray | rays/s | rays per 16.67 ms frame | hit rate | |---|---|---|---|---| -| closest | 746.3 | 1339944 | 22332 | 0.88 | -| any | 546.0 | 1831502 | 30525 | 0.88 | -| all (buffer 32) | 1532.4 | 652571 | 10876 | 0.88 | -| closest (5 m bound) | 386.4 | 2587992 | 43133 | 0.19 | -| closest (swept order) | 185.7 | 5385030 | 89750 | 1.00 | -| closest (swept, permuted) | 253.7 | 3941663 | 65694 | 1.00 | +| closest | 830.1 | 1204674 | 20078 | 0.88 | +| any | 575.7 | 1737016 | 28950 | 0.88 | +| all (buffer 32) | 1555.1 | 643045 | 10717 | 0.88 | +| closest (5 m bound) | 374.9 | 2667378 | 44456 | 0.19 | +| closest (swept order) | 184.7 | 5414185 | 90236 | 1.00 | +| closest (swept, permuted) | 239.0 | 4184100 | 69735 | 1.00 | **Reported, not gated.** No envelope is pre-registered: this is the first measurement of this path, and registering a bound before measuring its diff --git a/bench/results/forge_3d_shapecast.md b/bench/results/forge_3d_shapecast.md index 8128f07..7190e37 100644 --- a/bench/results/forge_3d_shapecast.md +++ b/bench/results/forge_3d_shapecast.md @@ -4,16 +4,16 @@ - Scene: 10000 STATIC bodies (spheres / boxes / capsules on a 3 m grid) — the raycast bench's scene, so the two tables are comparable - Queries: 10000 per rep, 10 reps, best rep reported -- Anti-DCE checksum: 14262579.523 +- Anti-DCE checksum: 19343205.471 | mode | ns/query | queries/s | queries per 16.67 ms frame | hit rate | |---|---|---|---|---| -| sphere cast | 1292.9 | 773455 | 12891 | 0.98 | -| box cast | 1343.7 | 744214 | 12404 | 0.98 | -| capsule cast | 1276.1 | 783638 | 13061 | 0.98 | -| shape overlap (buffer 32) | 292.3 | 3421143 | 57019 | 0.19 | -| point cast (radius 0) | 1452.3 | 688563 | 11476 | 0.89 | -| raycast (same rays) | 926.1 | 1079797 | 17997 | 0.89 | +| sphere cast | 1216.0 | 822368 | 13706 | 0.98 | +| box cast | 1253.3 | 797894 | 13298 | 0.98 | +| capsule cast | 1222.1 | 818264 | 13638 | 0.98 | +| shape overlap (buffer 32) | 233.1 | 4290004 | 71500 | 0.19 | +| point cast (radius 0) | 1249.2 | 800512 | 13342 | 0.89 | +| raycast (same rays) | 771.5 | 1296176 | 21603 | 0.89 | **Reported, not gated.** No envelope is pre-registered: this is the first measurement of this path, and registering a bound before measuring its diff --git a/briefs/M1.1.11-plane-halfspace.md b/briefs/M1.1.11-plane-halfspace.md new file mode 100644 index 0000000..4b9df5a --- /dev/null +++ b/briefs/M1.1.11-plane-halfspace.md @@ -0,0 +1,885 @@ +# M1.1.11 — Forge 3D shapes: the infinite plane (half-space) + +> **Status:** CLOSED +> **Phase:** 1 +> **Branch:** `phase-1/forge/plane-halfspace` +> **Planned tag:** `v0.11.11-plane-halfspace` +> **Dependencies:** M1.1.0 (shape store, `BodyManager`), M1.1.1 (broadphase BVH, pair generation), M1.1.2 (support maps, GJK cores), M1.1.3 (manifold and its `feature_id` producer classes), M1.1.6 (pair retention, contact cache), M1.1.9 (query family frozen, ray traversal, `containsPoint`), M1.1.10 (the eight query entries, the ordering key, `queryCast`) +> **Opened:** 2026-07-27 +> **Closed:** 2026-07-30 (code-complete, PR open; merge + tag are Guy's) + +--- + +# FROZEN SECTION + +*Produced by Claude.ai. Not modifiable by Claude Code outside a Claude.ai round-trip (cf. § Recorded deviations).* + +## Context + +Twelfth core sub-milestone of the M1.1 rigid arc, and the first that adds a shape whose geometry the existing narrowphase cannot express. Sphere, box and capsule have shipped since M1.1.0; the plan row for this milestone originally grouped the infinite plane with the static `MeshShape`, and the row has been **split**: `engine-phase-1-plan.md` now carries M1.1.11 (plane) and M1.1.11.1 (mesh) as separate sub-milestones. The two share only a taxonomy enum; the mesh half additionally carries a rigid-solver change (several constraints per body pair) and a manifold-quality change (internal edges) that the plane needs nothing of. Do not import mesh work into this milestone under any framing. + +The normative model was authored for this milestone and is `engine-physics-forge.md` **§1.11.15** (half-space internal model) and **§1.11.16** (`subshape_id` as an opaque path), appended to the §1.11 series so that nothing renumbers. + +Three things are settled here that are larger than one shape. + +First, **the narrowphase gains a taxonomy above the support map.** A half-space `{x : n·x ≤ d}` has an *unbounded* support map: it diverges in every direction but `−n`. GJK, EPA and the M1.1.10 cast kernel are all built on the support map, and none of them applies. The answer is not to weaken the support map but to choose the category *before* converting a shape into a `SupportShape` — so `supportShape` stops being a total function of the store and becomes an asserted precondition of the convex arm. The plane's kernels are then analytic and **cheaper** than GJK, not more expensive: one support call, a closed form, no iteration and no threshold. + +Second, **an unbounded AABB does not degrade the BVH, it destroys it.** Measured on the structure: 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 half-extents and the surface area are infinite, so the SAH cost is infinite at every candidate and the best-cost-child descent degenerates; and the union propagates the infinity to every ancestor up to the root, after which every node is infinite and every query visits every node, and §1.11.9's logarithmic property is gone. The finite substitute box is **refused**: the reference takes it (`PlaneShape.h`, default half-extent 1000 m) and its own class comment states the consequence — no collision is returned outside that box, and collision at its edge is *inconsistent*. That is a tuning constant that changes a query's answer, exactly what §1.11.12 already refuses for the broadphase margin. Unbounded shapes therefore live **outside the trees**, and a half-space is never asked for a box: it is asked a *predicate*, which it answers exactly. + +Third, **the refusal of an inadmissible probe shape moves to where a caller can cause it.** `error.UnsupportedShape` has two sites today: `createShape`, reachable and tested, and a rounded-box latch inside `rayShape`, reachable by nothing — recorded in `CLAUDE.md` as a dated unreachability whose date is this milestone. A plane does not make the kernel latch reachable, since a plane is never converted into a `SupportShape` at all. So the latch becomes an asserted precondition, and the typed refusal moves to the two query entries that take a caller-supplied shape handle. Those two gain an error channel (§1.11.7), which also separates three outcomes that a single `null` conflates **today**: a stale handle, an inadmissible probe, and a real miss. + +## Scope + +- **`src/modules/forge/api/types.zig`** — the `ShapeDescriptor.plane` payload: `normal: Vec3` (unit), `distance: f32`. A pre-freeze extension of the union, explicitly permitted by `engine-tier-interfaces.md` §1 ("les variantes sans payload le reçoivent à leur sous-milestone"). The **query family is not touched here**; the two signature changes below are separate and justified on their own. +- **`ShapeClass`** — a two-variant enum, `convex` and `half_space`, with an accessor on `Shape`. **Two variants, not three:** the mesh category arrives at M1.1.11.1, and every `switch` on the class must be exhaustive so that adding the third variant is a compile error at each site. That is the fail-loud behaviour that makes the addition safe; a `else =>` arm anywhere defeats it and is a review stop. +- **The plane record in the shape store** — `Shape` gains the half-space normal and distance. `local_aabb` and `unit_inertia` are **not valid** for a half-space; document that on the fields and assert the class in every reader (`computeSleepRadius`, `computeMotion`, `worldAabb`, `bodyAabb`). The stored normal is unit: apply the `Body.rotation` pattern verbatim — a domain assert on the descriptor plus normalisation at creation — so no call site ever re-normalises. +- **`addBody` rejects a non-static body carrying a half-space**, by a typed error, and the rejection is ordered **before any computation derived from a local AABB**. `computeSleepRadius` is called today inside the `Body` literal with no branch on body type; the ordering is normative, not stylistic (§1.11.15). +- **`pipeline/narrowphase/plane.zig`** (create) — the analytic kernels of §1.11.15's table: separation and normal against a bounded convex, ray, shape cast, solid membership, closest point, and the AABB corner predicate. Dependency discipline identical to `raycast.zig` and `shapecast.zig`: imports `foundation` and the sibling `support.zig` only — never `gjk.zig`, `epa.zig`, `manifold.zig`, `weld_forge`, `body*.zig`, `config.zig` or `broadphase.zig`. +- **The separation formula carries the inflation radius.** `sep = n · supportCore_B(−n) − r_b − d`. `SupportShape.support` returns the support of the **core**, radius excluded, and its own doc comment says so. Omitting `−r_b` places the contact at the **centre** of a sphere, whose core is a point. Write the term and write why. +- **The plane arm of the manifold generator** — the supporting face of B in direction `−n` gives up to four core vertices with stable local ids; each vertex whose projection lies below the plane is a contact point; **no clipping** runs, the half-space being unbounded. The returned position is the midpoint of the convex surface point (`core_vertex − r_b·n`) and its projection on the plane, matching §3's definition of `position`. The `feature_id` uses a **class pair the existing producers do not use** — the tagging is 2 bits per half and only four pairs are taken; read the table in `manifold.zig` before choosing, do not guess. +- **`pipeline/broadphase.zig`** — a flat, insertion-ordered list of unbounded shapes **per broad layer**, outside the trees; no hashed container. `Proxy` gains a discriminator between a tree proxy and an unbounded slot; read the existing type before shaping it. Three obligations, and each of the first two is silently wrong without the other: + 1. a bounded proxy entering the moved log is crossed with the unbounded lists of the layers it may pair with; + 2. **inserting an unbounded shape enumerates every existing leaf** of those same layers and emits those pairs — without this, a plane created after the bodies collides with nothing, and the defect is invisible in any scene that creates the plane first, which is every naively written test; + 3. `queryAabb`, `queryRay` and `queryCast` visit the unbounded lists as well as the four trees (§1.11.1 point 3, amended). +- **The two query entries that take a shape handle gain an error channel** — `shapeCast` and `overlapShape`, solver-side, and correspondingly in `engine-tier-interfaces.md` §1 and `engine-c-api.md` (already patched in the KB). The channel separates a stale handle, an inadmissible probe (unbounded, i.e. a plane) and a real miss. The six other entries take no handle and gain nothing. +- **The rounded-box latch in `rayShape` becomes an asserted precondition** and `error.UnsupportedShape` leaves the ray path: `raycastBody`, the three ray collectors' error latch, and the return types of `raycast` / `raycastAny` / `raycastAll`. No box is ever built with a non-zero radius, and a control that has never been seen to fire is a comment with syntax. If Weld ever gives boxes a convex radius, as the reference does, the kernel gains the case; it does not regain an error. +- **The plane arms of all eight query entries and of `collidePair`**, wired through `BodyManager`'s adapters (`raycastBody`, `castShapeBody`, `overlapShapeBody`, `containsPointBody`, `closestPointBody`) at `Real`. + +## Out of scope + +- **`MeshShape` and everything it drags in** (M1.1.11.1): the triangle core, a per-triangle acceleration structure, back-face handling, degenerate-triangle rejection, the mesh descriptor contract, per-triangle `subshape_id` values, **several contact constraints per body pair** (`ContactConstraint` identity, the warm-start cache key, island constraint ordering), active/internal edges, and per-triangle results in `raycastAll` with the fourth ordering-key term §1.11.14 would then need. None of it is here. A plane has no sub-shape, no back face, no internal edge, owns no memory, and produces one manifold per pair exactly as today. +- **A back-face mode field on the query structs.** §1.11.4 promises it for the triangle milestone, which is M1.1.11.1, not this one. Landing it here would add a field with no consumer. +- **Extending §1.11.14's ordering key.** Its fourth term is needed only when two hits can share `(distance, entity, BodyId)`, which requires sub-shapes. M1.1.11.1 owns that amendment. +- **`ShapeStore` owned memory** — `destroyShape` still frees nothing and does not need an allocator: a half-space is POD. The lifecycle change belongs to the mesh. +- **The ECS authoring surface** — `CollisionShape` / `ShapeParams` gain no plane variant. The mesh variant needs an asset handle that does not exist before M1.6, and shipping the plane variant alone into a POD union whose 48-byte size is pinned buys a layout change for half a feature. Both are deferred together, deliberately. +- **The 2D symmetry.** `shapeCast2D` keeps no error channel; `PhysicsModule2D` freezes at M1.8.11 and the asymmetry is handled there, not here. Do not touch `forge_2d`. +- **A bit-packed `SubShapeID` path creator.** §1.11.16 settles the **contract**; the mechanism waits for compounds (M1.1.20). A plane consumes zero bits and its `subshape_id` is 0. +- **Cylinder, TaperedCylinder, ConvexHull** (M1.1.19); **HeightField, Compound, MutableCompound, Empty** (M1.1.20). +- **`step()` / `PhysicsWorld` / `PhysicsModule` instantiation / ECS `Transform` sync / the public `f32` wrapper / widening `Real` on the public surface** (M1.1.15, one grouped decision). Nothing in production inserts a body into the broadphase today; the wiring is M1.1.15's. This milestone therefore proves its broadphase behaviour through the test harness, and must say so rather than imply a wiring that does not exist. +- Far-field conditioning beyond what §1.11.4 bis mandates: characterised, **not** fixed. +- Character controller (M1.1.12), sensors and triggers (M1.1.13), cross-platform determinism (M1.1.14), joints (M1.1.16–18), vehicle (M1.1.21), the deformable branch (M1.1.22–23). +- The Tier 1 `physics_query` service, its Etch wrappers, the entity-level deduplication it owes (§13), editor overlays, `src/c-api/`. + +## Specs to read first + +1. `engine-physics-forge.md` — **§1.11 in full**, and first the two sections authored for this milestone: **§1.11.15** (half-space internal model, including the kernel table and the two pairing directions) and **§1.11.16** (`subshape_id`). Then §1.11.1 to §1.11.14 including §1.11.4 bis, with attention to the amended §1.11.1 point 3, §1.11.3 (the three categories, and where the refusal lives), §1.11.7 (the fourth signature principle) and §1.11.12. Then §1.1, §1.2, §2 (the shape table, reconciled with the twelve frozen variants), §3 in full, §4, §13 in full, §17. +2. `engine-tier-interfaces.md` — §1 at **version 0.4**: the `ShapeDescriptor.plane` payload, the two `anyerror!` query signatures, and the `subshape_id` doc. Everything else in §1 is untouched. +3. `engine-c-api.md` — the `weld_service_physics_query.h` block: `WeldQueryStatus` and the two entries that now return it. Mirror only; no C code is written here. +4. `engine-phase-1-criteria.md` — C1.1, whose shape list now carries the twelve variants. +5. `engine-phase-1-plan.md` — the M1.1.11, M1.1.11.1, M1.1.12, M1.1.19 and M1.1.20 rows. +6. `engine-physics-forge-2d.md` — §7, for symmetry only. Nothing 2D is touched. +7. `engine-zig-conventions.md` — naming, unmanaged-first allocation, the `root.zig` convention, the comptime interface-check pattern, the file-length guideline. + +## Files to create or modify + +- `src/modules/forge/api/types.zig` — modify — the `ShapeDescriptor.plane` payload. +- `src/modules/forge/forge_3d/shape.zig` — modify — `ShapeClass`, the accessor on `Shape`, the half-space fields, the `createShape` arm with the unit-normal domain assert and normalisation, the `supportShape` precondition assert. +- `src/modules/forge/forge_3d/body.zig` — modify — class asserts in `computeSleepRadius` and `computeMotion`. +- `src/modules/forge/forge_3d/body_manager.zig` — modify — the typed rejection in `addBody`, ordered first; class asserts in `worldAabb` and `bodyAabb`; the plane arms of the five adapters and of `collidePair`. +- `src/modules/forge/forge_3d/pipeline/narrowphase/plane.zig` — create — the analytic kernels. +- `src/modules/forge/forge_3d/pipeline/narrowphase/root.zig` — modify — re-export. +- `src/modules/forge/forge_3d/pipeline/narrowphase/raycast.zig` — modify — the latch becomes a precondition; the error leaves the signature. +- `src/modules/forge/forge_3d/pipeline/narrowphase/manifold.zig` — modify — the plane arm and its `feature_id` class pair. +- `src/modules/forge/forge_3d/pipeline/broadphase.zig` — modify — the per-layer unbounded lists, the `Proxy` discriminator, the two pairing directions, the three query entries. +- `src/modules/forge/forge_3d/query/root.zig` — modify — the reshaped error set, the two entries that carry it, the six that do not, the plane arms. +- `src/modules/forge/forge_3d/query/ray.zig` — modify — the error latch removed from the three collectors. +- `src/modules/forge/forge_3d/query/cast.zig`, `.../overlap.zig` — modify — the probe admissibility check and the three-way outcome. +- `src/modules/forge/forge_3d/root.zig` — modify — re-exports and the comptime pin for the new suite. +- `src/modules/forge/forge_3d/tests/plane_test.zig` — create — the plane acceptance suite. +- `src/modules/forge/forge_3d/tests/raycast_test.zig` — modify — the frozen-signature pin updated field by field (**extended, never weakened**), the ray path's dropped error. +- `src/modules/forge/forge_3d/tests/shapecast_test.zig`, `.../overlap_test.zig` — modify — the three-way outcome, RED-first. +- `src/modules/forge/forge_3d/tests/broadphase_test.zig` — modify — the unbounded list, both pairing directions, query visiting. +- `src/modules/forge/forge_3d/tests/body_manager_test.zig`, `.../manifold_test.zig` — modify — the rejection, the class asserts, the plane manifold. +- `CLAUDE.md` — modify — the **stale `Current state` header block** (it still reads M1.1.10 code-complete, last tag `v0.11.9`, active branch `queries-shapecast-overlap`, while `main` is `v0.11.10`) and the §3.4 patch, in the closing gate, content supplied by Claude.ai. + +## Acceptance criteria + +### Tests + +RED-first on every pinnable behaviour. Every expectation is a **closed form computed in the comment above it**, never a value read back from the implementation. + +**`tests/plane_test.zig`** (create) +- `test "the stored normal is unit and the descriptor domain is asserted"` — a non-unit descriptor normal is rejected in the debug build; the stored normal is unit to a **tight** bound, independent of `distance` and of the body's distance from the origin. +- `test "separation against a sphere subtracts the radius"` — the discriminating test of the whole kernel: a unit sphere whose **centre** is exactly on the plane is penetrating by its radius, not touching. Assert the closed form, and assert that the radius-free form would give the refuted answer, so the test can tell the two apart. +- `test "separation against a box and against a capsule matches the closed form"` — three orientations each, at least one **not** axis-aligned. +- `test "the contact normal is the plane normal and is unit"` — asserted tight, with `normal · (b_centre − plane_point) ≥ 0` on every contact. +- `test "raycast against a half-space"` — a ray from outside crossing the plane, a ray from outside receding, a ray parallel to the plane from outside, and a ray parallel **inside**; the parallel guards are at true zero and each case has a closed-form answer. +- `test "a ray origin inside the half-space hits at distance zero"` — normal `−direction`, per §1.11.4. +- `test "solid membership includes the boundary"` — strictly inside, exactly on, and one ulp outside. +- `test "shape cast against a half-space"` — closed-form time of impact for sphere, box and capsule probes, plus a receding direction that misses and a grazing direction whose guard is at true zero. +- `test "closest point projects orthogonally"` — outside gives `n·p − d`, inside gives zero and the point itself. +- `test "overlapAabb answers by the corner predicate"` — a box entirely above, entirely below, and straddling; and a box that the predicate accepts for **each** of the eight normal sign patterns, so the per-component corner selection is exercised in all its branches rather than in the one the author happened to write. +- `test "a plane manifold puts a resting box on four contacts"` — count, positions and penetrations in closed form; ids distinct; the class pair disjoint from every existing producer, asserted by construction and not by inspection. +- `test "an oblique far-field configuration keeps a unit normal"` — the §1.11.4 bis obligation. The norm is asserted **tight and everywhere**; only an orientation quantity carries a distance-dependent bound, and that bound applies only inside the envelope where the scalar still resolves the geometry. + +**Rejection and class asserts** (`tests/body_manager_test.zig`) +- `test "a dynamic or kinematic body carrying a half-space is rejected"` — the typed error, for both body types, with the **ordering** proven: construct the case so that reaching the sleep-radius computation would itself fail, and show the rejection arrives first. +- `test "a static body carrying a half-space is accepted"`. + +**Broadphase** (`tests/broadphase_test.zig`) +- `test "a plane inserted after the bodies pairs with all of them"` — the direction that is silently absent if only the moved log is consulted. This test must be seen **red** before the insertion path exists. +- `test "a body inserted after the plane pairs with it"` — the other direction. +- `test "the layer pair matrix applies to unbounded shapes unchanged"` — including a layer combination the matrix forbids, asserting no pair is emitted. +- `test "queryAabb, queryRay and queryCast all visit the unbounded list"` — one case each, with a **discrimination guard** proving the plane was the only possible source of the result. +- `test "the tree is untouched by an unbounded shape"` — node count, height and every stored box finite after inserting a plane; no NaN reachable anywhere in the tree. + +**The three-way outcome** (`tests/shapecast_test.zig`, `tests/overlap_test.zig`, RED-first on `main`) +- `test "a stale shape handle is distinguishable from a miss"` — red on `main` today, where both return `null`. Assert the conflation first, then the fix. +- `test "an unbounded probe shape is refused, not silently empty"` — a plane handle passed as the probe of `shapeCast` and of `overlapShape`. +- `test "every other entry keeps its total signature"` — the six entries that take no handle carry no error; pinned so a later widening is a deliberate act. + +**Shared, in the suite that owns each entry** +- `test "the object mask filters"` and `test "exclusions are honoured"` — including a plane body, for each entry. +- `test "a sleeping body answers and stays asleep"` — with a plane in the scene. +- `test "the result is invariant under creation-order permutation"` — including the **identity** of the bodies returned, with the plane created first in one ordering and last in another. +- `test "two identical runs are bit-identical"`. + +The whole suite green at `f32` **and** under `-Dphysics_f64=true`, debug and ReleaseSafe. + +### Benchmarks + +- **No new bench file.** The plane's kernels are closed-form and constant-time; the quantity worth measuring is the overhead the unbounded list adds to pairing and to queries. Re-run `bench/forge_3d_raycast.zig` and `bench/forge_3d_shapecast.zig` on their existing scenes **and** on the same scenes with one plane added, and report the delta. **Reported, not gated.** No numeric envelope is pre-registered. +- Every inherited envelope quantity of M1.1.5 to M1.1.10 re-measured against `main` in both precisions, and the movement reported digit for digit. +- Any leak-checking allocator used must have `safety` **forced true** — its default is `std.debug.runtime_safety`, false in ReleaseFast. Prove the check binds by reintroducing a leak deliberately, once, and reporting that it fired. + +### Observable behavior + +- `zig build test-forge-3d` green, and green again with `-Dphysics_f64=true`. +- The benches run and print their measurements; the figures go in the closing notes. + +### CI + +- `zig build` clean, zero warnings, on the configured matrix +- `zig build test` green (debug + ReleaseSafe) +- `zig fmt --check` green +- `zig build lint` green +- `commit-msg` hook green on every commit of the branch +- The full forge suite green under `-Dphysics_f64=true` + +## Conventions + +- **Branch:** `phase-1/forge/plane-halfspace` +- **Final tag:** `v0.11.11-plane-halfspace` +- **PR title:** `Phase 1 / Forge / Shapes: infinite plane (half-space)` +- **Commit convention:** Conventional Commits (cf. `engine-development-workflow.md §4.3`) +- **Merge strategy:** squash-and-merge (cf. `engine-development-workflow.md §4.6`) + +## Notes + +**Threshold discipline.** Every guard on this shape is at **true zero**. There are exactly two: `n·dir` on the ray, and `n·dir` on the cast. A ray parallel to the plane starting outside is a miss; starting inside, solid membership has already answered before the division is reached. No geometric epsilon appears anywhere in `plane.zig`, and none is needed: the sign of the separation *is* the classification, exactly, with no accumulation to absorb — which is why §3's three-band regime does not apply here and must not be copied into it. + +**The inflation radius is the failure mode of this milestone.** `SupportShape.support` returns the support of the **core**. Sphere core is a point, capsule core is a segment, box core is the full box at radius 0. A separation written without `−r_b` is correct for a box and silently wrong for a sphere and a capsule, by exactly the radius — and a box-only test suite would pass. The sphere test above exists for that reason and may not be dropped or softened. + +**Why a half-space is not a `SupportShape.Core` variant.** Adding it there would make `support()` return an infinity, and every consumer of the support map — GJK's termination, EPA's expansion, the cast kernel's ray march — assumes a finite point. The failure would not be a compile error or a wrong answer at one call site; it would be a NaN appearing several modules away from the cause. The category is chosen upstream, and `supportShape` asserts its precondition. + +**Why the plane is asked a predicate and never a box.** `worldAabb` is a total function today and stays total *for convexes*; the half-space arm asserts rather than returning an infinite box, because an infinite box has no correct consumer. A box intersects `{x : n·x ≤ d}` if and only if its lowest corner along `n` does, and that corner is read component by component: `min_i` where `n_i ≥ 0`, `max_i` otherwise. Exact, eight branches, no infinity, no constant. + +**The two pairing directions are one obligation, not two.** Pair generation is driven by the **moved** proxies and rebuilds its output on every call. A test that creates the plane first and then the bodies exercises only direction 1; a test that creates the bodies first and then the plane exercises only direction 2. Both tests are required, and the second is the one a naive suite omits. A half-space being static, it never re-enters a moved log afterwards: its pairs are established once and carried by §1.7 step 2's retention rule. + +**Nothing in production inserts a body into the broadphase.** Verified on `main`: the only `insert` call sites are in tests; the wiring is M1.1.15's. State this plainly in the closing notes rather than describing behaviour the engine does not yet exhibit end to end. + +**`ShapeClass` has two variants on purpose.** The third arrives with the mesh. Every switch on the class must be exhaustive, so that M1.1.11.1 is greeted by a compile error at each site that needs a decision. An `else =>` arm converts that into a silent wrong answer and is a review stop wherever it appears. + +**Reference lineage, verified on source at `jrouwe/JoltPhysics@master`.** `Jolt/Physics/Collision/Shape/PlaneShape.h`: the plane is documented as an infinite shape whose collision is nevertheless bounded by a half-extent, defaulting to 1000 m, with the class comment stating that no collision is returned outside that box and that collision at its edge is inconsistent; `MustBeStatic()` returns true; `GetSubShapeIDBitsRecursive()` returns 0. `Jolt/Physics/Collision/ShapeCast.h`: the shape being cast is documented on the field itself as unable to be a mesh — the same probe-admissibility restriction this milestone makes typed. `Jolt/Physics/Collision/Shape/SubShapeID.h`: the id is a path whose **root** occupies the **low** bits, popped parents before children, each shape declaring how many bits it consumes — which is what makes §1.11.16's contract forward-compatible. Weld keeps the static-only invariant and the probe restriction, and refuses the bounded substitute box. Verify any further claim about the reference on the reference, never on a comment in this repository. + +**`subshape_id` stays 0, and its meaning changed.** §1.11.16 redefines the field as an opaque path decoded by the root shape. A half-space consumes zero bits, so the value is 0 and **is not read**. That is not the same statement as the M1.1.9/M1.1.10 wording ("0 while one shape is one body"), and the difference matters at M1.1.11.1. Grep for the old wording across source, `CLAUDE.md`, the C-API mirror and the spec, and report the remaining occurrences with their status. + +**File length.** `plane.zig` is a new file and has no excuse to approach the 500-line review guideline. `broadphase.zig` is already a conscious brief-driven overage; the unbounded-list addition must not be used to enlarge it further without saying so — if it does, that is a Recorded deviation, not a silent one. + +--- + +# LIVING SECTION + +*Maintained by Claude Code during the milestone. The FROZEN SECTION above is +immutable outside a Claude.ai round-trip; every contradiction found in it, and every +departure from it, is recorded here instead of corrected there.* + +## Recorded deviations + +### RD-1 — `Aabb(T).overlapsHalfSpace` lands in `foundation/math/aabb.zig` + +**Departure.** The FROZEN Scope assigns "the AABB corner predicate" to +`pipeline/narrowphase/plane.zig`, and `foundation/math/aabb.zig` is not in *Files to +create or modify*. The predicate is implemented in `aabb.zig`; `plane.zig`'s +`aabbOverlaps` is the named table row and delegates to it. + +**Why.** Two callers need the same eight branches: `plane.zig`, and +`pipeline/broadphase.zig`, which imports `foundation` ONLY and therefore cannot reach +`plane.zig`. Written twice, the two copies would drift, and the formula is normative +(§1.11.15). The placement follows an exact triple precedent — `surfaceArea` (M1.1.1), +`rayInterval` (M1.1.9) and `inflate` (M1.1.10) each landed in `aabb.zig` under the +first-consumer rule for this same sharing. It is pure box geometry: no threshold, no +constant, no physical semantics. + +**Obligations discharged.** The doc states the CLOSED half-space `{ x : n·x <= d }`, +and the eight-sign-pattern test lives beside the predicate in `aabb.zig`, checked +against an ENUMERATION of all eight corners rather than a restatement of the closed +form. Approved by Guy at the E3→E4 gate. + +### RD-2 — `LocalHit` and `CastHit` move to `pipeline/narrowphase/support.zig` + +**Departure.** `support.zig` is not in *Files to create or modify*. The two result +types move there from `raycast.zig` and `shapecast.zig`, which now alias them. + +**Why.** `plane.zig`'s dependency discipline — mandated by the FROZEN Scope — admits +`foundation` and `support.zig` only, so it cannot import `raycast.zig` or +`shapecast.zig`. And the `BodyManager` adapters that dispatch on shape CLASS must +return ONE type: `raycastBody` cannot return a convex `LocalHit` on one arm and a +structurally identical plane type on the other. Putting the shared vocabulary in the +package's shared geometry base is what makes both constraints hold at once. A pure +relocation, same class as the M1.1.3 promotion of `narrowphase.zig` to a package. + +### RD-3 — pairing direction (2) PRUNES on the corner predicate + +**Departure.** The FROZEN Scope says "inserting an unbounded shape **enumerates every +existing leaf** of those same layers and emits those pairs". The implementation +descends the layer's tree pruning on `Aabb.overlapsHalfSpace`, so it emits pairs only +for the leaves the half-space actually meets. + +**Why.** §1.11.15 states the broadphase role of an unbounded shape as the PREDICATE — +"not «give me your box» but «do you meet this box»" — and pruning is that predicate +applied where it belongs. It cannot lose a pair: the stored box is FAT, so it contains +the tight box, which contains the body; a subtree the half-space does not meet holds +nothing it could touch. What it avoids is emitting a pair for every body in the scene +against a ground plane it is 10 km above, which the narrowphase would then reject every +tick. A body descending toward the plane still acquires its pair the moment it exits +its fat AABB and re-enters the moved log — the same hysteresis the tree pairs have. + +The brief's requirement is read as the COVERAGE (every leaf considered), which the +descent satisfies; the enumeration is its mechanism, and the refinement is recorded +rather than taken silently. The acceptance test the brief demands is unaffected: its +bodies all straddle the plane, so both mechanisms emit the same four pairs. Its +discrimination was proven by counter-factual — disabling direction (2) fails exactly +that one test. + +### RD-4 — a SIXTH `BodyManager` adapter, `aabbOverlapsBody` + +**Departure.** The FROZEN Scope names "the plane arms of all eight query entries and of +`collidePair`, wired through `BodyManager`'s adapters (`raycastBody`, `castShapeBody`, +`overlapShapeBody`, `containsPointBody`, `closestPointBody`)" — five. + +**Why.** `overlapAabb`'s collector called `bodyAabb` on every candidate. Correct for a +bounded convex; on a half-space it hits the class assert, and in ReleaseFast — where +that assert is compiled out — it falls through `worldAabb`'s `unreachable`. Exposed at +E2, sequenced to E5 by Guy, and closed here. The dispatch lives in `body_manager.zig` +beside the other five rather than in the collector, so all class dispatch stays in one +file and `query/overlap.zig` needs no half-space transport. MEASURED: reverting the +collector to `bodyAabb` panics at `body_manager.zig:481` in two tests. + +### RD-5 — the test harness `tests/solver_test.zig` is modified + +**Departure.** Not in *Files to create or modify*. + +**Why.** `harness.World.addBody` calls `bm.bodyAabb(...).?` before inserting a proxy, so +a plane body panicked there and the eight query entries could only be exercised at BODY +grain. Guy sequenced the harness change to E5 as item 7, precisely to unblock entry +grain. `addBody` dispatches on the class and step (10) skips unbounded proxies, which +cannot move. + +### RD-6 — changes outside the file list, in the two benches and one narrowphase doc + +`pipeline/narrowphase/shapecast.zig` — doc only: the rounded box is a precondition now, +not an error (E3). + +`bench/forge_3d_raycast.zig` / `bench/forge_3d_shapecast.zig` — `try` added or removed at +the call sites of the reshaped entries (E3, forced by the signature change), then at E7 +`buildScene` gains a `with_plane` parameter and each bench gains a delta block. The +FROZEN Benchmarks section MANDATES the second measurement ("on their existing scenes AND +on the same scenes with one plane added"), so the edit is required; the two files are +simply absent from the file list. + +`bench/results/forge_3d_raycast.md` / `bench/results/forge_3d_shapecast.md` — the two +benches' committed output, regenerated by running them. Not edited by hand; they are the +record the FROZEN Benchmarks section asks for. + +`src/modules/forge/forge_3d/rigid/contact_cache.zig` — doc only, and required by §1.11.16: +`CacheKey.subshape_id` carried the stale "reserved at 0 for the Compound/Mesh co-design" +wording, which the new section supersedes (an OPAQUE PATH decoded by the root shape, zero +bits for a shape without sub-shapes). It is one of the two sites the item-3 grep found — +the other, `api/types.zig`, is in the file list. Leaving it would have left the repo +asserting a superseded model of a field this milestone's shape is the first to make +meaningful the absence of. + +**Two files in the list were NOT touched, and neither needed to be.** +`src/modules/forge/forge_3d/query/cast.zig` — the list assigns it "the probe admissibility +check and the three-way outcome", but the eight query entries live in the `query/root.zig` +façade (RD-3 of M1.1.10 recorded that allocation), so `shapeCast`'s admissibility landed +there and `cast.zig` holds only the collectors, which the reshape does not reach. +`src/modules/forge/forge_3d/tests/manifold_test.zig` — assigned "the plane manifold", which +was written in `plane_test.zig` instead: the plane suite owns the shape's manifold tests +next to its kernels, and splitting them across two files would have separated the +`feature_id` disjointness proof from the producers it names. + +### RD-7 — `broadphase.zig` grows from 981 to 1224 lines + +The FROZEN Notes require this to be said rather than taken silently: "`broadphase.zig` +is already a conscious brief-driven overage; the unbounded-list addition must not be +used to enlarge it further without saying so." + +It is said. +243 lines, of which the substance is `Bvh.queryHalfSpace` with its +recursive half (~30), the `ProxyKind` / `Proxy` / `UnboundedShape` / `UnboundedSlot` +declarations and the three per-layer arrays (~60), `insertUnbounded` with its LIFO reuse +(~30), the two `remove` / `update` dispatches (~25), pairing direction (1)'s inner loop +and direction (2) whole (~35), and `visitUnbounded` plus the `queryCast` list walk (~30); +the rest is doc. The last 48 of those lines are J3's free-list, added at E7 and counted +here rather than left to be inferred. + +**No split was made, and the reason is the brief's own.** The FROZEN Scope places the +per-layer unbounded lists in this file, and the file's stated dependency discipline — +`foundation` math ONLY — is what makes the multi-layer aggregate and its structures one +unit. Splitting `Broadphase(T)` from `Bvh(T)` would be a restructuring of a file the +milestone was told to modify, not to reorganise; it belongs to whoever next opens this +file with that as its purpose. + +## Blockers encountered + +### B1 — the amended §1.11.15 (RESOLVED at E7) + +H3 of the E5 prompt requires aligning `plane.zig` and `plane_test.zig` on three new +paragraphs of §1.11.15 (guard frame-locality, the `sep/ulp` traversal with both +measurements, the half-space conditioning decomposition), and states that the +re-uploadable file accompanies the message. **No file was attached.** The normative +wording is therefore not in hand, and it is not invented here. + +Everything the three paragraphs describe is already implemented, measured and asserted +— the frame-locality finding and its two figures are in `plane_test.zig`'s grazing test +(corrected under H2), and the conditioning decomposition is in `plane.zig`'s header and +the far-field test. What is pending is only the ALIGNMENT of that prose on the KB's +formulation. + +**RESOLVED at E7, and the fault was the search, not the delivery.** The amended file is +`Devlab/weldengine/weld-spec/engine-physics-forge.md` — 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/`, which is where every +previous milestone's spec bundle had been, and concluded "not attached" from the absence +there rather than from a search of the tree. The conclusion was wrong even though each +individual check was accurate: the right move was to widen the search, not to report a +negative from a narrow one. + +`plane.zig` and `plane_test.zig` are now aligned on that formulation — the +non-composition of a true-zero guard 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, and the conditioning decomposition that moves the +whole far-field residue into `signedDistance`. + +**Superseded record, kept for the trail.** At the E4→E5 and E5→E6 gates the file at +`Downloads/M1.1.11-plane-halfspace/engine-physics-forge.md` is BYTE-IDENTICAL to the copy +read at E1 — 1663 lines, md5 `2d4ae6f45eb4ce46e27c33e9648fe47d`, §1.11.15 spanning lines +660–707 and still ending on "Sous-shapes" — and none of the three topics (guard +frame-locality, the `sep/ulp` traversal, the half-space conditioning decomposition) +appears anywhere in it. Carried again. + +## Closing notes (E7) + +### I1 — the bit-agreement, exercised rather than pleaded + +E6 claimed that not copying the generic generator's `keep_eps` makes +`overlapShapeBody`'s exact `separation(...) <= 0` and `collidePlane`'s per-vertex +`sep <= 0` agree to the bit. Exercised on 15 configurations that BRACKET the contact — +box, sphere and capsule, each at its resting height plus one ULP, exactly at it, one ULP +below, and two clear controls — with `nextAfter` walking f32's actual representable +neighbours. **No divergence at either precision**, and the sample straddles: 6 separated, +9 touching, both verdicts for every shape. No topological-flip band was observed 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.subshape_id`, and the frozen-family test +comment), `query/root.zig` (the three mirror types `RayHit` / `CastHit` / +`ClosestPointHit`), and `rigid/contact_cache.zig` — the last being the one that said +"Sub-shape INDEX", which is precisely what §1.11.16 refutes. + +Two occurrences of the phrase remain in source, both deliberate: `api/types.zig` quotes +the superseded wording in order to name what it replaces, and `query/root.zig`'s header +says the layer PREDICATE degenerates to per-body while one shape is one body — a +different claim, about the filter and not about the field, and still true. + +The other 29 are not patched, by rule: the field-type pins in `raycast_test.zig` / +`shapecast_test.zig` carry no semantic claim, and the CLAUDE.md tag rows and the frozen +briefs of M1.1.6 / M1.1.9 / M1.1.10 are CLOSED RECORDS — the same rule that kept the M0.7 +plan line un-patched at M1.1.9. + +### Item 4 — the bench delta, REPORTED and below the noise floor + +Both benches gained a `with_plane` scene and a delta block measuring both scenes in the +SAME process, back to back — a delta across two runs would carry thermal drift and a +differently compiled path. + +`forge_3d_raycast`, ReleaseFast, 10 000 rays, five runs of the same binary: + +| mode | baseline ns/ray | deltas observed (ns) | +|---|---|---| +| closest | 758.8 – 794.0 | +1.5, −3.5, +16.2, +4.6, +27.9 | +| any | 552.4 – 582.0 | +11.2, −7.7, +15.1, +0.7, +38.2 | +| all (buffer 32) | 1490 – 1585 | +25.2, −21.2, −10.4, −33.3, −14.4 | + +`forge_3d_shapecast`, ReleaseFast, 10 000 queries, three runs: + +| entry | baseline ns/query | deltas observed (ns) | +|---|---|---| +| sphere cast | 1170 – 1209 | +15.1, +11.9, −7.2 | +| point query | 5.1 | 0.0, −0.1, 0.0 | +| overlapAabb | 6.3 – 6.9 | −0.5, 0.0, −0.1 | + +**The conclusion is the repeatability, not the mean: the cost of one half-space in a +per-layer list is below this bench's run-to-run noise, and its SIGN is not stable** — +every mode shows both signs across runs. That is the honest report, and it is +informative: one list entry, filtered by a dot product, is not measurable against a +10 000-proxy tree traversal. No envelope is pre-registered, and none should be for a +quantity below the noise floor. + +Note on the two cheap entries: the bench's query origins sit outside the grid at +`z = −20` with `y` uniform in `0..63`, so the point query's degenerate box and the small +overlap box are mostly ABOVE `{ y <= 0 }` and the corner predicate rejects them before any +kernel runs. Their ~0 ns delta is therefore the cost of that predicate, which is what it +should be. + +### Item 5 — the eleven inherited envelope quantities + +Re-measured against `main` at `v0.11.10-queries-shapecast-overlap` (`712e4b5`) through a +git worktree, with the SAME probe file compiled in both trees, at both precisions. +**ZERO movement, digit for digit, on all eleven:** + +| # | quantity | f32 | f64 | +|---|---|---|---| +| E01 | free-flight `y` after 60 ticks | −4.901912000 | −4.901912602 | +| E02 | free-flight `v_y` | −9.564696000 | −9.564699950 | +| E03 | resting box centre `y` | 0.994999470 | 0.995000000 | +| E04 | resting penetration | 0.005000532 | 0.005000000 | +| E05 | velocity iterations run at rest | 2 | 3 | +| E06 | position iterations run at rest | 3 | 3 | +| E07 | NGS `min_separation` | −0.005000591 | −0.005000000 | +| E08 | resting lateral drift `x` | 8.0256726e-8 | 8.126694489691235e-8 | +| E09 | far-field hit distance at 10 km | 9999.000000 | 9999.000000 | +| E10 | far-field normal norm error | 0 | 0 | +| E11 | symmetric-tie distance / entity | 19.133974000 / 0 | 19.133974596 / 0 | + +Corroborating structural evidence: of the inherited test files, `integration_test`, +`position_solver_test`, `island_test`, `sleep_test`, `gjk_test`, `epa_test`, +`epa_robustness_test`, `manifold_test` and `fast_paths_test` are BYTE-IDENTICAL to the +tag, and the only inherited file this milestone touched — `solver_test.zig`, the harness — +has no changed line containing a digit. + +### Item 6 — the leak check, proven in BOTH directions + +With `safety` FORCED true and a deliberate 4 KiB allocation never freed, the raycast bench +printed `LEAK DETECTED: the bench leaked memory`. With the SAME leak and +`DebugAllocator(.{})` — the default, whose `safety` is `std.debug.runtime_safety`, false +in ReleaseFast — it printed `allocator: no leaks`. The default is not a weaker check; it +reports success unconditionally. Both edits reverted. + +### I2 — the pre-commit hook's scope is narrower than its reputation + +`zig build lint` caught four public constants carrying `//` instead of `///` in +`manifold.zig`. The `pre-commit` hook runs `weld-lint` on STAGED FILES ONLY, so it would +not have seen them at all — 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 it is not run by any hook. Recorded here; the CLAUDE.md *Safeguards* entry +is Guy's to supply at the second phase of E7, as he stated. + +### J1 — the cast normal at an initial overlap is `−direction`, in all four kernels + +`castShape`'s initial-overlap branch returned `plane.normal`, which is the outward normal of +the solid the caster is ALREADY inside. Every other kernel returns `−direction` there +(`support.zig`'s `CastHit` doc states the rule, `raycast.zig` applies it at three sites, +`shapecast.zig` at its zero-parameter exit), and §1.11.4's reason is the invariant +`normal · direction <= 0`: for a cast that starts inside, `plane.normal · direction` is +POSITIVE exactly when the cast is heading out of the solid, which is the ordinary case, so +the old value violated the invariant it was supposed to serve. + +No information is lost, and the site now says why: the plane's normal is not a datum the +caller can only learn from the hit. It is the shape's own constant, reachable from the same +handle the caller passed in, and `closestPoint` returns it for any probe. What `−direction` +carries instead is the one thing specific to this hit — that the sweep began inside, so the +solver must not use the reported normal to separate along the surface. The witness point +keeps the geometry: `deepest − n·sep0` is the deepest point projected onto the plane, so the +depth is recoverable as `(point − deepest) · n`. + +Three tests, RED-first, and the OUTWARD one is the case that did not exist before — the +suite only ever cast a probe from inside towards the interior, where the old value happened +to satisfy the invariant. Restoring `plane.normal` fails exactly those three +("an outward cast from inside a half-space reports minus-direction, not the plane normal", +"the four kernels agree on the normal at a zero parameter", +"castShapeBody transports the normal without disturbing the invariant") and nothing else. + +`castShapeBody` was checked at the source and does NOT retouch the normal: it rotates it by +the body's world rotation, which is the identity for the plane cases and a pure rotation in +general — norm-preserving and sign-preserving, so the invariant survives transport. The fix +is local to the kernel. Committed alone (`34ac5eb`), ahead of every measurement, as +required. + +### J2 — `isFinite` on the descriptor's plane distance + +The unit-normal assert had no companion on `distance`, so a NaN distance was accepted at +creation and then read by two consumers that disagree about it. MEASURED, identically at +both precisions: `collidePlane` reported CONTACT (`count == 1`) for a unit sphere 1000 m +OUTSIDE the solid, because `sep > 0` is FALSE when `sep` is NaN and the `continue` never +fires; while `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 — which is why the test names both consequences rather than +asserting a panic message. + +`HalfSpace.assertUnit` became `assertDomain` (unit normal AND finite distance, seven call +sites), and `buildShape`'s plane arm asserts `isFinite(p.distance)` beside the existing +normal check. Both halves bit: removing the descriptor-side assert panics at `shape.zig:298` +on the unit-normal line for a NaN normal and reaches the kernel for a NaN distance; removing +the kernel-side one panics at `plane.zig:139`. + +### J3 — a LIFO free-list for retired unbounded slots + +`remove` cleared a slot's `live` flag and left it in the list forever, so the per-layer list +was bounded by the TOTAL EVER CREATED and not by the live count. MEASURED with the same +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` +field carries the next-free index (the `Bvh` precedent 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: 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 — the same exposure the `Bvh` free-list accepted, for the +same reason. + +Two tests, both RED against the pre-J3 code: "the unbounded list is bounded by the LIVE +count, not by the total ever created" (list length asserted at EVERY one of 64 steps, then +that eight live shapes occupy nine slots and a ninth insertion after a free reuses rather +than appends) and "a retired unbounded slot never surfaces in a pair after its index is +reused" (logged, freed, reused before `computePairs` — zero pairs carrying the dead payload, +exactly one carrying the live one). + +### J4 — the back-face bound, aligned by measurement + +§1.11.4's bound is now "no setting before MeshShape (M1.1.11.1)". Six mentions of a back +face exist in the repo, ZERO of them carry the old formulation, and none is patched: + +| Site | Status | +|---|---| +| `CLAUDE.md:68` (M1.1.9 tag row) | Closed record, not retro-patched. Reads "vacuous on a solid convex until triangles" — already the new bound in other words. | +| `briefs/M1.1.9-queries-raycast.md:40` | Closed brief, not retro-patched. Names M1.1.11 as the owner, claims no Phase-1 bound. | +| `briefs/M1.1.10-queries-shapecast-overlap.md:46` | Closed brief, not retro-patched. Same wording. | +| `briefs/M1.1.10-queries-shapecast-overlap.md:1303` | Closed brief, not retro-patched. | +| `briefs/M1.1.11-plane-halfspace.md:50` | FROZEN SECTION of this brief, immutable. Already attributes back-face handling to M1.1.11.1. | +| `briefs/M1.1.11-plane-halfspace.md:51` | FROZEN SECTION, immutable. States verbatim that §1.11.4 "promises it for the triangle milestone, which is M1.1.11.1, not this one". | + +Zero `.zig` file mentions a back face, which is correct rather than an omission: a half-space +has no face at all, hence no back one, and the twelve `§1.11.4` citations in the sources all +concern solidity or far-field conditioning. `grep` for the old phrasing ("réglage en Phase +1", "setting in Phase 1", "no back-face setting", "not tunable in Phase 1") returns nothing +tree-wide, and `Phase 1` appears in no source file under `src/modules/forge/` or +`src/foundation/math/`. The patch set is empty and the reason is verifiable — unlike +`subshape_id` at item 3, where seven live source sites did carry the stale wording. + +**The spec file could not be verified as instructed.** The announced md5 +`ca38e229f3b4a1e261a72e8949620259` / 1669 lines is on no copy reachable from this machine; +the widest search (`Downloads`, `Devlab`, `Desktop`, `Documents`, depth 4) finds one +`engine-physics-forge.md`, at `~/Devlab/weldengine/weld-spec/`, md5 +`7f2143299c709e28f93155fe959d0693` / 1669 lines — the E4-amendment copy, whose line 502 +still reads the old bound. That is neither the expected md5 nor the STOP md5. J4 therefore +rests on the wording given in the instruction, and the KB-side check was not performed. + +### K1 — no Etch target fails, and the `failed command:` line is not a failure + +Measured, at the tag AND on the branch, with `--summary all` rather than a tail: + +| Tree | Steps | Tests | `failed command:` lines | +|---|---|---|---| +| `v0.11.10-queries-shapecast-overlap` (`712e4b5`, worktree) | 264/264 succeeded | 1471/1488 passed, 17 skipped | 7 | +| `phase-1/forge/plane-halfspace` (`63fc7d5`) | 264/264 succeeded | 1521/1538 passed, 17 skipped | 7 | + +Zero failures in either tree, both exit 0, and the SEVEN emitters are the same seven on +both sides: the cook-consolidate test's `cook: broken: parse diagnostic`, the hot-reload +timing line, the corpus test's `rule r_*: matched 2`, the plugin-loader warnings, the EBNF +harness count, the event-queue drop-saturation warning, and `ref500`'s parse median. Run +alone, `zig build test-ref500` reports `Build Summary: 3/3 steps succeeded; 2/2 tests +passed` / `test-ref500 success` and exits 0 — while printing the line. Run as a bare +binary, `All 2 tests passed`. + +**The mechanism, read on the Zig 0.16.0 source rather than inferred.** +`std/Build/Step/Run.zig:1540-1541` sets `result_failed_command` immediately BEFORE +spawning, commented "If an error occurs, it's caused by this command"; nothing clears it on +success (the only two clear sites are the foreign-interpreter re-spawn and fuzz mode). +`compiler/build_runner.zig:1381` then calls `printErrorMessages` under a condition whose +own comment is `// No matter the result, we want to display error/warning messages.`, +satisfied by `s.result_stderr.len > 0` alone. And `printErrorMessages` at line 1516 prints +`failed command: ` whenever that field is non-null. So the line is emitted for exactly +the set of test steps whose PASSING tests write to stderr. + +**Proven in both senses by controlled injection**, in the worktree at the tag: +`zig build test-forge-3d` reported `306 pass (306 total)`, step `success`, and NO line. +One `std.debug.print` added to the first passing test in `broadphase_test.zig`, nothing else +changed: same `306/306 tests passed`, same `test-forge-3d success`, same exit 0 — and the +line appeared. Reverted. + +**Two of my own claims refuted in the process, both by measurement.** First, I hypothesised +that caching suppressed the lines; a second, fully cached run at the tag printed the same +seven, so the hypothesis is false — a `Run` step re-executes its binary every time. Second, +my previous report said the full suite printed "two `failed command` lines"; that was a +`tail -12` truncating the output, not a count. The real count is seven, and was seven +before this branch existed. This also refutes the standing note describing the phenomenon +as a spurious macOS flake affecting `plugin_loader`/`events`/`etch-cache`: it is neither +spurious in origin nor platform-specific nor variable — it is a deterministic function of +whether a passing test wrote to stderr, and the varying set was the varying tail length. + +### L1 — the real bound is the live PEAK, not the live count + +The `remove` comment promised a bound by the LIVE count. False: `items.len` never decreases, +because retirement recycles a slot rather than removing it. The true bound is the **peak of +SIMULTANEOUSLY live slots, per layer** — a layer that once held nine half-spaces at once +keeps nine slots forever with one alive. What the free-list removed is the growth with the +TOTAL EVER CREATED, which was the pathology (a monotonic visit cost in every plane a +session has ever built), and that is strictly better without making the list dense. + +Acceptable because of what a half-space is here: `addBody` rejects any non-static body +carrying one, so the population is authored level geometry rather than gameplay churn, and +its peak is a quantity the scene author controls directly — MEASURED at 1 in every scene in +this repo, both benches included. + +The test is renamed to what it demonstrates — "the unbounded list stops growing with the +total ever created; its bound is the live PEAK" — and its final block, which already +measured the peak without naming it, now isolates it: nine live occupy nine slots, freeing +eight leaves ONE live and still NINE slots, and re-inserting reuses rather than appending a +tenth. A dense list would read 1 at the middle assertion; this one reads 9, deliberately. + +**The dense list is NOT implemented, and the option is recorded with its trigger.** The +argument first written here rested on preserving insertion order and was WRONG on its own +premise — see M1, which corrects it: the list is not insertion-ordered and §1.11.15 does not +require it to be. The real cost of density is that compaction moves a surviving slot's index, +and that index is what a live `Proxy` holds: every held proxy would have to be rewritten, or +the list would need a second level of indirection to keep them valid. What it buys is +iteration in O(live) rather than O(peak). The trigger for paying it is a real scene that +CHURNS half-spaces — creating and destroying them during play rather than at load. None +exists. + +### L2 — the reserve is conditional, and the atomicity argument becomes true + +`ensureUnusedCapacity(gpa, 1)` guarantees room for `len + 1`, so at `len == capacity` it +GROWS the list. The comment claimed it was "a no-op on a list at capacity", and that single +false sentence was what the atomicity argument rested on: an insertion that the free-list +was about to serve for free could fail with `error.OutOfMemory`. + +`moved_unbounded` is reserved unconditionally — it always receives an entry — and `unbounded` +only when `unbounded_free[li] == null_slot`. The head is read before either reserve, which +mutates nothing, so both fallible steps still precede every mutation and the guarantee is +intact rather than pleaded. + +The discriminating test is the only configuration that separates the two forms, and it is +not reachable by accident: the slot list EXACTLY at capacity (below capacity, both forms +allocate nothing) with a free slot available, under a `FailingAllocator` set to fail its +next allocation. It asserts the insertion succeeds, returns the recycled index, leaves the +length unchanged, and performs `failing.allocations == 0`. The moved log is drained first, +so its own unconditional reserve is genuinely free and cannot mask the result. RED against +the unconditional form — that one test and nothing else — GREEN with the conditional one. + +### L3 — the finiteness precondition, declared where the caller reads + +`api/types.zig` documented only the unit normal. The plane payload now carries a single +**Domain, asserted at creation** clause covering both — `normal` unit, `distance` finite, +both checked in `createShape` — and states why `distance` earns its own line rather than +being assumed: nothing downstream catches a non-finite one, and the two measured +consequences contradict each other (contact reported for a sphere 1000 m outside; no overlap +reported for a box 5000 m inside). A precondition, not a tolerance. + +### L4 — STOP: the announced spec file is on no reachable copy + +*Resolved: see M2 (resolved) below — the file arrived at `8599409c…` and was read.* + +Searched `/` entire, not `Downloads` alone: 30 hits, 14 distinct paths (each doubled through +`/System/Volumes/Data`, the same volume). None bears md5 `b9a20693181c4519b5c020504015469d` +/ 1671 lines. The two named in the instruction are both present and neither is it — +`2d4ae6f45eb4ce46e27c33e9648fe47d` / 1663 at `Downloads/M1.1.11-plane-halfspace/`, and +`7f2143299c709e28f93155fe959d0693` / 1669 at `Devlab/weldengine/weld-spec/`, whose line 502 +still carries the pre-J4 back-face wording. The remaining twelve are earlier milestones' +copies, 979 to 1582 lines. + +So the domain-clause wording could not be read, and the L4 alignment of `plane.zig` and +`api/types.zig` prose onto it is NOT performed. L3's clause is my own English, written to +L3's stated content; if the file arrives, the phrasing is what has to move, not the meaning. + +### M1 — iteration follows the slot INDEX, and that is the wanted property + +Two claims in the source were false from J3 onward, and the second leaned on the first: +`broadphase.zig` declared the list "insertion-ordered" on its field, and the dense-list +paragraph invoked "the insertion order §1.11.15 makes normative". A LIFO free-list recycles +`A`'s slot for `D`, so after `A, B, C`, retire `A`, insert `D` the iteration is `D, B, C`. +Neither the code nor the spec clause it cited described what the code did. + +The corrected 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. What M1.1.14 requires is not +insertion order but that the iteration order be a **deterministic function of the operation +sequence** — which LIFO recycling satisfies exactly, the free-list head being itself a +function of that sequence. This is the same argument J3 had already made correctly one +paragraph away, about index reuse; it simply was not carried to the field's own doc. + +And no observable result depends on the order at all: the query entries sort their hits by +the §1.11.14 key `(distance, entity, BodyId)`, and `computePairs` sorts by the canonical +packed pair key and adjacent-dedupes. The list order reaches no answer. + +Four sites corrected — the field doc, the dense-list paragraph, the test-file header, and the +retire-in-place comment — plus the L1 record above, whose own argument rested on the false +premise. **No dense list.** The test pins `D, B, C` as WANTED, then the actual requirement: +a second broadphase driven by an identical sequence iterates identically, and a DIFFERENT +sequence reaching the same live set (retire `B` instead of `A`) gives `A, D, C` — without +that third leg the identity assertion would pass on any implementation whatsoever. +Discriminating counter-factual: disabling LIFO reuse makes insertion the order and fails +this test on `D, B, C`, along with the three other free-list tests. + +**Contradiction with the FROZEN SECTION, recorded not corrected.** Frozen Scope line 40 asks +for "a flat, **insertion-ordered** list of unbounded shapes per broad layer". Under the +corrected contract that property does not hold and is not required. The frozen text stands as +written; this record is the amendment, per the round-trip rule. + +### M2 — STOP: the announced spec file is on no reachable copy, second time + +*Resolved: see M2 (resolved) below.* + +md5 `8599409c5ad11c8526f66e7d6db2e5b5` / 1671 lines: absent. Three independent sweeps, none +restricted to `Downloads` — every `engine-physics-forge.md` under `~` md5'd (15 copies, 979 +to 1669 lines); every `*.md` under `~` md5-compared against the target; and every file whose +name contains `forge` checked for a 1671-line length. Zero hits on all three. Nothing +spec-shaped was written under `~` in the last six hours either. The newest copy on disk +remains `7f2143299c709e28f93155fe959d0693` / 1669, the E4 amendment, whose line 502 still +carries the pre-J4 back-face wording. + +The M2 prose alignment of `plane.zig`, `api/types.zig` and `broadphase.zig` onto those +formulations is therefore NOT performed. M1 was done anyway because its corrected contract +is stated in full in the instruction, and L3's clause likewise; both are my English on stated +content, so if the file arrives it is the phrasing that has to move, not the meaning. + +### M2 (resolved) — the spec arrived, and the confirmation reading found one divergence + +`8599409c5ad11c8526f66e7d6db2e5b5` / 1671 lines, at `~/Devlab/weldengine/weld-spec/`. The +L4 and M2 STOP records above stand as what was true when written; this is the resolution. + +Read against the four amendments: + +- **Frame-locality of the true-zero guard and the conditioning decomposition (§1.11.15) — + CONCORDANT.** `plane.zig`'s header carries the guard's frame-locality, the `sep / floatEps` + crossing with the spec's own two figures (`8.3886120e7` m at f32, `4.5035996e16` m at f64 + for `sep = 10 m`), the refusal to invent an epsilon, and the finite `max_distance` as what + rejects such a ray. The conditioning half matches clause for clause: the normal is the + stored `n` returned verbatim, length AND orientation exact at any range and asserted as bit + equality, the residue entirely in `signedDistance` with error like `floatEps(T)·|p|`, the + suite asserting the normal tight and everywhere and reserving the scale-relative bound for + the scalar alone. `plane_test.zig` carries the spec's corollary in substance — a test + expecting `null` from a world-parallel ray tests a property the model does not promise, and + the exactly-parallel case is exercised at kernel grain in local coordinates. +- **Descriptor domain (§1.11.15) — ONE DIVERGENCE, corrected.** `shape.zig` already said the + normal must be ALREADY unit "to `f32` tolerance — it IS `f32`", and `plane.zig`'s + `assertDomain` carries the full two-contradictory-behaviours account. The PUBLIC surface, + which is the one the spec singles out, said only "must be unit" — dropping the + already-unit framing and its precision qualifier, which is exactly 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. Both aligned, in this commit. +- **Slot-stable index and the three-clause contract (§1.11.15) — CONCORDANT.** + `broadphase.zig` states the three clauses, the `D, B, C` result, that it is the WANTED + behaviour, the determinism-as-a-function-of-the-operation-sequence requirement, and that no + observable result depends on the order because queries sort by the §1.11.14 key and + `computePairs` by the canonical pair key with adjacent dedup. `broadphase_test.zig` pins + all of it. Reported as a difference of SCOPE rather than a divergence: the spec's reason + for refusing a dense list is that requiring insertion order in addition would cost a + second dense ordered list "for a property nothing reads", while the repo's paragraph + records the dense list as the option that would buy O(live) iteration, with its trigger — + the framing L1 commanded. Neither contradicts the other and both are kept. +- **Back faces bounded to MeshShape (§1.11.4 line 502) — CONCORDANT, zero occurrences of the + old bound.** Grep over the whole tree for "no back-face setting", "setting in Phase 1", + "not tunable in Phase 1" and the French form returns two matches, neither an occurrence: + the new M1.1.11 tag row, which states the amendment, and this brief's J4 record, which + lists the patterns searched. The fifteen back-face mentions keep their J4 statuses — + three `CLAUDE.md` rows consistent with the new bound, three closed-brief lines not + retro-patched, two FROZEN lines already attributing the subject to M1.1.11.1, and the J4 + record itself. Zero `.zig` file mentions a back face, which remains correct rather than an + omission: a half-space has no face. + +### K2, K3, K4 — the `CLAUDE.md` patch, inside the PR + +Content supplied by Guy and applied **verbatim**, in three commits, on the branch and +therefore inside the PR rather than after the merge. + +K2 replaced the `Current state` block, which was stale in four of its five rows: it still +announced M1.1.10 as code-complete with a PR open, `v0.11.9` as the last tag and +`queries-shapecast-overlap` as the active branch, while `main` was already at `v0.11.10`. +K3 appended the M1.1.11 tag row. K4 added the two `Safeguards` entries — the `pre-commit` +hook's scope, and `failed command:` not meaning a command failed. + +Verified rather than assumed: both table blocks were compared programmatically against the +supplied text and are byte-identical, and every row carries exactly the right number of +pipes, so no cell holds an unescaped one. `Last updated:` was left alone — it was not part +of the supplied content. The K3 row's four resting figures were cross-checked against +`plane_test.zig:1324-1325` and are bit-for-bit the repo's own measurement, the apparent +4e-9 gap at f32 being the third column of that same table. + +The content of K2 and K4 took **three emissions** to reach me. I reported its absence twice +rather than reconstructing it, which was the right call under the standing rule; the second +report named the exact target lines so the third emission could be applied without further +round-trip. + +## Closing notes — summary + +- **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, all above and 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 (now including + `contact_cache.zig` and the two bench results), 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 (M1). Two files in the list were not touched and neither needed to be + (`query/cast.zig`, `tests/manifold_test.zig`), both accounted for under RD-6. +- **What to flag explicitly in review:** (a) The DENSE list is deliberately not built; the + bound on an unbounded list's length is the PEAK of simultaneously live slots per layer, + measured at 1 in every scene here, and the trigger for paying the O(n) removal is a real + scene that churns half-spaces. (b) `broadphase.zig` is 1224 lines, over the 500-line + Review guideline, a brief-driven overage recorded in RD-7. (c) The frame-locality of a + true-zero guard is a MODEL property, not a defect: a world-parallel ray against a rotated + plane body is correctly reported as a crossing at `sep / floatEps`, and what rejects it is + the entry's finite `max_distance`. (d) `error.UnsupportedShape` is now reachable end to + end through the two entries that take a caller-supplied shape handle, which separates + three outcomes a single `null` conflated on `main`. +- **Final measurements:** 356/356 forge-3d tests green at f32 AND `-Dphysics_f64=true`, + debug AND ReleaseSafe. Full `zig build test` green (264/264 steps, 1521/1538 tests, 17 + skipped); `zig build` clean; `zig fmt --check` clean; `zig build lint` clean on the TREE. + 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, and no envelope registered + for a quantity below the noise floor. +- **Residual risks / tech debt left intentionally:** The far-field residue of + `signedDistance` grows like `floatEps(T)·|p|`, the same 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 all + belong to M1.1.11.1 and are absent on purpose. 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); §1.11.16 makes that a + property of a shape without sub-shapes rather than a placeholder. The 2D symmetry waits on + `PhysicsModule2D` at M1.8.11. + +## Execution log + +| Gate | Delivered | Tests | +|---|---|---| +| E1 | `ShapeClass` (two variants, exhaustive switches), the accessor, the half-space fields, `ShapeDescriptor.plane`, the `createShape` arm with the unit-normal domain assert + normalisation, `supportShape`'s asserted precondition | 313 | +| E2 | `error.ShapeMustBeStatic` ordered first in `addBody`; class asserts in `computeSleepRadius`, `computeMotion` (dynamic path), `worldAabb`, `bodyAabb`; NaN poison on `local_aabb` / `unit_inertia` / `sleep_radius` (F1); the two f32 measurement figures corrected (F2); the French spec citation paraphrased (F3) | 317 | +| E3 | The error channel on `shapeCast` / `overlapShape` separating three outcomes; `query.Error` reshaped to `{ InvalidShape, UnsupportedShape }`; probe admissibility before any use of the record; `rayShape`'s rounded-box latch → `raySupportsShape` precondition; the error off the ray path (32 code lines); the frozen-signature pin extended with explicit totality | 319 | +| E4 | `plane.zig` (301 lines, six kernels, two true-zero guards); `Aabb.overlapsHalfSpace` (RD-1); `LocalHit`/`CastHit` to `support.zig` (RD-2); the plane arm of the five adapters; G1 (the redundant-placement argument at both sites), G2 (probe-outranks-direction precedence) | 332 | +| E5 | Per-layer unbounded lists outside the trees; `Proxy.kind`; `insertUnbounded`; `Bvh.queryHalfSpace`; both pairing directions; the layer matrix unchanged; list visiting in `queryAabb`/`queryRay`/`queryCast`; `aabbOverlapsBody` (RD-4); the harness (RD-5); H1, H2 | 342 | +| E6 | The half-space arm of `collidePair` (nested exhaustive class dispatch, the plane-vs-plane arm asserted unreachable) and of the manifold generator, no clipping, `sep <= 0` exactly with `keep_eps` deliberately not copied; `class_plane` as a FOURTH class tag with a comptime pairwise-distinctness proof; the full tick cycle closed | 347 | +| E7 | B1 resolved and the prose aligned on the amended §1.11.15; I1 (the bit-agreement exercised on 15 bracketing configurations); I2 recorded; the stale `subshape_id` wording patched at 7 source sites; both bench deltas measured; the eleven envelope quantities re-measured at zero movement; the leak check proven both ways | 348 | +| E7 (J) | J1 the cast normal at an initial overlap, committed alone ahead of the measurements; J2 `isFinite` on the plane distance with both contradictory consequences measured; J3 the LIFO free-list for retired unbounded slots, 64 slots measured before and 1 after; J4 the back-face bound, six mentions classified and an empty patch set | 354 | +| E7 (K/L) | K1 no Etch target fails at the tag or on the branch — 7 identical `failed command:` lines both sides, mechanism read on the Zig source and proven by injection, two of my own claims refuted; L1 the bound restated as the live PEAK with the test renamed and the dense-list option recorded with its trigger; L2 the conditional reserve with the only test that separates the two forms; L3 the domain clause on the public payload; L4 STOP — the announced spec md5 is on no reachable copy | 355 | +| E7 (M) | M1 the two false order claims corrected at four sites plus the L1 record that leaned on them; iteration follows the slot index, pinned as WANTED with the determinism requirement and a third leg giving it power; the frozen Scope's "insertion-ordered" contradiction recorded; M2 STOP — the second announced spec md5 is absent under three independent sweeps | 356 | +| E7 (K2–K4) | The `CLAUDE.md` patch applied verbatim inside the PR — the stale `Current state` block, the M1.1.11 tag row, the two `Safeguards` entries — each verified byte-identical against the supplied text and each table row pipe-counted | 356 | +| Close | The spec arrived at `8599409c…` and the confirmation reading found ONE divergence, on the public surface the spec singles out, corrected in a single prose commit; brief closed; branch-wide §3.6.1 audits; scope diff reconciled | 356 | diff --git a/src/foundation/math/aabb.zig b/src/foundation/math/aabb.zig index 194bacd..bf84625 100644 --- a/src/foundation/math/aabb.zig +++ b/src/foundation/math/aabb.zig @@ -76,6 +76,39 @@ pub fn Aabb(comptime T: type) type { @reduce(.And, other.min.data <= self.max.data); } + /// Whether this box intersects the CLOSED half-space `{ x : normal·x <= distance }`. + /// + /// Exact, eight branches, no infinity and no constant. The minimum of `normal·x` + /// over a box separates per axis — `min_i(n_i·x_i)` is `n_i·min_i` when `n_i >= 0` + /// and `n_i·max_i` otherwise — so that minimum is attained at ONE corner, the + /// lowest one along `normal`, read component by component. The box meets the + /// half-space exactly when that corner does, which is what this computes; the + /// inline test below checks it against an enumeration of all eight corners, for + /// all eight sign patterns of the normal. + /// + /// CLOSED half-space: a corner exactly on the boundary plane counts, matching the + /// face-inclusive convention of `overlaps` and `contains`. The `>= 0` selection + /// is a fixed tie-break for a zero component, which contributes nothing to the + /// dot product either way. + /// + /// `normal` is expected unit — the caller's invariant, not this function's + /// concern: the predicate is scale-invariant in `normal` only if `distance` + /// scales with it, so the two travel together and the physics side normalises at + /// shape creation. + /// + /// **Why it lives here.** An unbounded shape has no AABB to compare, so its + /// broadphase role is this PREDICATE rather than a box (`engine-physics-forge.md` + /// §1.11.15), and two callers need it: the broadphase, which imports only + /// `foundation`, and the narrowphase's half-space kernels. Same first-consumer + /// placement as `surfaceArea`, `rayInterval` and `inflate` — pure box geometry, + /// no threshold, no physical semantics — and the alternative was the same eight + /// branches written twice in two files that would drift. + pub fn overlapsHalfSpace(self: Self, normal: Vec3T, distance: T) bool { + const zeros: @Vector(3, T) = @splat(0); + const lowest = @select(T, normal.data >= zeros, self.min.data, self.max.data); + return @reduce(.Add, normal.data * lowest) <= distance; + } + /// Geometric center. pub fn center(self: Self) Vec3T { return self.min.add(self.max).scale(0.5); @@ -302,6 +335,100 @@ test "inflate is the Minkowski sum: overlap equals containment of the centre" { try testing.expect(disjoint > 0); } +/// The minimum of `normal·x` over the box's eight corners, by ENUMERATION — the oracle +/// `overlapsHalfSpace`'s per-axis closed form is checked against. +fn minDotOverCorners(comptime T: type, box: Aabb(T), normal: vec.Vec(3, T)) T { + const lo = box.min.toArray(); + const hi = box.max.toArray(); + const n = normal.toArray(); + var best: ?T = null; + for ([_]usize{ 0, 1 }) |i| { + for ([_]usize{ 0, 1 }) |j| { + for ([_]usize{ 0, 1 }) |k| { + const x = if (i == 0) lo[0] else hi[0]; + const y = if (j == 0) lo[1] else hi[1]; + const z = if (k == 0) lo[2] else hi[2]; + const dot = n[0] * x + n[1] * y + n[2] * z; + best = if (best) |b| @min(b, dot) else dot; + } + } + } + return best.?; +} + +test "overlapsHalfSpace agrees with a corner enumeration on all eight sign patterns" { + // The per-axis corner selection has EIGHT branches, one per sign pattern of the + // normal, and a test written on the pattern the author happened to pick exercises + // one. So: all eight, each against the enumerated minimum over the eight corners, + // on a box that is neither centred nor cubic — a symmetric box would let a wrong + // corner give the right answer. + const box = Aabbf.fromMinMax(Vec3.fromArray(.{ 1, 2, 3 }), Vec3.fromArray(.{ 4, 8, 5 })); + const inv_root3: f32 = 1.0 / @sqrt(@as(f32, 3)); + + var accepted: u32 = 0; + var rejected: u32 = 0; + for ([_]f32{ 1, -1 }) |sx| { + for ([_]f32{ 1, -1 }) |sy| { + for ([_]f32{ 1, -1 }) |sz| { + const n = Vec3.fromArray(.{ sx * inv_root3, sy * inv_root3, sz * inv_root3 }); + const lowest = minDotOverCorners(f32, box, n); + // Sweep `distance` across the whole projected extent of the box, so each + // pattern sees the predicate answer true AND false, and sees the exact + // boundary case `distance == lowest` — which must be a HIT, the + // half-space being closed. + try testing.expect(box.overlapsHalfSpace(n, lowest)); // exactly on it + try testing.expect(box.overlapsHalfSpace(n, lowest + 1)); // beyond + try testing.expect(!box.overlapsHalfSpace(n, lowest - 1)); // short of it + accepted += 2; + rejected += 1; + // And agreement with the oracle over a fine sweep of offsets. + var step: i32 = -12; + while (step <= 12) : (step += 1) { + const d = lowest + @as(f32, @floatFromInt(step)) * 0.5; + try testing.expectEqual(lowest <= d, box.overlapsHalfSpace(n, d)); + } + } + } + } + // Both verdicts really occurred for every pattern — 8 patterns × (2 hits, 1 miss). + try testing.expectEqual(@as(u32, 16), accepted); + try testing.expectEqual(@as(u32, 8), rejected); + + // Axis-aligned normals, where a component is EXACTLY zero and the `>= 0` tie-break + // decides which face is read. A zero component contributes nothing to the dot + // product, so either corner is correct — asserted against the same oracle rather + // than trusted. + for ([_]Vec3{ + Vec3.unit_x, Vec3.unit_y, Vec3.unit_z, + Vec3.unit_x.neg(), Vec3.unit_y.neg(), Vec3.unit_z.neg(), + Vec3.fromArray(.{ 0, 1, 0 }), Vec3.fromArray(.{ 0, 0, -1 }), + }) |n| { + const lowest = minDotOverCorners(f32, box, n); + try testing.expect(box.overlapsHalfSpace(n, lowest)); + try testing.expect(!box.overlapsHalfSpace(n, lowest - 0.5)); + } + + // A DEGENERATE box (a point) is a legal region and answers by its single corner. + const point = Aabbf.fromMinMax(Vec3.fromArray(.{ 1, 1, 1 }), Vec3.fromArray(.{ 1, 1, 1 })); + try testing.expect(point.overlapsHalfSpace(Vec3.unit_y, 1)); // exactly on the plane + try testing.expect(!point.overlapsHalfSpace(Vec3.unit_y, 0.5)); +} + +test "overlapsHalfSpace at f64 matches the f32 closed form" { + const A = Aabb(f64); + const V = vec.Vec(3, f64); + // Unit box [0,1]³ against `{ y <= d }`: the lowest corner along +Y is y = 0, so the + // box meets the half-space for every `d >= 0` and no negative one. + const box = A.fromMinMax(V.zero, V.one); + try testing.expect(box.overlapsHalfSpace(V.unit_y, 0)); + try testing.expect(box.overlapsHalfSpace(V.unit_y, 1e-300)); + try testing.expect(!box.overlapsHalfSpace(V.unit_y, -1e-300)); + // Along −Y the lowest corner is y = 1, giving `−1 <= d`. + try testing.expect(box.overlapsHalfSpace(V.unit_y.neg(), -1)); + try testing.expect(!box.overlapsHalfSpace(V.unit_y.neg(), -1.0000000000000002)); + try testing.expectEqual(@as(f64, -1), minDotOverCorners(f64, box, V.unit_y.neg())); +} + test "generic Aabb f64 instantiation compiles" { const A = Aabb(f64); const V = vec.Vec(3, f64); diff --git a/src/modules/forge/api/types.zig b/src/modules/forge/api/types.zig index bf6ecae..6cee604 100644 --- a/src/modules/forge/api/types.zig +++ b/src/modules/forge/api/types.zig @@ -66,7 +66,9 @@ pub const BodyType = enum(u8) { /// Collision-shape kind. `u8`-backed (component tag). C1.1-complete set — /// the spec §1 enum is a subset; the extension (plane, tapered_cylinder, /// height_field, mutable_compound, empty) is additive and pre-freeze -/// (Notes decision 3b). M1.1.0 constructs only sphere/box/capsule. +/// (Notes decision 3b). `createShape` constructs sphere/box/capsule (M1.1.0) and +/// plane (M1.1.11); every other variant returns `error.UnsupportedShape` until its +/// own sub-milestone. pub const ShapeType = enum(u8) { /// Sphere (radius). sphere, @@ -97,8 +99,11 @@ pub const ShapeType = enum(u8) { /// Shape parameters, a tagged union discriminated by `ShapeType`. The spec §1 /// flat struct self-describes as a simplification of a discriminated union; /// the union IS the specified design (Notes decision 3a). -/// M1.1.0 carries payloads for sphere/box/capsule; the rest are `void` -/// placeholders whose payloads land at their own sub-milestones (pre-freeze). +/// M1.1.0 carries payloads for sphere/box/capsule and M1.1.11 adds plane; the +/// rest are `void` placeholders whose payloads land at their own sub-milestones — +/// a pre-freeze extension of the union that `engine-tier-interfaces.md` §1 +/// explicitly permits, each payload-less variant receiving its payload at the +/// sub-milestone that delivers the shape. pub const ShapeDescriptor = union(ShapeType) { /// Sphere of `radius` metres. sphere: struct { radius: f32 = 0.5 }, @@ -112,8 +117,37 @@ pub const ShapeDescriptor = union(ShapeType) { tapered_cylinder: void, /// Placeholder — payload lands at the convex-hull sub-milestone. convex_hull: void, - /// Placeholder — payload lands at the plane sub-milestone. - plane: void, + /// Solid half-space `n·x <= d`: `normal` unit, `distance` in metres, both in + /// the shape's local frame and transported by the body pose (M1.1.11, + /// `engine-physics-forge.md` §1.11.15). + /// + /// The body carrying it must be STATIC: a half-space has neither a finite + /// volume, nor an inertia tensor, nor a local AABB, so mass, inertia and sleep + /// radius are undefined on it. `addBody` rejects a dynamic or kinematic body + /// carrying one with `error.ShapeMustBeStatic`, BEFORE any computation derived + /// from a local AABB or an inertia — the ordering is normative, both of those + /// living in the body literal with no branch on body type of their own. Same + /// invariant as the reference, whose plane declares `MustBeStatic`. + /// + /// **Domain, asserted at creation.** Both fields carry a precondition and they are of + /// the same class, so they are declared TOGETHER and in the same place — on the public + /// surface the caller reads, not only at the site that checks them (§1.11.15). + /// `normal` is ALREADY unit, to the tolerance of its own precision — it is `f32` + /// whatever the solver scalar is — and `distance` is FINITE. Both are checked in + /// `createShape`; the stored normal is normalised once there, so no call site ever + /// re-normalises. + /// + /// `distance` earns its own clause because a non-finite one is not caught downstream + /// by anything — it is silently accepted, and two consumers then disagree about it. + /// MEASURED with a NaN distance, identically at f32 and f64: the contact generator + /// reported a contact point for a unit sphere 1000 m OUTSIDE the solid, since its + /// `sep > 0` skip is FALSE when `sep` is NaN; while the broadphase corner predicate + /// reported no overlap for a box at the origin AND for a box 5000 m INSIDE, since its + /// `<=` is false in the other direction. One malformed field, two silent behaviours + /// that contradict each other, and no diagnostic — which is why this is a precondition + /// and not a tolerance, the same pattern the typed rejection of a `collision_layer` + /// outside `[0, 32)` exists to close (§1.11.4). + plane: struct { normal: Vec3 = Vec3.unit_y, distance: f32 = 0 }, /// Placeholder — payload lands at the triangle-mesh sub-milestone. triangle_mesh: void, /// Placeholder — payload lands at the height-field sub-milestone. @@ -248,16 +282,27 @@ pub const OverlapQuery = struct { filter: PhysicsQueryFilter = .{}, }; -/// One ray hit. `subshape_id` identifies the sub-shape hit and is 0 while one -/// shape is one body; the service derives `physics_material` from it, because the -/// solver result carries the sub-shape identity and never the material itself -/// (§1.11.7 — the same construction as the reference's `CastResult.h`). +/// One ray hit. +/// +/// `subshape_id` is an OPAQUE PATH decoded by the ROOT shape, never a global index: its +/// width is a property of the SHAPE and not of the value, and a shape with no sub-shape +/// consumes ZERO BITS, so the `0` default is not read at all (§1.11.16). Sphere, box, +/// capsule and plane all carry zero sub-shapes. That is NOT the same statement as the +/// M1.1.9/M1.1.10 wording it replaces ("0 while one shape is one body"), and the +/// difference is what makes the encoding forward-compatible: wrapping a shape in a +/// compound shifts its index up and inserts the child's below, extending the encoding +/// without reinterpreting any value already written. +/// +/// The service derives `physics_material` from it, because the solver result carries the +/// sub-shape identity and never the material itself (§1.11.7 — the same construction as +/// the reference's `CastResult.h`). pub const RaycastHit = struct { /// Entity owning the body hit. entity: EntityId, /// The body hit. body: BodyId, - /// Sub-shape hit; 0 while one shape is one body. + /// Sub-shape hit — an opaque path, zero bits wide for a shape with no sub-shape, + /// so the `0` is not read (§1.11.16; the type doc above carries the reasoning). subshape_id: u32 = 0, /// World-space hit point. position: Vec3, @@ -338,6 +383,15 @@ test "ShapeDescriptor payload defaults" { const c = ShapeDescriptor{ .capsule = .{} }; try testing.expectEqual(@as(f32, 0.3), c.capsule.radius); try testing.expectEqual(@as(f32, 0.5), c.capsule.half_height); + + // The plane payload (M1.1.11), on the same footing as the other three: its + // default is `{x : y <= 0}`, a ground plane through the origin, and the default + // normal is EXACTLY unit — which is what lets `.plane = .{}` pass the + // creation-time domain assert of `forge_3d/shape.zig` unchanged. + const p = ShapeDescriptor{ .plane = .{} }; + try testing.expect(p.plane.normal.eql(Vec3.unit_y)); + try testing.expectEqual(@as(f32, 0), p.plane.distance); + try testing.expectEqual(@as(f32, 1), p.plane.normal.lengthSq()); } test "BodyDescriptor defaults match the brief" { @@ -384,8 +438,10 @@ test "the frozen query family mirrors engine-tier-interfaces.md §1" { const overlap = OverlapQuery{ .shape = 0, .position = Vec3.zero }; try testing.expect(overlap.rotation.approxEql(Quatf.identity, 0)); - // `subshape_id` defaults to 0 — one shape is one body today, and it is by this - // field that the service derives the material, never from the solver result. + // `subshape_id` defaults to 0, and §1.11.16 is why that default is safe rather than + // provisional: it is an opaque PATH whose width the shape declares, and a shape with + // no sub-shape consumes zero bits, so the value is not read. It is by this field that + // the service derives the material, never from the solver result. const hit = RaycastHit{ .entity = .{ .index = 0, .generation = 0 }, .body = 0, diff --git a/src/modules/forge/forge_3d/body.zig b/src/modules/forge/forge_3d/body.zig index 3db18fc..ef43355 100644 --- a/src/modules/forge/forge_3d/body.zig +++ b/src/modules/forge/forge_3d/body.zig @@ -126,6 +126,11 @@ pub const Body = struct { /// AABB, computed once at creation. It converts the rotation since the /// reference pose into the displacement of the body's furthest material point, /// so one bound in metres covers both translation and rotation. + /// + /// **NaN for a half-space**, which has no local AABB to derive it from. Nothing + /// reads it there: such a body can only be `.static` (`addBody` rejects any other + /// type with `error.ShapeMustBeStatic`), and both `sleep.updateWindows` and the + /// island seeding skip a non-dynamic body before touching the radius. sleep_radius: Real, /// Owning ECS entity. entity: EntityId, @@ -136,13 +141,27 @@ pub const Body = struct { /// component-wise maximum of `|min|` and `|max|` picks that corner without /// enumerating all eight — the furthest one is the one furthest along every axis /// at once. Computed once, at body creation: it is pose-invariant. +/// +/// **PRECONDITION: the shape is a bounded CONVEX** (M1.1.11, §1.11.15). This reads +/// `local_aabb`, and a half-space has none — the field is NaN there. `addBody` +/// never asks: a non-static body carrying a half-space is rejected with +/// `error.ShapeMustBeStatic` before the `Body` literal is built, and for a static +/// one the literal branches on the class instead of calling this, a static body +/// having no sleep window at all (`sleep.updateWindows` and the island seeding both +/// skip a non-dynamic body before reading the radius). pub fn computeSleepRadius(shape: Shape) Real { + std.debug.assert(shape.class() == .convex); return shape.local_aabb.min.abs().max(shape.local_aabb.max.abs()).length(); } /// Derive `MotionProperties` from a descriptor and its shape. Inertia is the /// shape's unit-mass diagonal scaled by `desc.mass`, then inverted per axis. /// Static/kinematic bodies get zero inverse mass and inertia. +/// +/// The class precondition is on the DYNAMIC PATH ONLY, and deliberately not at the +/// entry: a STATIC body carrying a half-space is legal (§1.11.15) and takes the +/// early return below without ever touching `unit_inertia`. Asserting at the entry +/// would refuse the one body type a half-space is allowed to have. pub fn computeMotion(desc: BodyDescriptor, shape: Shape) MotionProperties { const ld: Real = desc.linear_damping; const ad: Real = desc.angular_damping; @@ -158,6 +177,12 @@ pub fn computeMotion(desc: BodyDescriptor, shape: Shape) MotionProperties { }; } + // The dynamic path reads `unit_inertia`, which a half-space does not have (NaN + // there). Unreachable from `addBody`, which rejects a dynamic body carrying one + // with `error.ShapeMustBeStatic` before building the `Body` — this is what makes + // that rejection's ORDERING load-bearing rather than stylistic (§1.11.15): move + // it after the literal and a dynamic half-space lands here instead. + std.debug.assert(shape.class() == .convex); // A dynamic body must have positive mass (inv_mass/inertia divide by it). // Full descriptor validation (typed errors, degenerate geometry) is a later // milestone; this guards the unchecked dynamic path. diff --git a/src/modules/forge/forge_3d/body_manager.zig b/src/modules/forge/forge_3d/body_manager.zig index 6f1f7d2..5b52761 100644 --- a/src/modules/forge/forge_3d/body_manager.zig +++ b/src/modules/forge/forge_3d/body_manager.zig @@ -117,10 +117,36 @@ pub const BodyManager = struct { } /// Create a body from `desc`, resolving its shape in `store` for the - /// inertia. Returns the new handle. Velocity starts at zero. Fails with - /// `error.InvalidShape` on a stale/invalid `desc.shape`. + /// inertia. Returns the new handle. Velocity starts at zero. + /// + /// Three typed failures, in the order they are tested: `error.InvalidShape` on a + /// stale/invalid `desc.shape`; `error.ShapeMustBeStatic` when the shape is a + /// half-space and `desc.body_type` is not `.static` (§1.11.15); and + /// `error.InvalidCollisionLayer` outside `[0, collision_layer_count)` (§1.11.5). + /// On any of them nothing is mutated and no handle is allocated. pub fn addBody(self: *BodyManager, gpa: std.mem.Allocator, store: *const ShapeStore, desc: BodyDescriptor) !BodyId { const shape = store.get(desc.shape) orelse return error.InvalidShape; + // A HALF-SPACE FORCES A STATIC BODY, and the refusal is a TYPED error + // (`engine-physics-forge.md` §1.11.15). It has no finite volume, no inertia + // tensor and no local AABB, so mass, inertia and sleep radius are not defined + // on it — a dynamic or kinematic body carrying one is not a body with unusual + // numbers, it is a body whose numbers do not exist. Same invariant as the + // reference, whose plane declares `MustBeStatic`. + // + // **The ORDER is normative, not stylistic.** It sits immediately after the + // shape resolution — which it depends on — and BEFORE every computation + // derived from the local AABB or the inertia: both `computeMotion` (on its + // dynamic path) and the sleep radius live in the `Body` literal below, with no + // branch on body type of their own. Moved after that literal, a dynamic + // half-space would reach `computeMotion`'s class assert in a safe build and, + // in ReleaseFast where that assert is compiled out, would silently store a NaN + // inverse inertia. + // + // Named for the invariant rather than for the shape: `MeshShape` is static-only + // too (§2), so M1.1.11.1 reuses this error instead of minting a second one. + if (shape.class() == .half_space and desc.body_type != .static) { + return error.ShapeMustBeStatic; + } // A TYPED error, not a debug assert: the query mask is 32 bits, so a body // beyond that domain would be invisible to every query with no diagnostic // at all — the silent-miss class the shape invariant forbids @@ -173,7 +199,20 @@ pub const BodyManager = struct { // widening error and report a phantom displacement — tiny against the // 15 mm bound, and wrong regardless. .sleep_ref_rotation = rotation_r, - .sleep_radius = body_mod.computeSleepRadius(shape), + // A switch on the CLASS, exhaustive and with no `else` arm, so the mesh + // (M1.1.11.1) is a compile error here and must state its own answer. + // + // A half-space has no local AABB, hence no sleep radius — and needs none: + // it can only be STATIC (rejected above otherwise), and nothing ever reads + // a static body's radius, both `sleep.updateWindows` and the island seeding + // skipping a non-dynamic body before they touch it. NaN rather than a + // plausible zero, for the reason the two shape fields it derives from carry + // NaN: the class asserts guarding them are compiled out of ReleaseFast, and + // a finite placeholder there would pass unnoticed. + .sleep_radius = switch (shape.class()) { + .convex => body_mod.computeSleepRadius(shape), + .half_space => std.math.nan(Real), + }, .entity = desc.entity, }; try self.alloc.ensureUnusedCapacity(gpa, 1); @@ -281,7 +320,9 @@ pub const BodyManager = struct { /// Safe getter: the body's sleep radius (distance from its centre to the /// furthest corner of its shape's local AABB), or null if `id` is - /// stale/invalid. Pose-invariant, computed once at creation. + /// stale/invalid. Pose-invariant, computed once at creation. NaN for a body + /// carrying a half-space, which has no local AABB and no sleep window — see + /// `Body.sleep_radius`. pub fn sleepRadius(self: *const BodyManager, id: BodyId) ?Real { const idx = self.alloc.validate(id) orelse return null; return self.bodies.items(.sleep_radius)[idx]; @@ -433,17 +474,30 @@ pub const BodyManager = struct { const pos = self.bodies.items(.position)[idx]; const rot = self.bodies.items(.rotation)[idx]; const shape = store.get(self.bodies.items(.shape)[idx]) orelse return null; + // The same precondition `worldAabb` carries, asserted again at the BODY grain + // — this is where a caller holds a `BodyId` and can be told which body it + // asked about. A body carrying a half-space is asked a PREDICATE ("do you + // overlap this box"), never a box (§1.11.15). + std.debug.assert(shape.class() == .convex); return worldAabb(shape, pos, rot); } /// Ray against one body's shape, resolving its world pose and support shape /// (via `store`). Returns null if the handle — or its shape — is - /// stale/invalid, or if the ray misses; `error.UnsupportedShape` if the shape - /// is outside the kernel's set (a rounded box). The `BodyId`-level ray + /// stale/invalid, or if the ray misses. The `BodyId`-level ray /// adapter for the broadphase→kernel flow, mirroring `gjkPair` / /// `collidePair`: unpack a `queryRay` candidate's `user_data` as a `BodyId` /// and call this per candidate. /// + /// **No error channel since M1.1.11.** It carried `error.UnsupportedShape` from + /// the kernel's rounded-box latch, and no path could reach it: `supportShape` + /// gives every stored box `radius = 0`, so a `SupportShape` built from a body is + /// never a rounded box. The kernel's refusal is an asserted precondition now + /// (`narrowphase.raySupportsShape`) and the typed refusal lives at the two query + /// entries taking a caller-supplied shape handle (§1.11.7). What this adapter + /// resolves is a BODY's shape, which the store validated at creation, so there is + /// nothing here for a caller to get wrong. + /// /// The hit comes back in the shape's LOCAL frame, which is enough: a rigid /// transform preserves distances and the direction is unit on both sides, so /// `distance` is already the world distance and only the normal needs @@ -460,13 +514,22 @@ pub const BodyManager = struct { store: *const ShapeStore, id: BodyId, ray: RayR, - ) error{UnsupportedShape}!?narrowphase.LocalHit(Real) { + ) ?narrowphase.LocalHit(Real) { const idx = self.alloc.validate(id) orelse return null; const shape = store.get(self.bodies.items(.shape)[idx]) orelse return null; const inv_rot = self.bodies.items(.rotation)[idx].conjugate(); const local_origin = inv_rot.rotateVec3(ray.origin.sub(self.bodies.items(.position)[idx])); const local_direction = inv_rot.rotateVec3(ray.direction); - return narrowphase.rayShape(Real, shape_mod.supportShape(shape), local_origin, local_direction); + // Dispatch by CATEGORY, above the support map and never inside it (§1.11.15). + // Exhaustive with no `else`: the mesh (M1.1.11.1) is a compile error here. + // The half-space arm needs no transport of its own — the plane is stored in + // this body's local frame, which is the frame the ray has just been brought + // into, and both kernels return the SAME `LocalHit` so this adapter has one + // return type rather than a union of two. + return switch (shape.class()) { + .convex => narrowphase.rayShape(Real, shape_mod.supportShape(shape), local_origin, local_direction), + .half_space => narrowphase.plane.rayShape(Real, shape_mod.halfSpace(shape), local_origin, local_direction), + }; } /// Run distance-based GJK on the pair `a`/`b`, resolving each body's world @@ -532,14 +595,32 @@ pub const BodyManager = struct { self.bodies.items(.rotation)[idx], ); const local_dir = cast_rotation.conjugate().rotateVec3(direction); - const hit = narrowphase.castShape( - Real, - cast_shape, - relpose, - shape_mod.supportShape(shape), - local_dir, - max_distance, - ) orelse return null; + // The CAST shape is always a bounded convex — the query entry refuses an + // unbounded probe with a typed error (§1.11.7) — so only the HIT body's + // category is dispatched on. Exhaustive, no `else`. + // + // The half-space arm transports the plane INTO A's frame rather than + // transporting A: the sweep is a pure translation of A, so A's support in the + // fixed direction `−n` is a constant of the whole sweep, and one support call + // answers it in closed form. `RelativePose` already carries B-relative-to-A, + // which is exactly the pose the transport needs. + const hit = switch (shape.class()) { + .convex => narrowphase.castShape( + Real, + cast_shape, + relpose, + shape_mod.supportShape(shape), + local_dir, + max_distance, + ), + .half_space => narrowphase.plane.castShape( + Real, + shape_mod.halfSpace(shape).transformed(relpose.rot_rel, relpose.pos_rel), + cast_shape, + local_dir, + max_distance, + ), + } orelse return null; return .{ // A distance is invariant under a rigid transform, so it needs no mapping. .distance = hit.distance, @@ -566,16 +647,74 @@ pub const BodyManager = struct { ) ?bool { const idx = self.alloc.validate(id) orelse return null; const shape = store.get(self.bodies.items(.shape)[idx]) orelse return null; - const result = narrowphase.gjk( - Real, - query_shape, - query_position, - query_rotation, - shape_mod.supportShape(shape), - self.bodies.items(.position)[idx], - self.bodies.items(.rotation)[idx], - ); - return result.status != .separated; + switch (shape.class()) { + .convex => { + const result = narrowphase.gjk( + Real, + query_shape, + query_position, + query_rotation, + shape_mod.supportShape(shape), + self.bodies.items(.position)[idx], + self.bodies.items(.rotation)[idx], + ); + return result.status != .separated; + }, + // The half-space is A and the probe is B, which is the orientation + // §1.11.15's separation formula is written in — `sep = n · supportCore_B(−n) + // − r_b − d`, one support call. The SIGN of that separation is the whole + // classification, exactly: no GJK margin enters, and none should, since + // there is no accumulated rounding here to absorb. + .half_space => return narrowphase.plane.separation( + Real, + shape_mod.halfSpace(shape), + narrowphase.RelativePose(Real).init( + self.bodies.items(.position)[idx], + self.bodies.items(.rotation)[idx], + query_position, + query_rotation, + ), + query_shape, + ) <= 0, + } + } + + /// Whether body `id`'s exact geometry meets the world AABB `query_box`, faces + /// included. Null on a stale/invalid handle or shape — distinct from `false`, which + /// is a real answer. + /// + /// **A SIXTH adapter, and item 6 of E5 is why it exists.** `overlapAabb`'s collector + /// used to call `bodyAabb` on every candidate, which is correct for a bounded convex + /// and PANICS on a half-space — the class assert — and in ReleaseFast, where that + /// assert is compiled out, would fall through to `worldAabb`'s `unreachable`. Putting + /// the dispatch here rather than in the collector keeps all class dispatch in one + /// file, beside the other five, and keeps `query/overlap.zig` from needing the + /// half-space transport. + /// + /// The convex arm is the body's TIGHT world AABB and never the broadphase leaf's fat + /// box, so the answer is not a function of a tuning constant (§1.11.12). The + /// half-space arm is the corner PREDICATE, which never builds a box at all — the two + /// arms agree on what "meets" means without sharing a representation. + pub fn aabbOverlapsBody( + self: *const BodyManager, + store: *const ShapeStore, + id: BodyId, + query_box: Aabbr, + ) ?bool { + const idx = self.alloc.validate(id) orelse return null; + const shape = store.get(self.bodies.items(.shape)[idx]) orelse return null; + return switch (shape.class()) { + .convex => worldAabb(shape, self.bodies.items(.position)[idx], self.bodies.items(.rotation)[idx]) + .overlaps(query_box), + .half_space => narrowphase.plane.aabbOverlaps( + Real, + shape_mod.halfSpace(shape).transformed( + self.bodies.items(.rotation)[idx], + self.bodies.items(.position)[idx], + ), + query_box, + ), + }; } /// Whether the world-space `point` lies inside body `id`, boundary INCLUDED — @@ -594,7 +733,10 @@ pub const BodyManager = struct { const shape = store.get(self.bodies.items(.shape)[idx]) orelse return null; const local = self.bodies.items(.rotation)[idx].conjugate() .rotateVec3(point.sub(self.bodies.items(.position)[idx])); - return narrowphase.containsPoint(Real, shape_mod.supportShape(shape), local); + return switch (shape.class()) { + .convex => narrowphase.containsPoint(Real, shape_mod.supportShape(shape), local), + .half_space => narrowphase.plane.containsPoint(Real, shape_mod.halfSpace(shape), local), + }; } /// Closest point on body `id`'s SURFACE to the world-space `point`, with the @@ -633,6 +775,26 @@ pub const BodyManager = struct { ) ?BodyClosestPoint { const idx = self.alloc.validate(id) orelse return null; const shape = store.get(self.bodies.items(.shape)[idx]) orelse return null; + + // The half-space answers in closed form, and it answers BOTH regimes at once: + // `closestPoint` carries the solidity convention itself, returning distance 0 + // and the queried point for an interior point rather than projecting it onto + // the boundary. The point is transported into the body's local frame the way + // `containsPointBody` transports one, and only the POSITION needs mapping back + // — a distance is invariant under a rigid transform. + if (shape.class() == .half_space) { + const projection = narrowphase.plane.closestPoint( + Real, + shape_mod.halfSpace(shape), + self.bodies.items(.rotation)[idx].conjugate() + .rotateVec3(point.sub(self.bodies.items(.position)[idx])), + ); + return .{ + .distance = projection.distance, + .position = self.bodies.items(.rotation)[idx].rotateVec3(projection.position) + .add(self.bodies.items(.position)[idx]), + }; + } const shape_b = shape_mod.supportShape(shape); // Inside the solid — boundary included — is distance 0 at the point itself. @@ -728,15 +890,64 @@ pub const BodyManager = struct { const ib = self.alloc.validate(b) orelse return null; const shape_a = store.get(self.bodies.items(.shape)[ia]) orelse return null; const shape_b = store.get(self.bodies.items(.shape)[ib]) orelse return null; - return narrowphase.collideOrdered( - Real, - shape_mod.supportShape(shape_a), - self.bodies.items(.position)[ia], - self.bodies.items(.rotation)[ia], - shape_mod.supportShape(shape_b), - self.bodies.items(.position)[ib], - self.bodies.items(.rotation)[ib], - ); + const pos_a = self.bodies.items(.position)[ia]; + const rot_a = self.bodies.items(.rotation)[ia]; + const pos_b = self.bodies.items(.position)[ib]; + const rot_b = self.bodies.items(.rotation)[ib]; + + // Dispatch on the pair of CATEGORIES, nested and exhaustive with no `else` on + // either level: the mesh (M1.1.11.1) is a compile error at all four arms, each of + // which owes it a decision. + // + // The half-space arm is written with the PLANE as A, which is the orientation + // §1.11.15's formulas are stated in. When the plane is B — reachable, since this + // order is the body-id order and the plane may have been created either side — + // the pair is computed the other way round and the normal negated, which is + // exactly what `collidePair` does one level up for the caller's order. Position + // and penetration are order-independent, so nothing else needs mirroring, and + // `contact_constraint.prepare` reconstructs the two anchors from + // `position ± ½·penetration·normal` with no case for this shape. + switch (shape_a.class()) { + .convex => switch (shape_b.class()) { + .convex => return narrowphase.collideOrdered( + Real, + shape_mod.supportShape(shape_a), + pos_a, + rot_a, + shape_mod.supportShape(shape_b), + pos_b, + rot_b, + ), + .half_space => { + var m = narrowphase.collidePlane( + Real, + shape_mod.halfSpace(shape_b), + pos_b, + rot_b, + narrowphase.RelativePose(Real).init(pos_b, rot_b, pos_a, rot_a), + shape_mod.supportShape(shape_a), + ) orelse return null; + m.normal = m.normal.neg(); // computed B→A; the caller asked A→B + return m; + }, + }, + .half_space => switch (shape_b.class()) { + .convex => return narrowphase.collidePlane( + Real, + shape_mod.halfSpace(shape_a), + pos_a, + rot_a, + narrowphase.RelativePose(Real).init(pos_a, rot_a, pos_b, rot_b), + shape_mod.supportShape(shape_b), + ), + // Two half-spaces have NO narrowphase kernel — §1.11.15's table is a + // half-space against a BOUNDED CONVEX — and the pair is unreachable: a + // half-space forces a static body (`error.ShapeMustBeStatic`) and + // `default_layer_pairs` has static×static false, so the broadphase never + // emits it. A dated unreachability, asserted rather than answered. + .half_space => unreachable, + }, + } } }; @@ -774,7 +985,17 @@ fn deepCoreWitness(simplex: []const narrowphase.Simplex(Real).Vertex) Vec3r { /// `pub` since M1.1.10 / E5: a shape CAST needs the initial world AABB of a shape /// that is not a body — the query's own — to size the swept traversal /// (`engine-physics-forge.md` §1.11.10). `bodyAabb` is the body-level wrapper. +/// +/// **PRECONDITION: the shape is a bounded CONVEX.** A half-space has no world AABB +/// at all, and an infinite box does not degrade the BVH, it destroys it: its centre +/// is `(−inf + inf)·0.5`, i.e. NaN, which is the ray origin a shape cast derives +/// from a box; its surface area is infinite, so the SAH cost is infinite at every +/// candidate; and the union carries the infinity to the root, after which every +/// query visits every node (§1.11.15). The plane is asked a PREDICATE instead. The +/// class is asserted rather than left to fall through to the `unreachable` below, +/// because the class is the information the failure should carry. pub fn worldAabb(shape: Shape, pos: Vec3r, rot: Quatr) Aabbr { + std.debug.assert(shape.class() == .convex); switch (shape.shape_type) { .sphere => return Aabbr.fromCenterHalfExtents(pos, Vec3r.splat(shape.radius)), .box => { @@ -801,8 +1022,10 @@ pub fn worldAabb(shape: Shape, pos: Vec3r, rot: Quatr) Aabbr { const cap1 = Aabbr.fromMinMax(p1.sub(rr), p1.add(rr)); return cap0.merge(cap1); }, - // ShapeStore only admits sphere/box/capsule (createShape rejects the - // rest with error.UnsupportedShape), so no other tag can reach here. + // The store admits sphere/box/capsule and — since M1.1.11 — the plane, and + // rejects every other variant with `error.UnsupportedShape`. The plane is + // excluded by the class precondition above, so the three arms are exhaustive + // over what can reach here. else => unreachable, } } diff --git a/src/modules/forge/forge_3d/pipeline/broadphase.zig b/src/modules/forge/forge_3d/pipeline/broadphase.zig index 902bc90..dfb059e 100644 --- a/src/modules/forge/forge_3d/pipeline/broadphase.zig +++ b/src/modules/forge/forge_3d/pipeline/broadphase.zig @@ -258,6 +258,37 @@ pub fn Bvh(comptime T: type) type { self.queryNode(node.child2, query, collector); } + /// Half-space query: `collector.add(user_data)` for every proxy whose stored (fat) + /// AABB meets the CLOSED half-space `{ x : normal·x <= distance }`. Returns the + /// number of nodes visited. + /// + /// An exact mirror of `queryAabb` with one predicate swapped — the eight-branch + /// corner test of `Aabb.overlapsHalfSpace` instead of the box overlap. This is + /// the broadphase role of an unbounded shape: it is never asked for a box, it is + /// asked whether it meets one (`engine-physics-forge.md` §1.11.15). + /// + /// Pruning on the FAT box is conservative in the safe direction: the fat box + /// contains the tight one, so a subtree the half-space does not meet contains no + /// body it could touch. The surplus candidates a fat box admits are the exact + /// kernel's to reject, exactly as for `queryAabb` (§1.11.2). + pub fn queryHalfSpace(self: *const Self, normal: Vec3T, distance: T, collector: anytype) u32 { + if (self.root == null_index) return 0; + return self.queryHalfSpaceNode(self.root, normal, distance, collector); + } + + /// Recursive half of `queryHalfSpace` — prune on non-overlap, collect leaves, + /// descend otherwise. Each touched node counts as one visit. + fn queryHalfSpaceNode(self: *const Self, index: u32, normal: Vec3T, distance: T, collector: anytype) u32 { + const node = self.nodes.items[index]; + if (!node.aabb.overlapsHalfSpace(normal, distance)) return 1; // visited then pruned + if (isLeaf(node)) { + collector.add(node.user_data); + return 1; + } + return 1 + self.queryHalfSpaceNode(node.child1, normal, distance, collector) + + self.queryHalfSpaceNode(node.child2, normal, distance, collector); + } + /// Ray type for this scalar. pub const RayT = Ray(T); @@ -752,6 +783,10 @@ pub const BroadphaseLayer = enum(u8) { /// Number of broad layers. pub const layer_count = @typeInfo(BroadphaseLayer).@"enum".fields.len; +/// Sentinel for "no free unbounded slot" — a list index that can never exist, the same +/// role `Bvh`'s `null_index` plays for its node pool. +const null_slot: u32 = std.math.maxInt(u32); + /// Default layer-pair matrix — symmetric; `true` = the two layers produce /// candidate pairs. Indexed by `@intFromEnum(BroadphaseLayer)` /// (static=0, dynamic=1, debris=2, trigger=3). Allowed: dynamic×dynamic, @@ -780,12 +815,70 @@ pub fn Broadphase(comptime T: type) type { /// Tuning config type for this scalar. pub const Config = BroadphaseConfig(T); - /// A proxy handle: the owning layer plus its tree-local proxy id. + /// Which structure a `Proxy` names. An UNBOUNDED shape has no AABB, so it + /// cannot live in a tree at all (`engine-physics-forge.md` §1.11.15): it lives in + /// a flat per-layer list, and the handle has to say which of the two it indexes. + pub const ProxyKind = enum { + /// A leaf of the layer's `Bvh` — `id` is a node-pool index. + tree, + /// A slot of the layer's unbounded list — `id` is a list index. + unbounded, + }; + + /// A proxy handle: the owning layer, WHICH STRUCTURE it lives in, and its + /// structure-local id. pub const Proxy = struct { layer: BroadphaseLayer, + /// Added at M1.1.11. Every consumer switches on it exhaustively, so the + /// third structure a later shape category might need is a compile error at + /// each site rather than a silent mis-index into the wrong pool. + kind: ProxyKind = .tree, id: u32, }; + /// An unbounded shape as the broadphase sees it: NOT a box, a half-space + /// `{ x : normal·x <= distance }` in WORLD space, carried by value. + /// + /// Deliberately a local type rather than a shared one with the narrowphase's + /// `plane.HalfSpace`: this file imports `foundation` only, and what is shared is + /// the FORMULA — `Aabb.overlapsHalfSpace`, which lives in `foundation/math` + /// precisely so both callers use one copy of it. Two field names are not a + /// duplicated formula. + pub const UnboundedShape = struct { + /// Outward unit normal; the solid is the `normal·x <= distance` side. + normal: Vec3T, + /// Offset along `normal` (metres). + distance: T, + }; + + /// One slot of a layer's unbounded list. + /// + /// **What the in-place retirement defends is the slot's IDENTITY, not its + /// existence.** A live `Proxy` holds a list INDEX, so no operation may move + /// another slot: compaction would shift later entries down and silently re-point + /// every `Proxy` past the hole. REUSE is a different matter and is fine — a LIFO + /// free-list hands the same index back to a new shape and moves nothing — which is + /// exactly what `Bvh` does one level up with `freeNode` / `allocateNodeAssumeCapacity` + /// in this same file. Without the free-list the list grew monotonically with every + /// plane ever created, and its visit cost with it (M1.1.11/E7-J3); with it, the + /// bound becomes the live PEAK, which is stated on the `unbounded` field. + /// + /// A dead slot links to the next free one through `user_data`, which is meaningless + /// while dead — the same field-reuse `Bvh` performs on a free node's `parent`. + /// + /// The stale-index exposure a free-list carries is BOUNDED here, and by more than + /// precedent. A `moved_unbounded` entry naming a slot that was freed and reused + /// before `computePairs` ran now names the NEW occupant — which was itself logged + /// on insertion, so the effect is a duplicate entry, and `computePairs` sorts and + /// adjacent-dedupes its output. A freed and NOT reused slot fails `live` and is + /// skipped. Neither produces a wrong pair. + const UnboundedSlot = struct { + shape: UnboundedShape, + /// The caller's opaque payload while live; the next free slot index while dead. + user_data: u32, + live: bool, + }; + /// A candidate overlap between two proxies, by `user_data`, canonical /// (`a < b`). pub const Pair = struct { @@ -799,19 +892,76 @@ pub fn Broadphase(comptime T: type) type { /// `computePairs`; may hold duplicates or stale ids — both are handled /// there (pair-set dedup; `isLiveLeaf` skip for a freed id). moved: [layer_count]std.ArrayListUnmanaged(u32), + /// Per-layer flat list of UNBOUNDED shapes, outside the trees (§1.11.15). No hashed + /// container, here as everywhere on this path (determinism by construction, + /// M1.1.14). + /// + /// **Iteration follows the slot INDEX, which is not insertion order.** The contract + /// §1.11.15 states is exactly three clauses: a slot's index never moves while a + /// proxy holds it, a retired slot is recycled LIFO, and iteration follows the + /// index. So `A, B, C`, retire `A`, insert `D` iterates `D, B, C` — `D` took `A`'s + /// slot. That is the WANTED behaviour, not a tolerated side effect of the + /// free-list. + /// + /// It suffices because what M1.1.14 requires is not insertion order but that the + /// iteration order be a DETERMINISTIC FUNCTION OF THE OPERATION SEQUENCE — which + /// LIFO recycling satisfies exactly, the free-list head being itself a function of + /// that sequence. And no observable result depends on the order in the first place: + /// the query entries sort their hits by the §1.11.14 key `(distance, entity, + /// BodyId)`, and `computePairs` sorts by the canonical packed pair key and + /// adjacent-dedupes. The list order reaches no answer. + /// + /// **The length is the PEAK of SIMULTANEOUSLY live slots in this layer, not the + /// live count.** `items.len` never decreases: retirement recycles a slot, it does + /// not remove it, so a layer that once held nine half-spaces at once keeps nine + /// slots forever even when eight are dead. What the free-list removed is the + /// growth with the TOTAL EVER CREATED, which was the pathology — an unbounded + /// monotonic visit cost in the number of planes a session has ever built — and + /// that is strictly better without being dense. + /// + /// The peak is acceptable because of what a half-space IS here: `addBody` rejects + /// any non-static body carrying one (`error.ShapeMustBeStatic`, §1.11.15), so the + /// population is authored level geometry rather than gameplay churn, and its peak + /// is a quantity the scene author controls directly. MEASURED at 1 in every scene + /// in this repo, including the two benches. + /// + /// A DENSE list was considered and deliberately NOT built. 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 to keep them + /// valid. That is the cost, and it buys iteration in O(live) rather than O(peak). + /// The trigger for paying it is a real scene that CHURNS half-spaces — creating and + /// destroying them during play rather than at load — and no such scene exists yet. + unbounded: [layer_count]std.ArrayListUnmanaged(UnboundedSlot), + /// Per-layer LIFO free-list head over `unbounded`, or `null_slot` when empty. A + /// dead slot's `user_data` is the link to the next (see `UnboundedSlot`). + unbounded_free: [layer_count]u32, + /// Per-layer log of unbounded slots inserted since the last `computePairs` — + /// the second pairing direction's driver. Consumed (cleared) there. + /// + /// A separate log from `moved`, and not merely for tidiness: the two are + /// crossed against DIFFERENT structures. A moved bounded proxy is crossed with + /// the unbounded LISTS; a newly inserted unbounded shape is crossed with the + /// TREES. Sharing one log would lose which of the two a given id needs. + moved_unbounded: [layer_count]std.ArrayListUnmanaged(u32), /// A broadphase with the given tuning and no proxies. pub fn init(config: Config) Self { - var self: Self = .{ .trees = undefined, .moved = undefined }; + var self: Self = .{ .trees = undefined, .moved = undefined, .unbounded = undefined, .unbounded_free = undefined, .moved_unbounded = undefined }; for (&self.trees) |*t| t.* = BvhT.init(config); for (&self.moved) |*m| m.* = .empty; + for (&self.unbounded) |*u| u.* = .empty; + for (&self.unbounded_free) |*f| f.* = null_slot; + for (&self.moved_unbounded) |*m| m.* = .empty; return self; } - /// Release every layer tree and moved-log. + /// Release every layer tree, moved-log and unbounded list. pub fn deinit(self: *Self, gpa: std.mem.Allocator) void { for (&self.trees) |*t| t.deinit(gpa); for (&self.moved) |*m| m.deinit(gpa); + for (&self.unbounded) |*u| u.deinit(gpa); + for (&self.moved_unbounded) |*m| m.deinit(gpa); self.* = undefined; } @@ -835,13 +985,78 @@ pub fn Broadphase(comptime T: type) type { try self.moved[li].ensureUnusedCapacity(gpa, 1); const id = try self.trees[li].insert(gpa, tight_aabb, user_data); self.moved[li].appendAssumeCapacity(id); - return .{ .layer = layer, .id = id }; + return .{ .layer = layer, .kind = .tree, .id = id }; + } + + /// Insert an UNBOUNDED shape into `layer`'s flat list, outside the trees, and log + /// it so the next `computePairs` crosses it with the existing leaves. + /// + /// Precondition: `user_data` unique across ALL proxies of every layer and every + /// structure — the same requirement `insert` carries, and for the same reason + /// (`computePairs` treats it as the identity that excludes a self-match). + /// + /// Atomic: on error (OOM) the broadphase is unchanged. Both slots are reserved + /// before either list is appended to, so an entry can never exist unlogged — the + /// same reserve-then-mutate ordering `insert` uses, and here it matters more: an + /// unlogged unbounded shape would never be crossed with the existing leaves at + /// all, and nothing would ever log it again (a half-space is static and never + /// re-enters a moved log). + pub fn insertUnbounded(self: *Self, gpa: std.mem.Allocator, layer: BroadphaseLayer, shape: UnboundedShape, user_data: u32) !Proxy { + const li = @intFromEnum(layer); + // Both fallible steps precede EVERY mutation, so an OOM leaves the broadphase + // bit-unchanged and the call is retryable — and an entry can never exist + // unlogged (see the doc comment). + // + // The two reserves are NOT symmetric, and the asymmetry is the point. + // `moved_unbounded` always receives an entry, so its reserve is unconditional. + // `unbounded` receives one only when the free-list is empty, and + // `ensureUnusedCapacity(gpa, 1)` guarantees room for `len + 1` — so at + // `len == capacity` it GROWS the list. Reserving it unconditionally would + // therefore allocate for a slot the free-list is about to hand back, and would + // fail an insertion that already has every byte it needs. Reading the head + // before the reserve mutates nothing, so the ordering guarantee is intact. + const head = self.unbounded_free[li]; + try self.moved_unbounded[li].ensureUnusedCapacity(gpa, 1); + if (head == null_slot) try self.unbounded[li].ensureUnusedCapacity(gpa, 1); + const id = blk: { + // LIFO reuse first (the `Bvh.allocateNodeAssumeCapacity` shape): an + // identical op sequence therefore reuses indices identically, which is what + // keeps the whole path a pure function of that sequence (M1.1.14). + if (head != null_slot) { + self.unbounded_free[li] = self.unbounded[li].items[head].user_data; // next free + self.unbounded[li].items[head] = .{ .shape = shape, .user_data = user_data, .live = true }; + break :blk head; + } + const fresh: u32 = @intCast(self.unbounded[li].items.len); + self.unbounded[li].appendAssumeCapacity(.{ .shape = shape, .user_data = user_data, .live = true }); + break :blk fresh; + }; + self.moved_unbounded[li].appendAssumeCapacity(id); + return .{ .layer = layer, .kind = .unbounded, .id = id }; } - /// Remove a proxy. A lingering moved-log entry for it is harmless — the - /// freed slot fails `isLiveLeaf` and is skipped by `computePairs`. + /// Remove a proxy. A lingering moved-log entry for it is harmless — a freed tree + /// slot fails `isLiveLeaf` and a retired unbounded slot fails its `live` flag, and + /// `computePairs` skips both. + /// + /// Exhaustive on `ProxyKind`, no `else`: a third structure would be a compile + /// error here rather than a removal that silently indexes the wrong pool. pub fn remove(self: *Self, proxy: Proxy) void { - self.trees[@intFromEnum(proxy.layer)].remove(proxy.id); + const li = @intFromEnum(proxy.layer); + switch (proxy.kind) { + .tree => self.trees[li].remove(proxy.id), + // Retired IN PLACE and pushed onto the LIFO free-list: the slot's INDEX + // must not move (a live `Proxy` holds one), but the slot itself is + // recycled, so creating and destroying planes stops growing the list with + // the TOTAL EVER CREATED. `items.len` does not decrease — the bound is the + // peak of simultaneously live slots, and why that is acceptable, plus the + // dense-list option and its trigger, are on the `unbounded` field. + .unbounded => { + self.unbounded[li].items[proxy.id].live = false; + self.unbounded[li].items[proxy.id].user_data = self.unbounded_free[li]; + self.unbounded_free[li] = proxy.id; + }, + } } /// Move a proxy to `tight_aabb`. Marks it moved only when the tree @@ -856,6 +1071,13 @@ pub fn Broadphase(comptime T: type) type { /// lost forever. The reserve is unconditional; `computePairs` retains /// the log's capacity, so in steady state it is a no-op. pub fn update(self: *Self, gpa: std.mem.Allocator, proxy: Proxy, tight_aabb: AabbT) !void { + // An UNBOUNDED shape has no box to move to, and it cannot move at all: a + // half-space forces a STATIC body (`addBody` rejects any other with + // `error.ShapeMustBeStatic`, §1.11.15), so it never re-enters a moved log and + // its pairs are established once, at insertion, and then carried by the + // retention rule of §1.7 step 2. Asking it to move is a caller error rather + // than a no-op to absorb. + std.debug.assert(proxy.kind == .tree); const li = @intFromEnum(proxy.layer); try self.moved[li].ensureUnusedCapacity(gpa, 1); if (self.trees[li].update(proxy.id, tight_aabb)) { @@ -869,9 +1091,29 @@ pub fn Broadphase(comptime T: type) type { pub fn queryAabb(self: *const Self, query: AabbT, collector: anytype) u32 { var visited: u32 = 0; for (&self.trees) |*t| visited += t.queryAabb(query, collector); + self.visitUnbounded(query, collector); return visited; } + /// Offer every live unbounded shape the `query` box MEETS to `collector` + /// (M1.1.11, §1.11.1 point 3 as amended). Shared by the three query entries so + /// they cannot drift in which structures they visit. + /// + /// The returned visited-node count is deliberately NOT incremented by these + /// entries: that metric attests the TREES' logarithmic property, which the + /// acceptance suite asserts on, and the unbounded lists are an explicitly LINEAR + /// cost in a per-layer count that is one in a normal scene. Folding the two + /// together would make a logarithmic claim unreadable. + fn visitUnbounded(self: *const Self, query: AabbT, collector: anytype) void { + for (&self.unbounded) |*list| { + for (list.items) |slot| { + if (!slot.live) continue; + if (!query.overlapsHalfSpace(slot.shape.normal, slot.shape.distance)) continue; + collector.add(slot.user_data); + } + } + } + /// Ray type for this scalar. pub const RayT = Ray(T); @@ -897,6 +1139,19 @@ pub fn Broadphase(comptime T: type) type { if (collector.shouldStop()) break; visited += t.queryCast(ray, extent, collector); } + // The UNBOUNDED lists, after the trees (M1.1.11). Offered UNCONDITIONALLY — + // there is no box to run the slab test against, which is the very reason a + // half-space is not in a tree — so the collector's exact kernel decides, and + // the bound cannot prune here. `shouldStop()` is still honoured, exactly as it + // is between the four trees: an `any` query that already found its candidate + // walks nothing further. + for (&self.unbounded) |*list| { + for (list.items) |slot| { + if (collector.shouldStop()) return visited; + if (!slot.live) continue; + collector.add(slot.user_data); + } + } return visited; } @@ -915,6 +1170,10 @@ pub fn Broadphase(comptime T: type) type { out.clearRetainingCapacity(); var sink = PairSink{ .out = out, .gpa = gpa }; + + // DIRECTION (1) — a moved BOUNDED proxy is crossed with the trees it may pair + // with, and (M1.1.11) with their UNBOUNDED LISTS as well. Omitting the second + // half makes a body created after a plane collide with nothing. for (0..layer_count) |li| { for (self.moved[li].items) |proxy| { if (!self.trees[li].isLiveLeaf(proxy)) continue; // stale id @@ -923,12 +1182,43 @@ pub fn Broadphase(comptime T: type) type { for (0..layer_count) |lj| { if (!default_layer_pairs[li][lj]) continue; _ = self.trees[lj].queryAabb(p_aabb, &sink); + for (self.unbounded[lj].items) |slot| { + if (!slot.live) continue; + if (!p_aabb.overlapsHalfSpace(slot.shape.normal, slot.shape.distance)) continue; + sink.add(slot.user_data); + } + } + } + } + + // DIRECTION (2) — a newly inserted UNBOUNDED shape enumerates the existing + // leaves of the layers it may pair with. **The omission of this direction is + // invisible in any scene that creates the plane first**, which is every + // naively written test: pair generation is moved-driven, so the bodies' own + // insertions would have covered it. Create the plane last and only this loop + // can produce those pairs. + // + // An unbounded shape is NOT crossed with the other unbounded lists. Two + // half-spaces have no narrowphase kernel — §1.11.15's table is a half-space + // against a bounded convex — and the only reachable combination is forbidden + // anyway: a half-space forces a static body, and `default_layer_pairs` has + // static×static false. A dated unreachability, named rather than left to be + // discovered. + for (0..layer_count) |li| { + for (self.moved_unbounded[li].items) |slot_id| { + const slot = self.unbounded[li].items[slot_id]; + if (!slot.live) continue; // retired before the pairs were computed + sink.moved_ud = slot.user_data; + for (0..layer_count) |lj| { + if (!default_layer_pairs[li][lj]) continue; + _ = self.trees[lj].queryHalfSpace(slot.shape.normal, slot.shape.distance, &sink); } } } if (sink.err) |e| return e; for (&self.moved) |*m| m.clearRetainingCapacity(); + for (&self.moved_unbounded) |*m| m.clearRetainingCapacity(); std.mem.sort(Pair, out.items, {}, pairLess); dedupAdjacent(out); diff --git a/src/modules/forge/forge_3d/pipeline/narrowphase/manifold.zig b/src/modules/forge/forge_3d/pipeline/narrowphase/manifold.zig index 4d208ab..a43f828 100644 --- a/src/modules/forge/forge_3d/pipeline/narrowphase/manifold.zig +++ b/src/modules/forge/forge_3d/pipeline/narrowphase/manifold.zig @@ -44,6 +44,9 @@ const support = @import("support.zig"); const gjk_mod = @import("gjk.zig"); const epa_mod = @import("epa.zig"); const fast_paths = @import("fast_paths.zig"); +// M1.1.11 — the half-space geometry the plane arm works on. A sibling import: `plane.zig` +// does not import this file, so there is no cycle. +const plane_mod = @import("plane.zig"); /// The contact manifold between two shapes: a shared world-space contact /// `normal` (A→B) plus up to 4 `ContactPoint`s. FROZEN convention (brief Notes); @@ -329,6 +332,87 @@ fn pointCoreContact(comptime T: type, n_world: math.Vec(3, T), closest_a: math.V return oneContact(T, n_world, sa.add(sb).scale(0.5), @max(base_penetration, 0), feature_id); } +/// **The half-space arm of the manifold generator** (`engine-physics-forge.md` +/// §1.11.15). `plane` is expressed in A's frame — A being the HALF-SPACE — and +/// `relpose` carries B relative to A, the same shape GJK and `separation` take. +/// Returns `null` when the pair is separated, exactly as `collideOrdered` does. +/// +/// The supporting face of B in direction `−n` yields up to four CORE vertices with +/// their stable local ids; each one whose surface point lies below the boundary is a +/// contact. +/// +/// **NO CLIPPING RUNS, and that is a property rather than an omission.** The clip +/// exists to cut an incident polygon against the reference face's SIDE PLANES, and a +/// half-space has none: it is unbounded, there is nothing to cut against, so the +/// costlier half of `generateManifold` — `clipIncident`, the ≤4 reduction, the +/// reference/incident selection and the `faceNormalA` alignment test — has nothing to +/// do here. The face already has at most four vertices and they are already the +/// answer. +/// +/// **The retained criterion is `sep <= 0`, EXACTLY, and `keep_eps` is deliberately not +/// copied.** The generic generator carries a scale-relative epsilon because its +/// candidates come out of a CLIP: each intersection point 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: +/// `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. +/// +/// The returned `position` is the MIDPOINT of the convex's surface point +/// `core_vertex − r_b·n` and its projection on the boundary, which is §3's definition +/// verbatim: with `penetration = −sep` and the normal A→B, `position + ½·pen·n` is A's +/// surface point and `position − ½·pen·n` is B's — the two anchors +/// `contact_constraint.prepare` reconstructs, with no special case for this shape. +pub fn collidePlane( + comptime T: type, + plane: plane_mod.HalfSpace(T), + pos_a: math.Vec(3, T), + rot_a: math.Quat(T), + relpose: support.RelativePose(T), + shape_b: support.SupportShape(T), +) ?ContactManifold(T) { + const Vec3T = math.Vec(3, T); + plane.assertDomain(); + const n_a = plane.normal; + const r_b = shape_b.radius; + + // B's supporting feature in the direction that digs into the solid, in A's frame. + // Its `vert_ids` are the stable local identities `feature_id` needs (a box corner's + // sign pattern, a segment endpoint's index), NOT buffer positions. + const face_b = relpose.supportingFaceB(shape_b, n_a.neg()); + + var points: [4]ContactPoint(T) = undefined; + var count: usize = 0; + for (0..face_b.count) |i| { + // The convex's SURFACE point: the core vertex pushed out by the inflation + // radius toward the solid. Omitting `r_b` here would place a sphere's contact + // at its centre — the failure the core + radius convention exists to prevent. + const surface_b = face_b.verts[i].sub(n_a.scale(r_b)); + const sep = plane.signedDistance(surface_b); + if (sep > 0) continue; + points[count] = .{ + .position = rot_a.rotateVec3(surface_b.sub(n_a.scale(sep * 0.5))).add(pos_a), + .penetration = -sep, + .feature_id = featureId( + class_plane | plane_face_id, + class_a | (@as(u16, face_b.vert_ids[i]) & id_mask), + ), + }; + count += 1; + } + + // `count == 0` IS the separated verdict, and it needs no separate test: the + // supporting face in direction `−n` contains `supportCore_B(−n)`, so its own + // separation is the pair's, and no vertex is below the boundary exactly when the + // pair is disjoint. + if (count == 0) return null; + const zero = ContactPoint(T){ .position = Vec3T.zero, .penetration = 0, .feature_id = 0 }; + for (count..4) |i| points[i] = zero; + return .{ .normal = rot_a.rotateVec3(n_a), .points = points, .count = @intCast(count) }; +} + // --- Internal helpers --- /// Largest number of points the incident clip can transiently hold: an incident @@ -410,12 +494,45 @@ fn faceNormalA(comptime T: type, face: support.Face(T), expected_axis: math.Vec( // reference half: face (`class_a`), side plane / edge (`class_edge`), vertex / // corner (`class_c`); incident half: vertex (`class_a`), edge (`class_edge`), // face (`class_c`). -const class_a: u16 = 0x0000; // reference face / incident vertex -const class_edge: u16 = 0x4000; // reference side plane / incident edge -const class_c: u16 = 0x8000; // reference vertex (corner) / incident face -const class_mask: u16 = 0xc000; +/// Class tag: a reference FACE, or an incident VERTEX. +pub const class_a: u16 = 0x0000; +/// Class tag: a reference SIDE PLANE, or an incident EDGE. +pub const class_edge: u16 = 0x4000; +/// Class tag: a reference VERTEX (a corner), or an incident FACE. +pub const class_c: u16 = 0x8000; +/// The HALF-SPACE producer's reference tag (M1.1.11): the plane's boundary face. +/// +/// A FOURTH class value rather than a free PAIR of the existing three, and the +/// difference is what makes the disjointness structural: the four existing producers +/// OR only `class_a` / `class_edge` / `class_c` into either half, so no value they can +/// emit carries `class_plane` in its reference half, and a test asserts the plane +/// producer's id by MASK instead of by enumerating which pairs happen to be taken. The +/// pair `(class_a, class_c)` was already the single-witness producer's, so a free pair +/// was not free. +pub const class_plane: u16 = 0xc000; +/// The 2-bit class field of a `feature_id` half. Masking a half with this yields its +/// producer class, which is how the half-space producer's disjointness is asserted. +pub const class_mask: u16 = 0xc000; const id_mask: u16 = 0x3fff; +comptime { + // The four tags are pairwise distinct, and each is exactly a value of the 2-bit + // class field. This is the BY-CONSTRUCTION half of the disjointness claim: the + // producers below OR one of these into each half and nothing else, so a tag that is + // distinct here cannot be emitted by another producer there. + const tags = [_]u16{ class_a, class_edge, class_c, class_plane }; + for (tags, 0..) |x, i| { + std.debug.assert(x & id_mask == 0); + std.debug.assert(x & class_mask == x); + for (tags[i + 1 ..]) |y| std.debug.assert(x != y); + } +} + +/// The plane's own reference feature id, under `class_plane`. A half-space has exactly +/// ONE face — its boundary — so this is a constant rather than a selection, and the +/// remaining `id_mask` space stays free for whatever a later unbounded shape needs. +const plane_face_id: u16 = 0; + /// Pack a `feature_id` from a class-tagged reference half and incident half. fn featureId(ref16: u16, inc16: u16) u32 { return (@as(u32, ref16) << 16) | @as(u32, inc16); diff --git a/src/modules/forge/forge_3d/pipeline/narrowphase/plane.zig b/src/modules/forge/forge_3d/pipeline/narrowphase/plane.zig new file mode 100644 index 0000000..8b49607 --- /dev/null +++ b/src/modules/forge/forge_3d/pipeline/narrowphase/plane.zig @@ -0,0 +1,365 @@ +//! `forge_3d/pipeline/narrowphase/plane.zig` — the analytic half-space kernels +//! (M1.1.11). +//! +//! A `Plane` is the **solid half-space** `{ x : n·x <= d }`, `n` unit and `d` in +//! metres (`engine-physics-forge.md` §1.11.15). It is not a bounded convex: its +//! support map diverges in every direction but `−n`, so it traverses neither GJK, +//! nor EPA, nor the M1.1.10 cast kernel, and it is never converted into a +//! `SupportShape`. The category is chosen upstream, by `Shape.class()`. +//! +//! **Closed forms, and therefore CHEAPER than GJK rather than costlier.** Every +//! kernel below is one support call and a division at most — no descent, no +//! simplex, no restart, no iteration ceiling. That is the compensation for the +//! taxonomy: the shape that does not fit the general machinery is the one that +//! needs none of it. +//! +//! **The sign of the separation IS the classification.** Exactly, with nothing to +//! absorb: there is no accumulated rounding here, no simplex to converge, so §3's +//! three-band `separated` / `shallow` / `deep` regime does not apply and must not +//! be copied in. `sep > 0` is disjoint, `sep <= 0` is penetrating by `−sep`. +//! +//! **Threshold discipline — exactly TWO guards, both at TRUE ZERO**, and both on +//! `n·dir`: one on the ray, one on the cast. No geometric epsilon appears anywhere +//! in this file. A ray or a sweep parallel to the boundary is a miss when it starts +//! outside, and when it starts inside solid membership has already answered before +//! the division is reached. The only named tolerances are the unit-domain asserts, +//! compared against 1, which are float noise and not geometry. +//! +//! **A true-zero guard is exact IN THE FRAME IT IS EVALUATED IN, AND THAT DOES NOT +//! COMPOSE** (§1.11.15). The kernels below work in a local frame, and a rigid +//! transform does not preserve EXACT orthogonality: a ray parallel to the boundary in +//! WORLD, transported into the body's frame by a quaternion built in floating point, +//! arrives with a dot product about one ULP from zero rather than at zero. The guard +//! therefore does not fire, and the kernel reports — correctly — a crossing at +//! `sep / |n·dir|`, of the order of `sep / floatEps(T)`; measured for `sep = 10 m` at +//! 8.3886120e7 m in f32 and 4.5035996e16 m in f64. +//! +//! That is not a kernel defect and **it is not fixed with an epsilon.** The kernel has +//! no range of its own, and inventing one would make the answer depend on a constant — +//! the refusal §1.11.12 already states for the broadphase margin. What rejects such a +//! ray is the query entry's FINITE `max_distance`, which §1.11.4 requires to be finite +//! anyway. +//! +//! **A half-space's conditioning does NOT decompose the way §1.11.4 bis does** +//! (§1.11.15). There the normal is reconstructed and only its length is invariant with +//! distance. Here the contact normal is the STORED `n` returned VERBATIM, with no +//! intermediate arithmetic at all: length AND orientation are exact at any range, and +//! they are asserted as BIT EQUALITY rather than through a bound. The far-field residue +//! therefore moves entirely into the scalar `signedDistance = n·p − d`, a difference of +//! two quantities that both grow with distance from the origin, whose absolute error +//! grows like `floatEps(T)·|p|`. That is the same structural worldspace limit +//! `-Dphysics_f64` answers, characterised and not hidden — so an acceptance suite on +//! this shape asserts the normal TIGHT AND EVERYWHERE, and reserves the scale-relative +//! bound for the scalar alone. +//! +//! **Dependency discipline.** Imports `foundation` (math) and the sibling +//! `support.zig` ONLY — never `gjk.zig`, `epa.zig`, `manifold.zig`, `raycast.zig`, +//! `shapecast.zig`, `weld_forge`, `body*.zig`, `config.zig` or `broadphase.zig`. +//! Identical to `raycast.zig` and `shapecast.zig`; the scalar is the comptime `T` +//! and `forge_3d` instantiates it at `config.Real`. The shared `LocalHit` and +//! `CastHit` live in `support.zig` for exactly this reason: the adapter that +//! dispatches between a convex and a half-space by shape class must return ONE +//! type. + +const std = @import("std"); +const math = @import("foundation").math; +const support = @import("support.zig"); + +/// Slack on a unit-norm domain assert, in ULPs of 1. The comparison is against 1, +/// so this is pure float noise — same constant and same role as `raycast.zig`'s +/// `unit_k` and `shapecast.zig`'s `unit_dir_k`. A half-space transported into +/// another frame by a quaternion rotation costs a handful of ULPs on the norm, +/// which is exactly what this budgets and nothing more. +const unit_k: comptime_int = 16; + +/// A solid half-space `{ x : normal·x <= distance }`, expressed in some frame. +/// +/// Which frame is the CALLER's statement: every kernel below takes the half-space +/// and its other geometric argument in the SAME frame, and `transformed` is how a +/// caller moves one. `ShapeStore` holds the local-frame form, `n` normalised once +/// at creation so no consumer re-normalises. +pub fn HalfSpace(comptime T: type) type { + return struct { + const Self = @This(); + const Vec3T = math.Vec(3, T); + const QuatT = math.Quat(T); + + /// Outward unit normal — the solid lies on the `normal·x <= distance` side. + normal: Vec3T, + /// Offset along `normal` (metres). The boundary plane is `normal·x = distance`. + distance: T, + + /// This half-space expressed in the frame a pose maps INTO: a point `x` of the + /// current frame sits at `rotation·x + translation` in the target one. + /// + /// `{ x : n·x <= d }` becomes `{ y : (q·n)·y <= d + (q·n)·t }`. The derivation + /// is one substitution — `x = conj(q)·(y − t)`, and a rotation preserves the dot + /// product, so `n · conj(q)·v == (q·n) · v` — and it is exact in the sense that + /// matters: the normal is only ROTATED, so it stays unit to the few ULPs the + /// rotation costs, and the offset picks up one dot product. There is no + /// alternative form that avoids that dot product: it IS the plane's distance to + /// the new origin. + pub fn transformed(self: Self, rotation: QuatT, translation: Vec3T) Self { + const n = rotation.rotateVec3(self.normal); + return .{ .normal = n, .distance = self.distance + n.dot(translation) }; + } + + /// `normal·p − distance`: strictly negative inside the solid, zero exactly on + /// the boundary, positive outside. THE scalar of this shape — every kernel + /// below is written on it, so the subtraction that carries the far-field + /// residue happens in ONE place. + pub fn signedDistance(self: Self, p: Vec3T) T { + return self.normal.dot(p) - self.distance; + } + + /// Domain assertion: the normal is UNIT and the distance is FINITE. Renamed from + /// `assertUnit` at M1.1.11/E7-J2, when the second half was added — a name that + /// promised one check while performing two would be worse than either. + /// + /// Neither half is cosmetic. Every kernel treats the normal as unit when it + /// divides by `n·dir`, projects along it, or returns it as a contact normal, and a + /// non-unit normal would scale `signedDistance` silently. + /// + /// A non-finite `distance` is worse, because it produces TWO silent behaviours + /// that CONTRADICT each other. MEASURED with `distance = NaN`, identically at both + /// precisions: + /// + /// - `signedDistance` is NaN, so `sep > 0` is FALSE, so the generator's + /// `if (sep > 0) continue;` does not fire and a contact is emitted — for a + /// unit sphere 1000 m OUTSIDE the solid, `collidePlane` returned a manifold + /// with one point. The plane reports contact with everything. + /// - every comparison in `Aabb.overlapsHalfSpace` is FALSE against a NaN bound, + /// so the predicate answers false for a box at the origin AND for a box 5000 m + /// deep inside. The plane meets nothing and vanishes from the broadphase. + /// + /// One malformed input, and the narrowphase says "touching everything" while the + /// broadphase says "touching nothing". Neither is an error the caller can see. + pub fn assertDomain(self: Self) void { + std.debug.assert(@abs(self.normal.lengthSq() - 1) <= unit_k * std.math.floatEps(T)); + std.debug.assert(std.math.isFinite(self.distance)); + } + }; +} + +/// The closest point on a half-space to a queried point, with the distance to the +/// SOLID (`closestPoint`). +pub fn Projection(comptime T: type) type { + return struct { + /// Distance from the queried point to the solid; 0 for a point inside it, + /// boundary included. + distance: T, + /// The orthogonal projection on the boundary plane, or the queried point + /// itself when that point is already inside the solid. + position: math.Vec(3, T), + }; +} + +/// **Separation between a half-space and a bounded convex B**, and the first row of +/// §1.11.15's kernel table: +/// +/// ``` +/// sep = n · supportCore_B(−n) − r_b − d +/// ``` +/// +/// `plane` is in A's frame — A being the half-space — and `relpose` carries B +/// relative to A, exactly as GJK takes them. The contact normal (A→B) is +/// `plane.normal` VERBATIM, and the penetration is `−sep` when `sep <= 0`; neither +/// needs a function, and inventing one would suggest there is a computation where +/// there is a field. +/// +/// **The `− r_b` term is not a detail.** `SupportShape.support` returns the support +/// of the CORE, radius EXCLUDED — its own doc comment says so — so a sphere's core +/// is a single point at its centre and a capsule's is a segment on its axis. +/// Omitting the term places the contact at the sphere's CENTRE: a unit sphere whose +/// centre sits exactly on the boundary would read as touching, when it is +/// penetrating by its whole radius. That is precisely the error the core + +/// inflation-radius convention exists to make impossible, and a suite that only +/// tested boxes — whose core radius is 0 — would pass with the term missing. +pub fn separation( + comptime T: type, + plane: HalfSpace(T), + relpose: support.RelativePose(T), + shape_b: support.SupportShape(T), +) T { + plane.assertDomain(); + // B's core support in the direction that digs INTO the solid, expressed in A's + // frame. One support call: that is the whole cost of this kernel. + const deepest_core = relpose.supportB(shape_b, plane.normal.neg()); + return plane.signedDistance(deepest_core) - shape_b.radius; +} + +/// Whether `p` lies in the solid half-space, boundary INCLUDED — the same solidity +/// convention as a ray origin inside a shape (§1.11.4) and the same predicate the +/// point query and `closestPoint` consult first (§1.11.12, §1.11.13). +pub fn containsPoint(comptime T: type, plane: HalfSpace(T), p: math.Vec(3, T)) bool { + plane.assertDomain(); + return plane.signedDistance(p) <= 0; +} + +/// Nearest ray↔half-space intersection, or `null` when the ray misses. `origin` and +/// `direction` are in the same frame as `plane`, and `direction` must be unit +/// (asserted). +/// +/// Solid, boundary included: an origin inside is a hit at distance ZERO whose normal +/// is `−direction`, the only choice preserving `normal · direction <= 0` on every hit +/// (§1.11.4). Testing membership first is also what lets the division below assume +/// the origin is strictly outside. +/// +/// **The single guard is at TRUE ZERO, and it covers two cases at once.** From +/// outside, the ray reaches the boundary only while closing on it, i.e. `n·dir < 0`; +/// so `n·dir >= 0` is a miss, and that one comparison rejects both the RECEDING ray +/// (`> 0`) and the PARALLEL one (`== 0`). No epsilon, and no separate branch for the +/// parallel case — which is why a ray parallel to the boundary from outside misses +/// while one parallel from inside was already answered above. +/// +/// Exact IN THIS FRAME, and that does not compose: a ray parallel in WORLD arrives here +/// through a rigid transform about one ULP off orthogonal, so the guard does not fire +/// and the answer is a crossing at `sep / |n·dir|` (§1.11.15, and the file header). The +/// caller's finite `max_distance` is what rejects it; this kernel has no range of its +/// own and must not invent one. +/// +/// The returned normal is the boundary's outward normal, `plane.normal` verbatim: no +/// arithmetic, hence unit at any distance and with an exact orientation, which is +/// strictly stronger than the reconstructed normals of §1.11.4 bis. +pub fn rayShape( + comptime T: type, + plane: HalfSpace(T), + origin: math.Vec(3, T), + direction: math.Vec(3, T), +) ?support.LocalHit(T) { + plane.assertDomain(); + std.debug.assert(@abs(direction.lengthSq() - 1) <= unit_k * std.math.floatEps(T)); + + const sep = plane.signedDistance(origin); + if (sep <= 0) return .{ .distance = 0, .normal = direction.neg() }; + + const closing = plane.normal.dot(direction); + if (closing >= 0) return null; // receding, or parallel from outside + + // `sep > 0` and `closing < 0`, so the quotient is strictly positive and no NaN is + // reachable. Written on `sep` rather than as `(d − n·o) / (n·dir)` so the one + // subtraction that carries the far-field residue stays in `signedDistance`. + return .{ .distance = -sep / closing, .normal = plane.normal }; +} + +/// Cast of the bounded convex `shape_a` along `direction` against the half-space, +/// returning the first touch within `[0, max_distance]` — a CLOSED interval — or +/// `null` on a miss. +/// +/// Everything is in A's frame, A being the shape being cast: `shape_a` is +/// untransformed there, `plane` has been `transformed` into it by the caller, and +/// `direction` is A-frame too. That is the frozen narrowphase discipline and it is +/// also what makes this kernel a closed form — A's support in a FIXED direction +/// `−n` does not change during a pure translation, so one support call answers the +/// whole sweep. +/// +/// ``` +/// sep₀ = n · supportCore_A(−n) − r_a − d (separation at the start pose) +/// t = sep₀ / (−n·dir) (miss when n·dir >= 0) +/// ``` +/// +/// **Initial contact** (`sep₀ <= 0`, already overlapping at the start pose): distance +/// 0, the witness on the HIT BODY — the boundary plane — never the cast origin, and the +/// normal `−direction`. +/// +/// The witness rule is §1.11.11's: a ray's origin necessarily belongs to the body it +/// hit, whereas the centre of a cast shape can be entirely outside it, so returning the +/// origin would give a point that is neither a contact point nor a point of the +/// collider. +/// +/// **The normal is `−direction`, not `n`, and no information is lost by that.** All four +/// kernels 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, and `shapecast.zig`, +/// whose `terminal` documents `−direction` as "the only one keeping +/// `normal · direction <= 0` on every hit". Returning `n` here broke that invariant +/// outright for a cast aimed OUT of the solid: sweeping along `+n` to leave the +/// half-space gave `normal · direction = +1`. +/// +/// Nothing is lost because 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. And for +/// this shape specifically the caller can always recover `n`: it is a stored field of the +/// shape. The invariant, by contrast, is what every consumer relies on and cannot +/// reconstruct. +/// +/// The guard on `n·dir` is the file's SECOND and last, at TRUE ZERO, and it rejects +/// the receding sweep and the parallel one together for the reason `rayShape` gives. +pub fn castShape( + comptime T: type, + plane: HalfSpace(T), + shape_a: support.SupportShape(T), + direction: math.Vec(3, T), + max_distance: T, +) ?support.CastHit(T) { + plane.assertDomain(); + std.debug.assert(@abs(direction.lengthSq() - 1) <= unit_k * std.math.floatEps(T)); + std.debug.assert(std.math.isFinite(max_distance) and max_distance >= 0); + + // A's deepest point ON ITS INFLATED SURFACE along the direction that digs into the + // solid: the core support minus the radius offset. This is where the `− r_a` of the + // separation formula lives, and it is also the point that first touches. + const deepest = shape_a.support(plane.normal.neg()).sub(plane.normal.scale(shape_a.radius)); + const sep0 = plane.signedDistance(deepest); + + if (sep0 <= 0) { + // Already overlapping: the witness is `deepest` projected onto the boundary, and + // the normal is `−direction` (see the doc comment). The two are along different + // axes, which is the same shape of answer `shapecast.zig` gives when its own axis + // has collapsed: a witness from the geometry, a normal from the invariant. + return .{ + .distance = 0, + .point = deepest.sub(plane.normal.scale(sep0)), + .normal = direction.neg(), + }; + } + + const closing = plane.normal.dot(direction); + if (closing >= 0) return null; // receding, or parallel from outside + + const t = sep0 / -closing; + // STRICT exceedance, per §1.11.11: the parameter is a lower bound of the true time + // of impact, so passing the bound proves the contact is out of reach while reaching + // it exactly proves nothing — and the interval is closed. + if (t > max_distance) return null; + + // The touching point is `deepest` advanced by the sweep; it lies on the boundary by + // construction, so no second projection is needed. + return .{ + .distance = t, + .point = deepest.add(direction.scale(t)), + .normal = plane.normal, + }; +} + +/// The closest point on the half-space to `p`, and the distance to the SOLID. +/// +/// `distance = max(0, n·p − d)` and the position is the orthogonal projection — +/// except that a point INSIDE the solid, boundary included, is at distance 0 and its +/// closest point is ITSELF (§1.11.13's solidity convention, the same one +/// `containsPoint` implements). Projecting an interior point would answer a point on +/// the boundary, which is the closest point on the SURFACE and not on the solid. +/// +/// No guard: there is no division. The projection subtracts `sep·n` from `p`, both +/// terms being of the caller's own scale. +pub fn closestPoint(comptime T: type, plane: HalfSpace(T), p: math.Vec(3, T)) Projection(T) { + plane.assertDomain(); + const sep = plane.signedDistance(p); + if (sep <= 0) return .{ .distance = 0, .position = p }; + return .{ .distance = sep, .position = p.sub(plane.normal.scale(sep)) }; +} + +/// Whether a world AABB meets the half-space — the broadphase role of an unbounded +/// shape, which is asked a PREDICATE and never a box of its own (§1.11.15). +/// +/// The eight-branch corner selection itself is `Aabb.overlapsHalfSpace`, in +/// `foundation/math`: it is pure box geometry with no threshold and no physical +/// semantics, and the broadphase — which imports only `foundation` — needs the same +/// formula this table row does. Written twice, the two copies would drift; this row +/// therefore names the kernel and delegates to it. RD-1 of the milestone records the +/// placement. +pub fn aabbOverlaps(comptime T: type, plane: HalfSpace(T), box: math.Aabb(T)) bool { + plane.assertDomain(); + return box.overlapsHalfSpace(plane.normal, plane.distance); +} diff --git a/src/modules/forge/forge_3d/pipeline/narrowphase/raycast.zig b/src/modules/forge/forge_3d/pipeline/narrowphase/raycast.zig index f809af5..2225ca0 100644 --- a/src/modules/forge/forge_3d/pipeline/narrowphase/raycast.zig +++ b/src/modules/forge/forge_3d/pipeline/narrowphase/raycast.zig @@ -5,12 +5,14 @@ //! sphere is a point of radius `r`, a capsule a Y-segment of radius `r`, a box //! the box itself of radius 0 — exactly the `SupportShape(T).Core` split GJK/EPA //! and the fast paths already run on (`engine-physics-forge.md` §1.11.3). A -//! rounded box (`radius > 0` on a box core) is **rejected**, never silently -//! approximated: it FAILS LOUD with `error.UnsupportedShape` rather than miss a -//! hit quietly, the same invariant `createShape` enforces. A core this file does -//! not know cannot exist silently either — the `switch` is exhaustive, so a -//! future `Core` case is a compile error here, which is fail-loud at the earliest -//! possible moment. +//! rounded box (`radius > 0` on a box core) is outside this kernel's shape set: it +//! is an ASSERTED PRECONDITION (`raySupportsShape`), never silently approximated. +//! The typed refusal lives where a CALLER can provoke one, at the two query entries +//! taking a caller-supplied shape handle (§1.11.7, M1.1.11) — through this kernel +//! the error was reachable by no path at all, every stored box converting with +//! `radius = 0`. A core this file does not know cannot exist silently either — the +//! `switch` is exhaustive, so a future `Core` case is a compile error here, which is +//! fail-loud at the earliest possible moment. //! //! **Everything is in the shape's LOCAL frame.** The caller transports the ray //! by the inverse pose and rotates the returned normal back to world (that is @@ -43,17 +45,11 @@ const std = @import("std"); const math = @import("foundation").math; const support = @import("support.zig"); -/// A ray hit on one shape, in that shape's local frame. -pub fn LocalHit(comptime T: type) type { - return struct { - /// Distance along the (unit) direction, `>= 0`. Zero when the origin is - /// inside the solid shape. - distance: T, - /// Outward unit surface normal at the hit point, local frame. At distance - /// zero it is `−direction` (§1.11.4). - normal: math.Vec(3, T), - }; -} +/// A ray hit on one shape, in that shape's local frame. Defined in `support.zig` +/// since M1.1.11: `plane.zig` produces the same type for the half-space, and the +/// `BodyId`-level adapter that dispatches between the two by shape class returns one +/// type rather than two identical ones. +const LocalHit = support.LocalHit; /// Slack allowed on the unit-direction domain assert, in ULPs of 1. The caller /// normalises, then a quaternion inverse-rotation into the local frame costs a @@ -61,25 +57,56 @@ pub fn LocalHit(comptime T: type) type { /// noise at scale 1 and not a geometric tolerance. const unit_k: comptime_int = 16; +/// Whether the ray kernels cover `shape`: every core, EXCEPT a box carrying a +/// non-zero inflation radius. A rounded box's inflated surface is measured by no arm +/// below — the box arm would under-report it by the radius — so it is not part of +/// this kernel's shape set. +/// +/// **The PRECONDITION of `rayShape`, exposed as a predicate** (M1.1.11). Two things +/// it makes structural rather than merely tested: it takes no origin, so the +/// rejection provably belongs to the SHAPE and not to the trajectory; and the two +/// callers that need to decide admissibility ahead of a call test the same condition +/// the assert tests, instead of restating it. +pub fn raySupportsShape(comptime T: type, shape: support.SupportShape(T)) bool { + return !(shape.core == .box and shape.radius != 0); +} + /// Nearest ray↔shape intersection, or `null` when the ray misses. /// /// `origin` and `direction` are in `shape`'s local frame and `direction` must be -/// unit (asserted). A rounded box returns `error.UnsupportedShape` — see the file -/// header on why that is an error and not an approximation. +/// unit (asserted). +/// +/// **PRECONDITION: `raySupportsShape(T, shape)`.** It was a typed +/// `error.UnsupportedShape` until M1.1.11, and the error was reachable through no +/// path at all: `shape.supportShape` gives every box `radius = 0` unconditionally, so +/// no `SupportShape` built from a stored shape can be a rounded box, and a control +/// that has never been seen to fire is a comment with syntax rather than a check +/// (§1.11.3). The typed refusal moved to where a CALLER can cause it — the two query +/// entries taking a caller-supplied shape handle (§1.11.7) — and what stays here is +/// the assert. If Weld ever gives boxes a convex radius, as the reference does, this +/// kernel gains the CASE; it does not regain an error. pub fn rayShape( comptime T: type, shape: support.SupportShape(T), origin: math.Vec(3, T), direction: math.Vec(3, T), -) error{UnsupportedShape}!?LocalHit(T) { +) ?LocalHit(T) { std.debug.assert(@abs(direction.lengthSq() - 1) <= unit_k * std.math.floatEps(T)); - // The shape rejection comes FIRST, before anything looks at the origin: a + // The shape precondition comes FIRST, before anything looks at the origin: a // rounded box is unsupported as a SHAPE, and that answer cannot depend on - // where the ray starts. Placed after the membership test below, an origin - // inside the core would have returned a distance-zero hit and never reached - // the rejection — the exact silent miss this file promises not to allow. - if (shape.core == .box and shape.radius != 0) return error.UnsupportedShape; + // where the ray starts. When this was a typed error placed AFTER the membership + // test below, an origin inside the core returned a distance-zero hit and never + // reached the check — the exact silent miss this file promises not to allow. + // + // The placement is now REDUNDANTLY protected, and the redundancy is the point: + // `containsPoint`'s box arm carries its own unconditional `assert(r == 0)`, taken + // for ANY point, so moving this line below the membership test would not + // reintroduce a silent miss — it would panic one frame deeper, at every origin. + // That is a structural guarantee rather than a sampled one. It is also why + // `containsPoint`'s assert must not be deleted as redundant: it is what makes + // THIS line's position unable to fail silently. + std.debug.assert(raySupportsShape(T, shape)); // Solid convex: inside — boundary included — is a hit at distance zero, and // the normal is `−direction` because no surface normal is defined there @@ -117,7 +144,14 @@ pub fn containsPoint(comptime T: type, shape: support.SupportShape(T), p: math.V switch (shape.core) { .point => return p.lengthSq() <= r * r, .box => |half_extents| { - // rounded box: rejected by `rayShape`, unsupported here + // A rounded box is outside this shape set: the test below measures the + // CORE, so it would under-report the inflated volume by the radius. + // + // **DO NOT DELETE THIS AS REDUNDANT.** It is unconditional and taken for + // ANY point, which is precisely what makes `rayShape`'s precondition + // unable to fail silently if a future edit moves it below the membership + // test: the failure would land here, at every origin, instead of becoming + // a distance-zero hit. The two asserts are one mechanism. std.debug.assert(r == 0); const a = p.abs(); return @reduce(.And, a.data <= half_extents.data); diff --git a/src/modules/forge/forge_3d/pipeline/narrowphase/root.zig b/src/modules/forge/forge_3d/pipeline/narrowphase/root.zig index 1c6efc7..77e5f02 100644 --- a/src/modules/forge/forge_3d/pipeline/narrowphase/root.zig +++ b/src/modules/forge/forge_3d/pipeline/narrowphase/root.zig @@ -19,6 +19,7 @@ const manifold = @import("manifold.zig"); const fast_paths = @import("fast_paths.zig"); const raycast_mod = @import("raycast.zig"); const shapecast_mod = @import("shapecast.zig"); +const plane_mod = @import("plane.zig"); // --- Support layer (support.zig) --- @@ -30,6 +31,12 @@ pub const RelativePose = support.RelativePose; pub const Vertex = support.Vertex; /// A shape's supporting feature (vertex / segment / quad) in a direction. pub const Face = support.Face; +/// A hit on one shape in that shape's local frame (distance + outward normal) — the +/// shared return type of the `raycast.zig` and `plane.zig` ray kernels. +pub const LocalHit = support.LocalHit; +/// One cast hit in the cast shape's frame — the shared return type of the +/// `shapecast.zig` and `plane.zig` cast kernels. +pub const CastHit = support.CastHit; /// Support point of the Minkowski difference of two cores (`pub` for EPA/manifold). pub const minkowskiSupport = support.minkowskiSupport; @@ -70,6 +77,16 @@ pub const collide = manifold.collide; /// Dispatches the M1.1.4 analytic fast paths, falling through to /// `collideOrderedGeneric`. pub const collideOrdered = manifold.collideOrdered; +/// The HALF-SPACE arm of the manifold generator — a half-space against a bounded +/// convex, no clipping (`engine-physics-forge.md` §1.11.15). Null when separated. +pub const collidePlane = manifold.collidePlane; +/// The `feature_id` reference-class tag of the half-space producer, and the 2-bit class +/// mask. Public so a test can assert the producer's disjointness BY MASK rather than by +/// enumerating which of the existing pairs happen to be taken. +pub const feature_class_plane = manifold.class_plane; +/// The 2-bit `feature_id` class mask (see `feature_class_plane`). +pub const feature_class_mask = manifold.class_mask; + /// `collideOrdered` with the fast-path dispatcher bypassed — the generic GJK/EPA /// manifold path. The differential oracle + bench baseline for the M1.1.4 fast /// paths. (`generateManifold` stays package-internal — not re-exported here.) @@ -87,18 +104,17 @@ pub const fastSeed = fast_paths.fastSeed; // --- Ray kernels (analytic ray↔core, raycast.zig) --- -/// A ray hit on one shape, in that shape's local frame (distance + outward normal). -pub const LocalHit = raycast_mod.LocalHit; -/// Nearest ray↔shape intersection in the shape's local frame; `null` on a miss, -/// `error.UnsupportedShape` for a rounded box. +/// Nearest ray↔shape intersection in the shape's local frame; `null` on a miss. +/// Precondition: `raySupportsShape` (no error channel since M1.1.11). pub const rayShape = raycast_mod.rayShape; +/// Whether the ray kernels cover a support shape — `rayShape`'s precondition, exposed +/// so a caller can decide admissibility instead of relying on a debug assert. +pub const raySupportsShape = raycast_mod.raySupportsShape; /// Whether a point lies in the solid shape, boundary included. pub const containsPoint = raycast_mod.containsPoint; // --- Shape-cast kernel (GJK ray march on the Minkowski difference, shapecast.zig) --- -/// One cast hit in A's frame: time of impact, witness on the hit body, outward normal. -pub const CastHit = shapecast_mod.CastHit; /// Which row of `engine-physics-forge.md` §1.11.11's termination table ended a march. pub const CastExit = shapecast_mod.CastExit; /// Observability channel for the march (exit row, iterations, advances, restart). @@ -113,6 +129,15 @@ pub const castShape = shapecast_mod.castShape; /// normative fallback observable rather than merely documented. pub const castShapeBounded = shapecast_mod.castShapeBounded; +// --- Half-space kernels (analytic, closed-form, plane.zig) --- + +/// The half-space kernels, as a NAMESPACE rather than six flat re-exports +/// (`engine-physics-forge.md` §1.11.15). Deliberate: `containsPoint`, `rayShape` and +/// `castShape` all have a bounded-convex namesake in this package, and flattening +/// them here would either collide or force six invented names. `narrowphase.plane.rayShape` +/// says which category it serves, which is the whole point of the taxonomy. +pub const plane = plane_mod; + // Pins so every package sub-file is analysed when forge_3d is built as a test // target (engine-zig-conventions.md §13 lazy-analysis guard). comptime { @@ -123,4 +148,5 @@ comptime { _ = fast_paths; _ = raycast_mod; _ = shapecast_mod; + _ = plane_mod; } diff --git a/src/modules/forge/forge_3d/pipeline/narrowphase/shapecast.zig b/src/modules/forge/forge_3d/pipeline/narrowphase/shapecast.zig index 3a92020..f9c1534 100644 --- a/src/modules/forge/forge_3d/pipeline/narrowphase/shapecast.zig +++ b/src/modules/forge/forge_3d/pipeline/narrowphase/shapecast.zig @@ -12,9 +12,11 @@ //! //! That is the core + inflation-radius convention of §1.11.3, never a second one, //! and it has a structural consequence worth stating: a sphere cast against a box is -//! a ray against a ROUNDED box, precisely the shape `raycast.zig` rejects with -//! `error.UnsupportedShape`. A cast is not expressible over the ray kernels — hence -//! this file. And because a support map covers every BOUNDED convex, this kernel has +//! a ray against a ROUNDED box, precisely the shape `raycast.zig` does not cover — +//! its asserted precondition `raySupportsShape` is false there (a typed +//! `error.UnsupportedShape` until M1.1.11, when the refusal moved to the query entries +//! a caller can provoke it from, §1.11.7). A cast is not expressible over the ray +//! kernels — hence this file. And because a support map covers every BOUNDED convex, this kernel has //! no shape to reject and so **no error channel at all**: the frozen signature //! carrying none is evidence for the design, not a constraint on it. //! @@ -90,24 +92,10 @@ const unit_dir_k: comptime_int = 16; /// proximity into a contact. const noise_k: comptime_int = 2; -/// One cast hit, in A's frame. -pub fn CastHit(comptime T: type) type { - return struct { - /// Distance along the (unit) cast direction at first touch, in - /// `[0, max_distance]`. Zero when the shapes already overlap at the start - /// pose. - distance: T, - /// Witness point on B — the HIT body — on its INFLATED surface, A's frame. - /// Reconstructed from the terminal simplex's barycentric weights over B's - /// support points plus the `r_b` offset along the outward normal; no EPA is - /// involved. Beyond a zero distance this is also the witness on the moved A, - /// the two coinciding exactly (see `terminal`). - point: math.Vec(3, T), - /// Outward unit normal of the HIT body at the witness, A's frame. Satisfies - /// `normal · direction <= 0` on every hit. - normal: math.Vec(3, T), - }; -} +/// One cast hit, in A's frame. Defined in `support.zig` since M1.1.11, for the reason +/// `LocalHit` is: `plane.zig` produces the same type from a closed form and +/// `BodyManager.castShapeBody` dispatches between the two by the hit body's class. +const CastHit = support.CastHit; /// How the march terminated — one variant per row of §1.11.11's termination table. /// Mirrors `epa.zig`'s `EpaDiagnostics.Exit`. @@ -177,7 +165,9 @@ pub fn castShape( /// /// The ceiling is a parameter for ONE reason: the normative fallback of §1.11.11 — /// exhaustion returns a hit at the current parameter — is unreachable in practice on -/// the three cores the store builds, which converge in a handful of iterations. A +/// the three cores the store builds — still three since M1.1.11, a half-space having +/// no core at all and never reaching this kernel — which converge in a handful of +/// iterations. A /// guard never observed to fire is a comment with extra syntax, so a test drives a /// small ceiling through the SAME code path and asserts both that the cap fired and /// that what came back is a hit at a parameter at or below the true time of impact. diff --git a/src/modules/forge/forge_3d/pipeline/narrowphase/support.zig b/src/modules/forge/forge_3d/pipeline/narrowphase/support.zig index 77b4d83..1422859 100644 --- a/src/modules/forge/forge_3d/pipeline/narrowphase/support.zig +++ b/src/modules/forge/forge_3d/pipeline/narrowphase/support.zig @@ -48,6 +48,47 @@ pub fn Vertex(comptime T: type) type { }; } +/// A hit on ONE shape, in that shape's local frame: how far along the (unit) query +/// direction, and the outward unit surface normal there. +/// +/// **Shared vocabulary, which is why it lives here** (M1.1.11). Two kernel files +/// produce it — `raycast.zig` for the bounded convexes and `plane.zig` for the +/// half-space — and the `BodyId`-level adapter that dispatches between them by shape +/// CLASS must return ONE type, not two structurally identical ones. `plane.zig` may +/// import only `foundation` and this file, so this is where the type can be common to +/// both (§1.11.15's taxonomy is above the support map, and so is this). +/// +/// At distance zero — an origin inside the solid — the normal is `−direction`, the only +/// choice preserving `normal · direction <= 0` on every hit (§1.11.4). +pub fn LocalHit(comptime T: type) type { + return struct { + /// Distance along the (unit) direction, `>= 0`. Zero when the origin is inside + /// the solid shape. + distance: T, + /// Outward unit surface normal at the hit point, local frame. + normal: math.Vec(3, T), + }; +} + +/// One shape-cast hit, in the frame the kernel computed in — A's frame, A being the +/// shape being cast. +/// +/// Shared for the same reason as `LocalHit`: `shapecast.zig` produces it from a GJK +/// ray march on the Minkowski difference and `plane.zig` from a closed form, and +/// `BodyManager.castShapeBody` dispatches between them by the hit body's class. +pub fn CastHit(comptime T: type) type { + return struct { + /// Distance along the (unit) cast direction at first touch, in + /// `[0, max_distance]`. Zero when the shapes already overlap at the start pose. + distance: T, + /// Witness point on B — the HIT body — on its INFLATED surface. + point: math.Vec(3, T), + /// Outward unit normal of the HIT body at the witness. Satisfies + /// `normal · direction <= 0` on every hit. + normal: math.Vec(3, T), + }; +} + /// The supporting **feature** of a core in a given direction: the polygon (or /// edge / vertex) whose points maximize `dir · p` on the core, in the core's /// local frame, radius excluded. Up to 4 vertices (a box face); `count` gives diff --git a/src/modules/forge/forge_3d/query/overlap.zig b/src/modules/forge/forge_3d/query/overlap.zig index 504a651..13642a4 100644 --- a/src/modules/forge/forge_3d/query/overlap.zig +++ b/src/modules/forge/forge_3d/query/overlap.zig @@ -49,7 +49,9 @@ pub const Probe = union(enum) { position: Vec3r, rotation: Quatr, }, - /// Overlap of a world AABB: the body's TIGHT world AABB, faces included. + /// Overlap of a world AABB: the body's TIGHT world AABB, faces included — or, for + /// an unbounded body, the corner predicate, which has no box to compare + /// (`BodyManager.aabbOverlapsBody` dispatches). /// Deliberately not the leaf's stored box, which is FAT by /// `BroadphaseConfig.margin` — returning the candidate set would report bodies /// that do not overlap the query, and the error would be a function of a TUNING @@ -79,10 +81,12 @@ pub const OverlapCollector = struct { const accepted = switch (self.probe) { .shape => |s| self.bm.overlapShapeBody(self.store, body, s.shape, s.position, s.rotation) orelse return, - .aabb => |query_box| blk: { - const tight = self.bm.bodyAabb(self.store, body) orelse return; - break :blk tight.overlaps(query_box); - }, + // Through the adapter, NOT `bodyAabb`: a candidate may be a half-space, which + // has no world AABB at all, and `bodyAabb` asserts the convex class (E5 item + // 6). The adapter's convex arm is still the body's TIGHT world box — the fat + // leaf box would make the answer a function of a tuning constant (§1.11.12) — + // and its half-space arm is the corner predicate. + .aabb => |query_box| self.bm.aabbOverlapsBody(self.store, body, query_box) orelse return, .point => |p| self.bm.containsPointBody(self.store, body, p) orelse return, }; if (!accepted) return; diff --git a/src/modules/forge/forge_3d/query/ray.zig b/src/modules/forge/forge_3d/query/ray.zig index c5a6f6a..e45ba77 100644 --- a/src/modules/forge/forge_3d/query/ray.zig +++ b/src/modules/forge/forge_3d/query/ray.zig @@ -8,7 +8,7 @@ //! the point of splitting in its own commit is that the diff can be read at `-M` and //! seen to move rather than change. //! -//! `root.zig` owns the shared vocabulary (`Filter`, `Ray`, `RayHit`, `Error`) and the +//! `root.zig` owns the shared vocabulary (`Filter`, `Ray`, `RayHit`) and the //! ordering key; this file owns only the three selection modes and the exact //! per-candidate test. The two import each other, which Zig resolves lazily at file //! granularity — the same shape the narrowphase package already has between @@ -27,26 +27,31 @@ const ShapeStore = body_manager_mod.ShapeStore; const Ray = root.Ray; const Filter = root.Filter; const RayHit = root.RayHit; -const Error = root.Error; const hitLess = root.hitLess; /// The exact per-candidate test every collector runs: filter, then kernel, then /// world-space assembly. `null` means "no hit to offer" — a filtered candidate, a -/// stale handle, or a genuine miss; an error means the shape is unsupported and -/// is latched by the caller. +/// stale handle, or a genuine miss. +/// +/// **Total since M1.1.11.** It returned `Error!?RayHit` because the kernel could +/// answer `error.UnsupportedShape`, and each collector latched that error in a field +/// for its entry to surface. The kernel's rounded-box refusal is an asserted +/// precondition now, and it was reachable through no body anyway — every stored box +/// converts with `radius = 0`. Nothing a candidate BODY can be makes this test fail, +/// so there is no latch left to keep (§1.11.7). fn evaluate( bm: *const BodyManager, store: *const ShapeStore, filter: Filter, ray: Ray, user_data: u32, -) Error!?RayHit { +) ?RayHit { const body: BodyId = user_data; // The layer getter also answers staleness: a freed handle has no layer. const layer = bm.collisionLayer(body) orelse return null; if (!filter.accepts(layer, body)) return null; - const local = (try bm.raycastBody(store, body, ray)) orelse return null; + const local = bm.raycastBody(store, body, ray) orelse return null; const rotation = bm.rotation(body) orelse return null; const owner = bm.entity(body) orelse return null; return .{ @@ -71,13 +76,9 @@ pub const ClosestCollector = struct { ray: Ray, bound: Real, best: ?RayHit = null, - err: ?Error = null, pub fn add(self: *ClosestCollector, user_data: u32) void { - const hit = (evaluate(self.bm, self.store, self.filter, self.ray, user_data) catch |e| { - self.err = e; - return; - }) orelse return; + const hit = evaluate(self.bm, self.store, self.filter, self.ray, user_data) orelse return; if (hit.distance > self.bound) return; // beyond the window, closed at the bound if (self.best) |best| { // The SAME total order the sort uses (`(distance, entity, BodyId)`, @@ -114,14 +115,10 @@ pub const AnyCollector = struct { ray: Ray, bound: Real, found: bool = false, - err: ?Error = null, pub fn add(self: *AnyCollector, user_data: u32) void { if (self.found) return; - const hit = (evaluate(self.bm, self.store, self.filter, self.ray, user_data) catch |e| { - self.err = e; - return; - }) orelse return; + const hit = evaluate(self.bm, self.store, self.filter, self.ray, user_data) orelse return; if (hit.distance > self.bound) return; self.found = true; self.bound = 0; @@ -146,13 +143,9 @@ pub const AllCollector = struct { bound: Real, out: []RayHit, count: u32 = 0, - err: ?Error = null, pub fn add(self: *AllCollector, user_data: u32) void { - const hit = (evaluate(self.bm, self.store, self.filter, self.ray, user_data) catch |e| { - self.err = e; - return; - }) orelse return; + const hit = evaluate(self.bm, self.store, self.filter, self.ray, user_data) orelse return; if (hit.distance > self.bound) return; if (self.count < self.out.len) { diff --git a/src/modules/forge/forge_3d/query/root.zig b/src/modules/forge/forge_3d/query/root.zig index 755a7c3..7885167 100644 --- a/src/modules/forge/forge_3d/query/root.zig +++ b/src/modules/forge/forge_3d/query/root.zig @@ -26,12 +26,13 @@ //! smallest prefers the bodies created first — the inverse of the invariance //! §1.11.6 claims. //! -//! **The exact kernel can fail, and the collector contract cannot.** -//! `queryRay`'s collector exposes `add(u32) void`, so a kernel error is LATCHED -//! in a collector field and surfaced by the entry function — the `PairSink.err` -//! pattern `computePairs` already uses for OOM. Never swallowed, never -//! `catch unreachable`, and the collector contract stays as the traversal -//! defines it. +//! **Only the two entries taking a caller-supplied SHAPE HANDLE carry an error** +//! (§1.11.7, M1.1.11): `shapeCast` and `overlapShape`. They separate a stale handle, +//! an inadmissible probe and a real miss — three outcomes a single `null` conflated. +//! The other six take no handle and are TOTAL. The three RAY entries carried +//! `error{UnsupportedShape}` until M1.1.11, latched per collector and surfaced by the +//! entry; the kernel's rounded-box refusal became an asserted precondition and the +//! latch went with it, since no body's shape could ever reach it. //! //! **Precision.** Everything here is at the solver scalar. The public surface //! stays `f32` (§1.11.8); that boundary lives at the interface tier (M1.1.15), @@ -74,9 +75,45 @@ pub const Ray = broadphase_mod.Ray(Real); /// defensive echo of that rejection. pub const layer_bits: u8 = api.collision_layer_count; -/// Errors a query can surface. `UnsupportedShape` comes from the exact kernel — -/// a rounded box today — and is propagated rather than read as a miss. -pub const Error = error{UnsupportedShape}; +/// Errors the two entries taking a caller-supplied SHAPE HANDLE can surface — +/// `shapeCast` and `overlapShape`, and no others (`engine-physics-forge.md` +/// §1.11.7). Together with a `null` / `0` answer they separate three outcomes a +/// single nullary value used to conflate, and each member is reachable from a +/// public entry by a caller mistake the caller can then diagnose: +/// +/// - `InvalidShape` — the handle is stale or was never valid. Reachable by +/// destroying a shape and casting with its id; same name and same meaning as +/// `addBody`'s, not a second vocabulary for one situation. +/// - `UnsupportedShape` — the probe is a shape the exact kernel cannot express. +/// Reachable TODAY by passing a plane handle: the cast kernel is a ray march on +/// the Minkowski difference of the two cores (§1.11.11) and the shape overlap is +/// GJK on those cores (§1.11.12), and a half-space has no bounded core. +/// +/// A real miss is `null` / `0`, and a ZERO DIRECTION is a miss too — a degenerate +/// query with an empty answer, not a malformed one (§1.11.11's domain table). +/// +/// This is a RESHAPING, not an extension. Before E3 the set was +/// `error{UnsupportedShape}` on the three RAY entries, where it came from the +/// kernel's rounded-box latch and was reachable through no store shape at all; it +/// now lives on the two handle-taking entries, where a caller can cause both +/// members. The three members map one-for-one onto the frozen `WeldQueryStatus` of +/// `engine-c-api.md` — `WELD_QUERY_OK`, `WELD_QUERY_INVALID_SHAPE`, +/// `WELD_QUERY_UNSUPPORTED_SHAPE`. +pub const Error = error{ InvalidShape, UnsupportedShape }; + +/// Whether a caller-supplied probe shape is admissible for the two entries that take +/// one, i.e. a bounded convex the support map describes. +/// +/// Exhaustive on the CLASS with no `else` arm: the mesh (M1.1.11.1) is a compile +/// error here and must state its own answer. A named function rather than an inline +/// comparison so both entries test the SAME condition and the tests can exercise it +/// on both answers without restating it. +fn probeAdmissible(record: shape_mod.Shape) bool { + return switch (record.class()) { + .convex => true, + .half_space => false, + }; +} /// Query filtering, shared by the whole family (§1.11.5): a mask over OBJECT /// layers and a list of bodies to ignore. @@ -126,7 +163,8 @@ pub const RayHit = struct { /// (§1.11.14), carried here rather than resolved at every comparison. The public /// `RaycastHit` carries it too, so the mirror is not widened by holding it. entity: EntityId, - /// Which sub-shape of that body was hit; 0 while one shape is one body. + /// Which sub-shape of that body was hit — an OPAQUE PATH decoded by the root shape, + /// zero bits wide for a shape with no sub-shape, so the `0` is not read (§1.11.16). subshape_id: u32 = 0, /// World-space hit point. position: Vec3r, @@ -158,7 +196,7 @@ pub const CastHit = struct { body: BodyId, /// The ECS entity owning it — the ordering key's leading identity (§1.11.14). entity: EntityId, - /// Which sub-shape of the hit body; 0 while one shape is one body. + /// Which sub-shape of the hit body — an opaque path, zero bits wide here (§1.11.16). subshape_id: u32 = 0, /// Which sub-shape of the CAST shape; 0 for the same reason. cast_subshape_id: u32 = 0, @@ -188,7 +226,7 @@ pub const ClosestPointHit = struct { body: BodyId, /// The ECS entity owning it. entity: EntityId, - /// Which sub-shape; 0 while one shape is one body. + /// Which sub-shape — an opaque path, zero bits wide here (§1.11.16). subshape_id: u32 = 0, /// World-space point on that body's surface, or the queried point when inside. position: Vec3r, @@ -203,7 +241,7 @@ pub fn raycast( bm: *const BodyManager, store: *const ShapeStore, query: RayQuery, -) Error!?RayHit { +) ?RayHit { const ray = prepare(query) orelse return null; var collector = ClosestCollector{ .bm = bm, @@ -213,7 +251,6 @@ pub fn raycast( .bound = query.max_distance, }; _ = bp.queryRay(ray, &collector); - if (collector.err) |e| return e; return collector.best; } @@ -226,7 +263,7 @@ pub fn raycastAny( bm: *const BodyManager, store: *const ShapeStore, query: RayQuery, -) Error!bool { +) bool { const ray = prepare(query) orelse return false; var collector = AnyCollector{ .bm = bm, @@ -236,7 +273,6 @@ pub fn raycastAny( .bound = query.max_distance, }; _ = bp.queryRay(ray, &collector); - if (collector.err) |e| return e; return collector.found; } @@ -256,7 +292,7 @@ pub fn raycastAll( store: *const ShapeStore, query: RayQuery, out: []RayHit, -) Error!u32 { +) u32 { const ray = prepare(query) orelse return 0; var collector = AllCollector{ .bm = bm, @@ -267,7 +303,6 @@ pub fn raycastAll( .out = out, }; _ = bp.queryRay(ray, &collector); - if (collector.err) |e| return e; const written = collector.count; std.mem.sort(RayHit, out[0..written], {}, hitLess); return written; @@ -298,16 +333,18 @@ pub fn raycastAll( /// /// The traversal is the swept one (§1.11.10): each node's box is inflated by the /// half-extents of the cast shape's INITIAL world AABB, and the ray starts at that -/// AABB's CENTRE — not at `query.origin`. The two coincide for the three shapes the -/// store builds, whose local AABB is centred on the origin, but that is a property -/// of those shapes and not of the model, so the centre is what is computed. The -/// AABB is a constant of the query: a sweep is a pure translation. +/// AABB's CENTRE — not at `query.origin`. The two coincide for the three bounded +/// convexes the store builds, whose local AABB is centred on the origin, but that is a +/// property of those shapes and not of the model, so the centre is what is computed. +/// (The store also builds a plane since M1.1.11, which has no world AABB at all and is +/// refused as a probe above, before this box is ever built.) The AABB is a constant of +/// the query: a sweep is a pure translation. pub fn shapeCast( bp: *const Broadphase, bm: *const BodyManager, store: *const ShapeStore, query: CastQuery, -) ?CastHit { +) Error!?CastHit { // The full §1.11.11 domain, asserted BEFORE the handle is resolved so a stale // shape cannot short-circuit it: origin, rotation unitary, direction finite, bound // finite and non-negative. @@ -315,7 +352,20 @@ pub fn shapeCast( assertFiniteVec(query.origin); assertFiniteVec(query.direction); assertUnitRotation(query.rotation); - const record = store.get(query.shape) orelse return null; + const record = store.get(query.shape) orelse return error.InvalidShape; + // PROBE ADMISSIBILITY, before ANY use of the record and before the direction is + // even looked at. Two reasons it cannot be moved later: + // + // - `worldAabb` (below) and `shape_mod.supportShape` both carry a class + // precondition, and a `std.debug.assert` is compiled OUT of ReleaseFast — so + // without this check a plane probe is not a panic there but UNDEFINED + // BEHAVIOUR, `worldAabb` falling through to its `unreachable`. The two + // entries also touch those two in OPPOSITE orders, so no single downstream + // guard covers both. + // - A malformed probe outranks a degenerate direction. Both could hold at once, + // and the shape being inexpressible is the caller's error, whereas a zero + // direction is a legal query with an empty answer. + if (!probeAdmissible(record)) return error.UnsupportedShape; const direction = unitDirection(query.direction) orelse return null; const box = body_manager_mod.worldAabb(record, query.origin, query.rotation); @@ -345,11 +395,15 @@ pub fn overlapShape( store: *const ShapeStore, request: OverlapRequest, out: []BodyId, -) u32 { +) Error!u32 { // Before the handle resolution, for the reason `assertFiniteVec` gives. assertFiniteVec(request.position); assertUnitRotation(request.rotation); - const record = store.get(request.shape) orelse return 0; + const record = store.get(request.shape) orelse return error.InvalidShape; + // PROBE ADMISSIBILITY, before any use of the record — see `shapeCast`, whose + // argument applies here with the two downstream guards in the opposite order + // (`supportShape` first, `worldAabb` second). + if (!probeAdmissible(record)) return error.UnsupportedShape; var collector = overlap_mod.OverlapCollector{ .bm = bm, .store = store, @@ -376,10 +430,14 @@ pub fn overlapShape( /// (`BroadphaseConfig.margin`, default 0.1 m), so returning the candidate set would /// report bodies that do not overlap the query — and the error would be a function /// of a TUNING CONSTANT, so changing the margin would change a query's answer. The -/// exact kernel is therefore the body's tight world AABB, which `bodyAabb` computes -/// from the shape and the pose and which consequently needs the store. The entry -/// stops at AABB GRANULARITY, which is not the same thing as stopping at the -/// broadphase (§1.11.12). +/// exact kernel is therefore the body's tight world AABB, which +/// `BodyManager.aabbOverlapsBody` computes from the shape and the pose and which +/// consequently needs the store. The entry stops at AABB GRANULARITY, which is not the +/// same thing as stopping at the broadphase (§1.11.12). +/// +/// An UNBOUNDED candidate has no world AABB to compare, so that adapter answers it by +/// the corner PREDICATE instead (§1.11.15). Both arms decide the same question — does +/// this body meet the queried box — without sharing a representation of the body. pub fn overlapAabb( bp: *const Broadphase, bm: *const BodyManager, diff --git a/src/modules/forge/forge_3d/rigid/contact_cache.zig b/src/modules/forge/forge_3d/rigid/contact_cache.zig index 3f73acc..c586414 100644 --- a/src/modules/forge/forge_3d/rigid/contact_cache.zig +++ b/src/modules/forge/forge_3d/rigid/contact_cache.zig @@ -32,13 +32,17 @@ const config = @import("../config.zig"); const Real = config.Real; const Vec3r = config.Vec3r; -/// A contact's warm-start identity. `subshape_id` is reserved at 0 for the -/// Compound/Mesh sub-shape co-design flagged at M1.1.3 (zero-cost now); the full -/// triple is the sort/match key so it extends without a format change. +/// A contact's warm-start identity. `subshape_id` is 0 for every shape delivered so +/// far, and the full triple is the sort/match key so it extends without a format +/// change. pub const CacheKey = struct { /// Packed canonical body pair `min(BodyId)<<32 | max`. pair_key: u64, - /// Sub-shape index within a Compound/Mesh pair — always 0 in M1.1.6. + /// Sub-shape of the pair — an OPAQUE PATH decoded by the root shape, NOT a global + /// index (`engine-physics-forge.md` §1.11.16). A shape with no sub-shape consumes + /// zero bits, so this is 0 and unread for sphere, box, capsule and plane; a compound + /// (M1.1.20) shifts its own index up and inserts the child's below, which extends the + /// encoding without reinterpreting a value already cached. subshape_id: u32 = 0, /// Per-contact feature id (from the manifold), unique within a manifold. feature_id: u32, diff --git a/src/modules/forge/forge_3d/root.zig b/src/modules/forge/forge_3d/root.zig index 142c2b1..76a3fa8 100644 --- a/src/modules/forge/forge_3d/root.zig +++ b/src/modules/forge/forge_3d/root.zig @@ -56,6 +56,9 @@ pub const Aabbr = config.Aabbr; /// Immutable per-shape data (geometry + local AABB + unit-mass inertia). pub const Shape = shape.Shape; +/// The narrowphase CATEGORY of a shape — bounded convex or half-space (M1.1.11, +/// `engine-physics-forge.md` §1.11.15). Scalar-free, so re-exported as-is. +pub const ShapeClass = shape.ShapeClass; /// Generational store of collision shapes. pub const ShapeStore = shape.ShapeStore; @@ -151,6 +154,19 @@ pub fn collideOrderedGeneric(shape_a: SupportShape, pos_a: Vec3r, rot_a: Quatr, /// The `(normal, closest points, base penetration)` fast-path seed at solver precision. pub const ContactSeed = narrowphase.ContactSeed(Real); +// --- Half-space kernels (M1.1.11) --- + +/// A solid half-space `{ x : n·x <= d }` at solver precision — the geometry a `.plane` +/// shape carries, and the input to every kernel of the `plane` namespace below. +pub const HalfSpace = narrowphase.plane.HalfSpace(Real); + +/// The analytic half-space kernels (`engine-physics-forge.md` §1.11.15): separation +/// against a bounded convex, ray, shape cast, solid membership, closest point, and the +/// AABB corner predicate. Closed-form and iteration-free — a half-space has no support +/// map, so it traverses neither GJK, EPA nor the cast march. Scalar-generic, so +/// re-exported as a namespace; `BodyManager`'s five adapters bind it at `Real`. +pub const plane = narrowphase.plane; + // --- Ray kernels + queries --- /// A ray hit on one shape in that shape's LOCAL frame (distance + outward @@ -160,10 +176,18 @@ pub const LocalHit = narrowphase.LocalHit(Real); /// Nearest ray↔shape intersection in the shape's local frame, at solver /// precision — the `Real`-bound kernel entry. `BodyManager.raycastBody` is the /// `BodyId`-level adapter the query traversal drives. -pub fn rayShape(support_shape: SupportShape, origin: Vec3r, direction: Vec3r) error{UnsupportedShape}!?LocalHit { +pub fn rayShape(support_shape: SupportShape, origin: Vec3r, direction: Vec3r) ?LocalHit { return narrowphase.rayShape(Real, support_shape, origin, direction); } +/// Whether the ray kernels cover `support_shape` — `rayShape`'s asserted +/// precondition at solver precision (M1.1.11). Every box the `ShapeStore` converts +/// carries `radius = 0`, so this is false only for a `SupportShape` a caller built by +/// hand. +pub fn raySupportsShape(support_shape: SupportShape) bool { + return narrowphase.raySupportsShape(Real, support_shape); +} + /// Spatial queries at solver precision (`engine-physics-forge.md` §1.11): the /// shared `Filter`, the `RayQuery`/`RayHit` types, and the three raycast entries /// `raycast` / `raycastAny` / `raycastAll`. Stateless — each entry takes @@ -229,4 +253,5 @@ comptime { _ = @import("tests/raycast_test.zig"); _ = @import("tests/shapecast_test.zig"); _ = @import("tests/overlap_test.zig"); + _ = @import("tests/plane_test.zig"); } diff --git a/src/modules/forge/forge_3d/shape.zig b/src/modules/forge/forge_3d/shape.zig index e5d974c..9e69fed 100644 --- a/src/modules/forge/forge_3d/shape.zig +++ b/src/modules/forge/forge_3d/shape.zig @@ -2,9 +2,22 @@ //! //! `createShape` builds a `Shape` (geometry at solver precision + precomputed //! local AABB + unit-mass local inertia diagonal) and stores it in a -//! generational slot pool. M1.1.0 constructs sphere/box/capsule; every other -//! `ShapeType` is rejected with `error.UnsupportedShape`. Inertia is the -//! unit-mass diagonal; `BodyManager` scales it by the body's mass at `addBody`. +//! generational slot pool. M1.1.0 constructs sphere/box/capsule and M1.1.11 adds +//! the infinite plane; every other `ShapeType` is rejected with +//! `error.UnsupportedShape`. Inertia is the unit-mass diagonal; `BodyManager` +//! scales it by the body's mass at `addBody`. +//! +//! **The store holds two CATEGORIES, not one** (M1.1.11, +//! `engine-physics-forge.md` §1.11.15). Sphere, box and capsule are bounded +//! convexes described by a support map; a half-space is not — its support map +//! diverges in every direction but `−n`. `ShapeClass` names that distinction and +//! `Shape.class()` answers it, so a consumer chooses the category BEFORE +//! converting a shape into a `SupportShape`. `supportShape` is therefore no longer +//! a total function of the store: it is the convex arm, and it asserts its +//! precondition. Putting a half-space into `SupportShape.Core` instead would make +//! `support()` return an infinity, and GJK's termination, EPA's expansion and the +//! cast kernel's ray march all assume a finite point — the failure would be a NaN +//! surfacing several modules away from its cause. const std = @import("std"); const api = @import("weld_forge"); @@ -18,22 +31,100 @@ const Aabbr = config.Aabbr; const ShapeId = api.ShapeId; const ShapeType = api.ShapeType; const ShapeDescriptor = api.ShapeDescriptor; +/// The descriptor's `f32` `Vec3` — the precision the plane normal arrives in +/// (`engine-physics-forge.md` §1.11.8: the public surface stays `f32`). +const ApiVec3 = @import("foundation").math.Vec3; + +/// The narrowphase CATEGORY of a shape (`engine-physics-forge.md` §1.11.15). The +/// dispatch on it happens UPSTREAM of any conversion to a `SupportShape`, never +/// inside one. +/// +/// **Two variants on purpose.** §1.11.15 reads the narrowphase in three +/// categories — bounded convex, half-space, triangle soup — and the third arrives +/// with `MeshShape` at M1.1.11.1. Every `switch` on this enum is EXHAUSTIVE, with +/// no `else` arm anywhere, so that adding the third variant is a compile error at +/// each site that owes a decision. An `else` arm converts that compile error into +/// a silent wrong answer, which is exactly the failure mode the taxonomy exists to +/// prevent. +pub const ShapeClass = enum { + /// A bounded convex, described by a support map: sphere, box, capsule (and + /// later cylinder, tapered cylinder, convex hull). GJK, EPA, the cast kernel + /// and the ray kernels all serve this category and only this one. + convex, + /// A solid half-space `{x : n·x <= d}`. Unbounded, so it has no support map, no + /// world AABB and no place in the broadphase trees; its kernels are analytic + /// and closed-form, and its broadphase role is a PREDICATE ("do you overlap + /// this box") rather than a box of its own. + half_space, +}; /// Immutable per-shape data: geometry (solver precision), the local-space AABB, /// and the unit-mass local inertia diagonal (principal axes). Only the geometry /// fields relevant to `shape_type` are meaningful. pub const Shape = struct { shape_type: ShapeType, - /// Sphere / capsule radius (metres). + /// Sphere / capsule radius (metres). A half-space carries 0: it is not a core + /// plus an inflation, it IS the solid, and the `− r_b` term of §1.11.15's + /// separation formula belongs to the other shape. radius: Real = 0, /// Box half-extents (metres). half_extents: Vec3r = Vec3r.zero, /// Capsule cylinder half-height (metres), along +Y. half_height: Real = 0, + /// Half-space outward normal in `n·x <= d`, local frame — meaningful only for + /// `.plane`. **UNIT at solver precision, permanently**: `createShape` asserts + /// the descriptor is unit at `f32` tolerance and normalises the widened value + /// once, so no consumer re-normalises (the `Body.rotation` pattern, and for the + /// same reason — an f32-unit vector widened to `f64` is off by ~6e-8 in its + /// squared norm). It is the sole source of the plane's contact normal. + normal: Vec3r = Vec3r.unit_y, + /// Half-space offset `d` in `n·x <= d` (metres), local frame — meaningful only + /// for `.plane`. + distance: Real = 0, /// Local-space (untransformed) bounding box. + /// + /// **NOT VALID for a half-space**, which is unbounded: every component is NaN + /// there, and its sole reader — `body.computeSleepRadius` — asserts + /// `class() == .convex` first. (`body_manager.worldAabb` and `bodyAabb` assert + /// the same class for a DIFFERENT reason: they never read this field, they + /// compute a world box per primitive, and a half-space simply has none.) + /// + /// An infinite box is not the alternative — its centre is `(−inf + inf)·0.5`, + /// i.e. NaN, which is the ray origin a shape cast derives from a box; its + /// 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. A finite substitute box is refused too: it is a tuning + /// constant that changes a query's answer (§1.11.15). local_aabb: Aabbr, /// Unit-mass local inertia diagonal (principal axes). + /// + /// **NOT VALID for a half-space**, which has no finite volume: NaN there. + /// `body.computeMotion` reads it only on the dynamic path and asserts the class + /// there — a dynamic body carrying a half-space never gets that far, `addBody` + /// rejecting it with `error.ShapeMustBeStatic` first. unit_inertia: Vec3r, + + /// The narrowphase category of this shape — the dispatch every consumer makes + /// before touching the geometry. + /// + /// Exhaustive over `ShapeType` with no `else` arm, so a thirteenth shape type + /// is a compile error HERE, where its category must be stated. The variants the + /// store cannot hold are named individually rather than swept together: they + /// are unreachable because `createShape` rejects them, and naming them is what + /// makes the milestone that lands each one arrive at this line. + pub fn class(self: Shape) ShapeClass { + return switch (self.shape_type) { + .sphere, .box, .capsule => .convex, + .plane => .half_space, + // Bounded convexes, not yet constructible (M1.1.19). + .cylinder, .tapered_cylinder, .convex_hull => unreachable, + // The THIRD category of §1.11.15, which `ShapeClass` deliberately does + // not name yet (M1.1.11.1). + .triangle_mesh, .height_field => unreachable, + // Composite / degenerate, not yet constructible (M1.1.20). + .compound, .mutable_compound, .empty => unreachable, + }; + } }; /// A generational store of collision shapes with LIFO slot reuse. @@ -53,8 +144,9 @@ pub const ShapeStore = struct { return self.alloc.live_count; } - /// Build and store a shape, returning its handle. Sphere/box/capsule only; - /// any other variant returns `error.UnsupportedShape` (no slot allocated). + /// Build and store a shape, returning its handle. Sphere/box/capsule/plane + /// only; any other variant returns `error.UnsupportedShape` (no slot + /// allocated). pub fn createShape(self: *ShapeStore, gpa: std.mem.Allocator, desc: ShapeDescriptor) !ShapeId { const shape = try buildShape(desc); try self.alloc.ensureUnusedCapacity(gpa, 1); @@ -83,10 +175,19 @@ pub const ShapeStore = struct { /// Convert an immutable `Shape` to the narrowphase `SupportShape` at solver /// precision: sphere → point core + radius, capsule → Y-segment(`half_height`) /// core + radius, box → box(`half_extents`) core + radius 0 (a box has no convex -/// radius in M1.1.2). `ShapeStore` only ever holds these three (`createShape` -/// rejects the rest with `error.UnsupportedShape`), so no other tag can reach -/// here — the same invariant as `body_manager.worldAabb`. +/// radius in M1.1.2). +/// +/// **This is the CONVEX ARM, and its precondition is asserted** (M1.1.11, +/// `engine-physics-forge.md` §1.11.15). It stopped being a total function of the +/// store the moment the store gained a half-space: the category is chosen upstream +/// by `Shape.class()`, and calling this with a `.half_space` is a programming +/// error, not an input to handle. Making a half-space a `Core` variant instead +/// would have `support()` return an infinity, and every consumer of the support map +/// — GJK's termination, EPA's expansion, the cast kernel's ray march — assumes a +/// finite point; the failure would be a NaN appearing modules away from its cause, +/// rather than a compile error or a wrong answer at one call site. pub fn supportShape(shape: Shape) narrowphase.SupportShape(Real) { + std.debug.assert(shape.class() == .convex); return switch (shape.shape_type) { .sphere => .{ .core = .point, .radius = shape.radius }, .capsule => .{ .core = .{ .segment = shape.half_height }, .radius = shape.radius }, @@ -95,8 +196,49 @@ pub fn supportShape(shape: Shape) narrowphase.SupportShape(Real) { }; } -/// Build the `Shape` for a descriptor (sphere/box/capsule), computing its local -/// AABB and unit-mass inertia. Other shapes → `error.UnsupportedShape`. +/// The poison value the half-space carries in the two fields that have no meaning +/// for it, `local_aabb` and `unit_inertia` (see their field docs). NaN and not a +/// finite placeholder: it survives ReleaseFast, where the class asserts guarding +/// those fields are compiled out. +const nan: Real = std.math.nan(Real); + +/// Slack allowed on the descriptor normal's unit norm, in ULPs of 1 at `f32` — the +/// precision the descriptor is expressed in. A normal built by normalising an `f32` +/// vector, or from `f32` trigonometry, lands a few ULPs off unit; anything further +/// out is a caller error, not rounding. Same constant and same role as +/// `body_manager.descriptor_rotation_unit_k`. +const descriptor_normal_unit_k: comptime_int = 16; + +/// Whether a plane descriptor's `normal` is unit to `f32` tolerance — the domain +/// `createShape` asserts. +/// +/// A named predicate rather than an inline expression, so the threshold and the +/// formula exist ONCE and the inline test below can exercise the guard in both +/// senses instead of restating its arithmetic. The comparison is against 1, so +/// `descriptor_normal_unit_k · floatEps(f32)` is pure float noise and not a +/// geometric tolerance. +fn descriptorNormalIsUnit(normal: ApiVec3) bool { + const n = normal.toArray(); + const norm_sq = n[0] * n[0] + n[1] * n[1] + n[2] * n[2]; + return @abs(norm_sq - 1) <= descriptor_normal_unit_k * std.math.floatEps(f32); +} + +/// Convert an immutable `Shape` to the narrowphase `HalfSpace` at solver precision, +/// in the shape's LOCAL frame — the sibling of `supportShape`, one per category. +/// +/// **This is the HALF-SPACE ARM, and its precondition is asserted**, symmetric with +/// `supportShape`'s: the category is chosen upstream by `Shape.class()` and calling +/// this with a convex is a programming error. The normal needs no normalisation here +/// — `createShape` established that invariant once, which is the whole reason it is +/// established there (`engine-physics-forge.md` §1.11.15). +pub fn halfSpace(shape: Shape) narrowphase.plane.HalfSpace(Real) { + std.debug.assert(shape.class() == .half_space); + return .{ .normal = shape.normal, .distance = shape.distance }; +} + +/// Build the `Shape` for a descriptor (sphere/box/capsule/plane), computing its +/// local AABB and unit-mass inertia for the bounded convexes. Other shapes → +/// `error.UnsupportedShape`. fn buildShape(desc: ShapeDescriptor) error{UnsupportedShape}!Shape { switch (desc) { .sphere => |s| { @@ -139,6 +281,55 @@ fn buildShape(desc: ShapeDescriptor) error{UnsupportedShape}!Shape { .unit_inertia = capsuleUnitInertia(r, h), }; }, + .plane => |p| { + // The descriptor normal must ALREADY be unit, to `f32` tolerance — it IS + // `f32`. Without this guard the normalisation below would silently repair + // any input, turning a zero normal into NaN; with it, the normalisation is + // total in what it does: it corrects the widening, it does not rescue an + // invalid input. The `Body.rotation` pattern verbatim. + std.debug.assert(descriptorNormalIsUnit(p.normal)); + // And the offset is FINITE, for the same reason in the same place: it is the + // other half of the caller-supplied domain, and a non-finite one is not a + // plane with an unusual position, it is two silent and MUTUALLY CONTRADICTORY + // behaviours — the narrowphase reporting contact with everything (`sep > 0` is + // false against a NaN) while the broadphase reports contact with nothing + // (every comparison against a NaN bound is false). Measured both ways; see + // `plane.HalfSpace.assertDomain`. + std.debug.assert(std.math.isFinite(p.distance)); + const n = p.normal.toArray(); + return .{ + .shape_type = .plane, + // Normalised ONCE, here, so no consumer ever re-normalises: an + // `f32`-unit vector widened to `f64` is off by up to ~6e-8 in its + // squared norm, and this normal is the SOLE source of the plane's + // contact normal. + .normal = Vec3r.fromArray(.{ n[0], n[1], n[2] }).normalize(), + .distance = p.distance, + // NOT VALID for a half-space (see the field docs): unbounded, so no + // local AABB, and no finite volume, so no inertia. + // + // **POISONED WITH NaN, not left `undefined`**, and the two mechanisms + // guarding these fields are COMPLEMENTARY rather than redundant. The + // class assert on each reader is a `std.debug.assert`, so it is + // compiled OUT of ReleaseFast — which is the mode the benches run in, + // with a plane in the scene. `undefined` there is whatever the memory + // held; in Debug it is the 0xAA fill, which reads as a perfectly + // ordinary small number: MEASURED on the E1 commit, a plane's sleep + // radius came out 5.2510e-13 at f32 and 6.4444e-104 at f64 — finite, + // small and plausible, so nobody would ever see it go past. A NaN + // survives ReleaseFast and propagates loudly through every arithmetic + // path instead. The assert is the primary guard in a safe build; the + // NaN is the one that outlives it. + // + // Safe because NOTHING compares or hashes a `Shape` by value — the + // 34 `store.get` sites all read individual fields, and `shapes.items` + // is touched only inside this file. A future `expectEqual` on a whole + // `Shape`, or a `Shape`-keyed map, would break on `NaN != NaN`; that + // is the one thing this choice forbids. + .local_aabb = Aabbr.fromMinMax(Vec3r.splat(nan), Vec3r.splat(nan)), + .unit_inertia = Vec3r.splat(nan), + }; + }, else => return error.UnsupportedShape, } } @@ -206,6 +397,36 @@ test "capsule unit inertia degenerates to a sphere at h=0" { try testing.expect(inertia.approxEql(Vec3r.splat(0.4 * 1.5 * 1.5), 1e-6)); } +test "the plane descriptor normal domain accepts float noise and rejects a real error" { + // The guard `createShape` asserts, exercised in BOTH senses. A control never seen + // to fail is a comment with syntax, and the assert itself cannot be caught in a + // Zig test — so the PREDICATE is what the test discriminates on, and the assert's + // wiring to it is one line above the call. + // + // ACCEPTED: exactly unit, and unit to a few ULPs. `(1,1,1)/√3` at f32 has a + // squared norm off by float noise, and `(2,−3,6)/7` is exact (4 + 9 + 36 = 49). + try testing.expect(descriptorNormalIsUnit(ApiVec3.unit_y)); + try testing.expect(descriptorNormalIsUnit(ApiVec3.unit_x.neg())); + try testing.expect(descriptorNormalIsUnit(ApiVec3.fromArray(.{ 1, 1, 1 }).normalize())); + try testing.expect(descriptorNormalIsUnit(ApiVec3.fromArray(.{ 2.0 / 7.0, -3.0 / 7.0, 6.0 / 7.0 }))); + + // REJECTED: the zero normal (which normalisation would turn into NaN — the exact + // reason the guard precedes it), an unnormalised direction, and one just far + // enough out to be a caller error rather than rounding. The last case is a + // squared norm of `(1 + 64·eps)² ≈ 1 + 128·eps`, eight times the 16-ULP budget. + try testing.expect(!descriptorNormalIsUnit(ApiVec3.zero)); + try testing.expect(!descriptorNormalIsUnit(ApiVec3.fromArray(.{ 0, 2, 0 }))); + try testing.expect(!descriptorNormalIsUnit(ApiVec3.fromArray(.{ 1, 1, 1 }))); + const just_out: f32 = 1 + 64 * std.math.floatEps(f32); + try testing.expect(!descriptorNormalIsUnit(ApiVec3.fromArray(.{ 0, just_out, 0 }))); + + // The boundary is where the constant says it is, not somewhere near it: a squared + // norm exactly `1 + 16·eps` is inside the budget, `1 + 17·eps` is outside. + const eps = std.math.floatEps(f32); + try testing.expect(descriptorNormalIsUnit(ApiVec3.fromArray(.{ 0, 0, @sqrt(1 + 16 * eps) }))); + try testing.expect(!descriptorNormalIsUnit(ApiVec3.fromArray(.{ 0, 0, @sqrt(1 + 24 * eps) }))); +} + test "shape slot reuse is LIFO and generation-checked" { const gpa = testing.allocator; var store = ShapeStore{}; @@ -228,5 +449,3 @@ test "shape slot reuse is LIFO and generation-checked" { fn vec3(x: Real, y: Real, z: Real) Vec3r { return Vec3r.fromArray(.{ x, y, z }); } - -const ApiVec3 = @import("foundation").math.Vec3; diff --git a/src/modules/forge/forge_3d/tests/body_manager_test.zig b/src/modules/forge/forge_3d/tests/body_manager_test.zig index 806e255..2b0068a 100644 --- a/src/modules/forge/forge_3d/tests/body_manager_test.zig +++ b/src/modules/forge/forge_3d/tests/body_manager_test.zig @@ -462,3 +462,119 @@ test "friction and restitution survive addBody" { try testing.expectEqual(@as(?Real, null), bm.friction(id)); try testing.expectEqual(@as(?Real, null), bm.restitution(id)); } + +// --------------------------------------------------------------------------- +// M1.1.11 / E2 — a half-space forces a static body, and the class preconditions +// --------------------------------------------------------------------------- + +test "a dynamic or kinematic body carrying a half-space is rejected, before any derived computation" { + const gpa = testing.allocator; + var store = ShapeStore{}; + defer store.deinit(gpa); + var bm = BodyManager{}; + defer bm.deinit(gpa); + const plane = try store.createShape(gpa, .{ .plane = .{} }); + + // A half-space has no finite volume, no inertia tensor and no local AABB, so + // mass, inertia and sleep radius are not defined on it: a non-static body + // carrying one is refused by a TYPED error (`engine-physics-forge.md` §1.11.15), + // not accepted with unusual numbers. + for ([_]api.BodyType{ .dynamic, .kinematic }) |body_type| { + try testing.expectError( + error.ShapeMustBeStatic, + bm.addBody(gpa, &store, descOf(0, body_type, plane)), + ); + } + // Nothing was created on the way: the refusal precedes every mutation. + try testing.expectEqual(@as(u32, 0), bm.count()); + + // THE ORDERING, observed rather than asserted in a comment. Both quantities the + // literal derives — `computeMotion` on its dynamic path, and the sleep radius — + // carry a class precondition, and the plane's `unit_inertia` / `local_aabb` are + // NaN. So had the refusal come after the `Body` literal, the `.dynamic` case above + // would have PANICKED on `computeMotion`'s assert in this Debug/ReleaseSafe build + // instead of returning. `expectError` returning at all is the proof the refusal + // arrived first; the counter-factual was run once by hand, moving the refusal below + // the literal, and the panic was observed at `body.zig`'s assert. + // + // And the shape resolution still comes FIRST, since the refusal reads the class: + // a stale shape handle is `error.InvalidShape`, not `error.ShapeMustBeStatic`. + var doomed = ShapeStore{}; + defer doomed.deinit(gpa); + const gone = try doomed.createShape(gpa, .{ .plane = .{} }); + doomed.destroyShape(gone); + try testing.expectError( + error.InvalidShape, + bm.addBody(gpa, &doomed, descOf(1, .dynamic, gone)), + ); +} + +test "a static body carrying a half-space is accepted" { + const gpa = testing.allocator; + var store = ShapeStore{}; + defer store.deinit(gpa); + var bm = BodyManager{}; + defer bm.deinit(gpa); + const plane = try store.createShape(gpa, .{ .plane = .{ .normal = Vec3.unit_y, .distance = -2 } }); + + const id = try bm.addBody(gpa, &store, descOf(7, .static, plane)); + try testing.expect(bm.isValid(id)); + try testing.expectEqual(@as(u32, 1), bm.count()); + // A static body carries zero inverse mass and inertia whatever its shape, and + // `computeMotion` returns before touching the NaN `unit_inertia` — so the accepted + // body's motion properties are the ordinary static ones, not NaN. + const mp = bm.motionProperties(id).?; + try testing.expectEqual(@as(Real, 0), mp.inv_mass); + inline for (0..3) |i| { + try testing.expect(mp.local_inv_inertia.cols[i].approxEql(Vec3r.zero, 0)); + } + // Its sleep radius is NaN and nothing reads it: `sleep.updateWindows` and the + // island seeding both skip a non-dynamic body before they touch the radius, and a + // half-space has no local AABB to derive one from. + try testing.expect(std.math.isNan(bm.sleepRadius(id).?)); + // The pose round-trips like any other body's. + try testing.expect(bm.position(id).?.approxEql(Vec3r.zero, 0)); +} + +test "the class precondition of every reader discriminates both ways" { + const gpa = testing.allocator; + var store = ShapeStore{}; + defer store.deinit(gpa); + var bm = BodyManager{}; + defer bm.deinit(gpa); + + // The four readers — `computeSleepRadius`, `computeMotion`'s dynamic path, + // `worldAabb` and `bodyAabb` — all guard on the SAME predicate, + // `shape.class() == .convex`. A Zig assert cannot be caught in a test, so what is + // discriminated here is the predicate itself, on both answers; each assert sits + // one line from its read, and each was observed to fire once by hand. + const sphere_id = try store.createShape(gpa, .{ .sphere = .{ .radius = 2 } }); + const plane_id = try store.createShape(gpa, .{ .plane = .{} }); + const sphere = store.get(sphere_id).?; + const plane = store.get(plane_id).?; + try testing.expect(sphere.class() == .convex); // the readers proceed + try testing.expect(plane.class() != .convex); // the readers refuse + + // POSITIVE sense, executed: every reader answers on a convex, so the guards are + // not simply refusing everything. Sleep radius of a radius-2 sphere: its local + // AABB is `[−2, 2]³`, whose furthest corner is at `√(4+4+4) = 2√3`. + try testing.expectApproxEqAbs( + @as(Real, 2 * @sqrt(@as(Real, 3))), + body_mod.computeSleepRadius(sphere), + 1e-5, + ); + const dyn = bm_mod.worldAabb(sphere, vr(1, 0, 0), config.Quatr.identity); + try testing.expect(dyn.min.approxEql(vr(-1, -2, -2), 1e-6)); + const body = try bm.addBody(gpa, &store, descOf(0, .dynamic, sphere_id)); + try testing.expect(bm.bodyAabb(&store, body) != null); + try testing.expect(bm.motionProperties(body).?.inv_mass > 0); + + // NEGATIVE sense, on the data rather than through the guard: the plane's two + // fields really are the poison the readers are guarded against, so the guards + // protect something. NaN and not a finite placeholder — a `std.debug.assert` is + // compiled out of ReleaseFast, and MEASURED on the E1 commit an `undefined` + // `local_aabb` gave a plane a sleep radius of 5.2510e-13 at f32: finite, small + // and plausible enough that nobody would see it go past. + try testing.expect(std.math.isNan(plane.local_aabb.min.toArray()[0])); + try testing.expect(std.math.isNan(plane.unit_inertia.toArray()[0])); +} diff --git a/src/modules/forge/forge_3d/tests/broadphase_test.zig b/src/modules/forge/forge_3d/tests/broadphase_test.zig index da7fec6..0b81208 100644 --- a/src/modules/forge/forge_3d/tests/broadphase_test.zig +++ b/src/modules/forge/forge_3d/tests/broadphase_test.zig @@ -1504,3 +1504,484 @@ test "queryCast is empty on an empty tree" { try std.testing.expectEqual(@as(u32, 0), bph.queryCast(ray, extent, &got)); try std.testing.expectEqual(@as(usize, 0), got.items.items.len); } + +// --------------------------------------------------------------------------- +// M1.1.11 / E5 — unbounded shapes live OUTSIDE the trees +// --------------------------------------------------------------------------- +// +// 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. A 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 per-layer list outside the trees, iterated by slot index. + +/// The ground half-space `{ y <= 0 }` in world space. +const ground = BphF.UnboundedShape{ .normal = Vec3.unit_y, .distance = 0 }; + +test "a plane inserted AFTER the bodies pairs with all of them" { + const gpa = std.testing.allocator; + var bp = BphF.init(.{}); + defer bp.deinit(gpa); + var pairs: std.ArrayListUnmanaged(BphF.Pair) = .empty; + defer pairs.deinit(gpa); + + // THE DIRECTION A NAIVE SUITE OMITS, and the reason it omits it: pair generation is + // driven by the MOVED proxies, so a test that creates the plane first and then the + // bodies exercises only direction (1) — each body enters the moved log and is + // crossed with the unbounded list. Create the plane LAST and direction (1) never + // fires for it: the bodies moved before it existed, and their log was consumed. Only + // direction (2) — inserting an unbounded shape enumerates the existing leaves of the + // layers it may pair with — can produce these pairs, and without it a plane created + // after the bodies collides with NOTHING. + var bodies: [4]u32 = undefined; + for (0..4) |i| { + const x = @as(f32, @floatFromInt(i)) * 4; + // Each box straddles y = 0, so every one of them genuinely meets the half-space. + bodies[i] = (try bp.insert(gpa, .dynamic, boxCe(.{ x, 0, 0 }, 0.5), 100 + @as(u32, @intCast(i)))).id; + _ = bodies[i]; + } + // Consume the moved log, exactly as a tick would: after this the bodies have no + // pending motion at all. + try bp.computePairs(gpa, &pairs); + + // NOW the plane arrives. + const plane_ud: u32 = 7; + _ = try bp.insertUnbounded(gpa, .static, ground, plane_ud); + try bp.computePairs(gpa, &pairs); + + // All four pairs, and nothing else involving the plane. + for (0..4) |i| { + try std.testing.expect(hasPair(pairs.items, plane_ud, 100 + @as(u32, @intCast(i)))); + } + var involving: u32 = 0; + for (pairs.items) |p| { + if (p.a == plane_ud or p.b == plane_ud) involving += 1; + } + try std.testing.expectEqual(@as(u32, 4), involving); +} + +test "a body inserted AFTER the plane pairs with it" { + const gpa = std.testing.allocator; + var bp = BphF.init(.{}); + defer bp.deinit(gpa); + var pairs: std.ArrayListUnmanaged(BphF.Pair) = .empty; + defer pairs.deinit(gpa); + + // The other direction, and the one a naive suite DOES cover: the plane exists, the + // body enters the moved log, and direction (1) crosses it with the unbounded list. + const plane_ud: u32 = 7; + _ = try bp.insertUnbounded(gpa, .static, ground, plane_ud); + try bp.computePairs(gpa, &pairs); + try std.testing.expectEqual(@as(usize, 0), pairs.items.len); // nothing to pair with yet + + _ = try bp.insert(gpa, .dynamic, boxCe(.{ 0, 0, 0 }, 0.5), 100); + try bp.computePairs(gpa, &pairs); + try std.testing.expect(hasPair(pairs.items, plane_ud, 100)); + try std.testing.expectEqual(@as(usize, 1), pairs.items.len); +} + +test "an unbounded shape is crossed only with the layers the matrix allows" { + const gpa = std.testing.allocator; + var bp = BphF.init(.{}); + defer bp.deinit(gpa); + var pairs: std.ArrayListUnmanaged(BphF.Pair) = .empty; + defer pairs.deinit(gpa); + + // `default_layer_pairs` applies to an unbounded shape with NO special case. Read off + // the matrix: static×dynamic is allowed, static×debris is allowed, and + // static×trigger is FORBIDDEN — which is the combination this test asserts emits + // nothing, in both insertion orders, so the refusal is not an artefact of one + // direction of the pairing. + try std.testing.expect(broadphase.default_layer_pairs[@intFromEnum(Layer.static)][@intFromEnum(Layer.dynamic)]); + try std.testing.expect(broadphase.default_layer_pairs[@intFromEnum(Layer.static)][@intFromEnum(Layer.debris)]); + try std.testing.expect(!broadphase.default_layer_pairs[@intFromEnum(Layer.static)][@intFromEnum(Layer.trigger)]); + + // Plane on `static`; one body per layer, all straddling y = 0 so geometry never + // explains an absent pair. + _ = try bp.insertUnbounded(gpa, .static, ground, 7); + _ = try bp.insert(gpa, .dynamic, boxCe(.{ 0, 0, 0 }, 0.5), 100); + _ = try bp.insert(gpa, .debris, boxCe(.{ 4, 0, 0 }, 0.5), 200); + _ = try bp.insert(gpa, .trigger, boxCe(.{ 8, 0, 0 }, 0.5), 300); + try bp.computePairs(gpa, &pairs); + + try std.testing.expect(hasPair(pairs.items, 7, 100)); // static × dynamic + try std.testing.expect(hasPair(pairs.items, 7, 200)); // static × debris + try std.testing.expect(!hasPair(pairs.items, 7, 300)); // static × trigger: FORBIDDEN + + // The reverse insertion order, so direction (2) is the one under test as well. + var bp2 = BphF.init(.{}); + defer bp2.deinit(gpa); + _ = try bp2.insert(gpa, .trigger, boxCe(.{ 8, 0, 0 }, 0.5), 300); + try bp2.computePairs(gpa, &pairs); + _ = try bp2.insertUnbounded(gpa, .static, ground, 7); + try bp2.computePairs(gpa, &pairs); + try std.testing.expect(!hasPair(pairs.items, 7, 300)); +} + +test "the tree is untouched by an unbounded shape" { + const gpa = std.testing.allocator; + var bp = BphF.init(.{}); + defer bp.deinit(gpa); + + // Three bodies on `static`, then a half-space on the SAME layer. The tree must be + // bit-for-bit unaffected: same leaf count, same height, and every stored box still + // finite. An infinite box would have propagated to the root and made `height` and + // the SAH costs meaningless; a NaN centre would have poisoned every shape cast. + for (0..3) |i| { + _ = try bp.insert(gpa, .static, boxAt(@as(f32, @floatFromInt(i)) * 4), 100 + @as(u32, @intCast(i))); + } + const tree = &bp.trees[@intFromEnum(Layer.static)]; + const leaves_before = tree.leafCount(); + const height_before = tree.height(); + + _ = try bp.insertUnbounded(gpa, .static, ground, 7); + + try std.testing.expectEqual(leaves_before, tree.leafCount()); + try std.testing.expectEqual(height_before, tree.height()); + tree.validate(); // every structural and metric invariant, including AABB containment + + // NO NaN and NO infinity anywhere in the node pool — checked on the stored boxes + // themselves rather than inferred from the counts above. + for (tree.nodes.items) |node| { + if (node.height == -1) continue; // free-list slot, its box is not live + for (node.aabb.min.toArray()) |v| try std.testing.expect(std.math.isFinite(v)); + for (node.aabb.max.toArray()) |v| try std.testing.expect(std.math.isFinite(v)); + } + // And the unbounded shape is where it belongs: in the layer's list, not the tree. + try std.testing.expectEqual(@as(usize, 1), bp.unbounded[@intFromEnum(Layer.static)].items.len); + try std.testing.expectEqual(@as(u32, 3), tree.leafCount()); +} + +test "queryAabb, queryRay and queryCast all visit the unbounded list" { + const gpa = std.testing.allocator; + var bp = BphF.init(.{}); + defer bp.deinit(gpa); + + // DISCRIMINATION GUARD: the scene holds exactly ONE unbounded shape and NO tree + // proxy at all, so a collected `user_data` of 7 can have come from nowhere else. + // With a body in the scene the tests would pass on the body and prove nothing about + // the list. + _ = try bp.insertUnbounded(gpa, .static, ground, 7); + for (&bp.trees) |*t| try std.testing.expectEqual(@as(u32, 0), t.leafCount()); + + const Collect = struct { + seen: [8]u32 = undefined, + count: u32 = 0, + bound: f32 = 1e9, + pub fn add(self: *@This(), ud: u32) void { + if (self.count < self.seen.len) { + self.seen[self.count] = ud; + self.count += 1; + } + } + pub fn maxDistance(self: *const @This()) f32 { + return self.bound; + } + pub fn shouldStop(_: *const @This()) bool { + return false; + } + }; + + // (1) queryAabb — a box straddling y = 0 meets the half-space. + var c1 = Collect{}; + _ = bp.queryAabb(boxCe(.{ 0, 0, 0 }, 0.5), &c1); + try std.testing.expectEqual(@as(u32, 1), c1.count); + try std.testing.expectEqual(@as(u32, 7), c1.seen[0]); + // …and a box entirely ABOVE it does not, so the visit is a real test and not an + // unconditional add. + var c1b = Collect{}; + _ = bp.queryAabb(boxCe(.{ 0, 10, 0 }, 0.5), &c1b); + try std.testing.expectEqual(@as(u32, 0), c1b.count); + + // (2) queryRay — the list has no box to prune on, so it is offered to the collector + // and the exact kernel decides. The ray here points at the plane. + var c2 = Collect{}; + _ = bp.queryRay(BphF.RayT.init(Vec3.fromArray(.{ 0, 5, 0 }), Vec3.unit_y.neg()), &c2); + try std.testing.expectEqual(@as(u32, 1), c2.count); + try std.testing.expectEqual(@as(u32, 7), c2.seen[0]); + + // (3) queryCast — same, with a non-zero extent. + var c3 = Collect{}; + _ = bp.queryCast(BphF.RayT.init(Vec3.fromArray(.{ 0, 5, 0 }), Vec3.unit_y.neg()), Vec3.splat(0.5), &c3); + try std.testing.expectEqual(@as(u32, 1), c3.count); + try std.testing.expectEqual(@as(u32, 7), c3.seen[0]); + + // A collector that has stopped is honoured for the lists too, exactly as it is + // between the four trees. + const Stopper = struct { + count: u32 = 0, + pub fn add(self: *@This(), _: u32) void { + self.count += 1; + } + pub fn maxDistance(_: *const @This()) f32 { + return 1e9; + } + pub fn shouldStop(_: *const @This()) bool { + return true; + } + }; + var s = Stopper{}; + _ = bp.queryRay(BphF.RayT.init(Vec3.fromArray(.{ 0, 5, 0 }), Vec3.unit_y.neg()), &s); + try std.testing.expectEqual(@as(u32, 0), s.count); +} + +test "removing an unbounded proxy retires it from the list and from pairing" { + const gpa = std.testing.allocator; + var bp = BphF.init(.{}); + defer bp.deinit(gpa); + var pairs: std.ArrayListUnmanaged(BphF.Pair) = .empty; + defer pairs.deinit(gpa); + + const plane = try bp.insertUnbounded(gpa, .static, ground, 7); + _ = try bp.insert(gpa, .dynamic, boxCe(.{ 0, 0, 0 }, 0.5), 100); + try bp.computePairs(gpa, &pairs); + try std.testing.expect(hasPair(pairs.items, 7, 100)); + + // Removed: the slot is retired, not compacted — a live `Proxy` holds a slot index, so + // no surviving slot's index may shift, and the entry is marked dead in place (the + // `isLiveLeaf` discipline of the tree, one level up). + bp.remove(plane); + _ = try bp.insert(gpa, .dynamic, boxCe(.{ 4, 0, 0 }, 0.5), 200); + try bp.computePairs(gpa, &pairs); + for (pairs.items) |p| { + try std.testing.expect(p.a != 7 and p.b != 7); + } + // A query no longer sees it either. + const Collect = struct { + count: u32 = 0, + pub fn add(self: *@This(), _: u32) void { + self.count += 1; + } + pub fn maxDistance(_: *const @This()) f32 { + return 1e9; + } + pub fn shouldStop(_: *const @This()) bool { + return false; + } + }; + var c = Collect{}; + _ = bp.queryAabb(boxCe(.{ 100, 0, 100 }, 0.5), &c); // meets the half-space geometrically + try std.testing.expectEqual(@as(u32, 0), c.count); +} + +test "the unbounded list stops growing with the total ever created; its bound is the live PEAK" { + const gpa = std.testing.allocator; + var bp = BphF.init(.{}); + defer bp.deinit(gpa); + const list = &bp.unbounded[@intFromEnum(Layer.static)]; + + // MEASURED BOTH WAYS rather than asserted once (M1.1.11/E7-J3). Before the free-list, + // `remove` only cleared the `live` flag, so 64 create/destroy cycles left 64 slots in + // the list and `visitUnbounded` walked all of them for every query — a monotonic cost + // in the number of planes ever created. With the LIFO free-list the same sequence + // leaves exactly ONE, because the PEAK of simultaneously live slots is one throughout. + // + // The distinction is not pedantry and the last block of this test is what proves it: + // `items.len` never decreases, so the bound is that peak and NOT the live count. What + // the free-list removed is the dependence on the total, which was the pathology. + var live: BphF.Proxy = try bp.insertUnbounded(gpa, .static, ground, 0); + try std.testing.expectEqual(@as(usize, 1), list.items.len); + var i: u32 = 1; + while (i < 64) : (i += 1) { + bp.remove(live); + live = try bp.insertUnbounded(gpa, .static, ground, i); + // The list stays at the peak, at every point of the sequence — not just at the + // end, which a single final assertion would not distinguish from a list that grew + // and was compacted. + try std.testing.expectEqual(@as(usize, 1), list.items.len); + // …and the recycled slot is the SAME index every time: LIFO, one live entry. + try std.testing.expectEqual(@as(u32, 0), live.id); + } + try std.testing.expectEqual(@as(usize, 1), list.items.len); + + // The surviving shape is the LAST one inserted, not a stale earlier one: a query sees + // `user_data` 63 and nothing else. This is the discrimination that a reused slot + // carries its new occupant's payload and not the dead one's. + const Collect = struct { + seen: [8]u32 = undefined, + count: u32 = 0, + pub fn add(self: *@This(), ud: u32) void { + if (self.count < self.seen.len) { + self.seen[self.count] = ud; + self.count += 1; + } + } + pub fn maxDistance(_: *const @This()) f32 { + return 1e9; + } + pub fn shouldStop(_: *const @This()) bool { + return false; + } + }; + var c = Collect{}; + _ = bp.queryAabb(boxCe(.{ 0, 0, 0 }, 0.5), &c); + try std.testing.expectEqual(@as(u32, 1), c.count); + try std.testing.expectEqual(@as(u32, 63), c.seen[0]); + + // THE PEAK, isolated. Nine live at once occupies nine slots — the free-list recycles, + // it does not merge. + var held: [8]BphF.Proxy = undefined; + for (&held, 0..) |*h, k| h.* = try bp.insertUnbounded(gpa, .static, ground, 100 + @as(u32, @intCast(k))); + try std.testing.expectEqual(@as(usize, 9), list.items.len); // 1 live + 8 new + + // And it does not come back down. Freeing all eight leaves ONE live shape and NINE + // slots — the length tracks the peak, not the live count, which is the true bound + // stated on the `unbounded` field. A dense list would read 1 here; this one reads 9, + // and that is the deliberate design, not a defect. + for (held) |h| bp.remove(h); + try std.testing.expectEqual(@as(usize, 9), list.items.len); + + // Re-inserting reuses a slot rather than appending a tenth: the peak is a ceiling the + // list returns to, never a floor it climbs past. + _ = try bp.insertUnbounded(gpa, .static, ground, 200); + try std.testing.expectEqual(@as(usize, 9), list.items.len); +} + +test "a retired unbounded slot never surfaces in a pair after its index is reused" { + const gpa = std.testing.allocator; + var bp = BphF.init(.{}); + defer bp.deinit(gpa); + var pairs: std.ArrayListUnmanaged(BphF.Pair) = .empty; + defer pairs.deinit(gpa); + + // The stale-index exposure a free-list carries, exercised at its worst point: the + // slot is logged as moved, then freed, then REUSED by a different shape, all before + // `computePairs` runs. The moved entry now names the new occupant — which was itself + // logged on insertion — so the effect is a duplicate log entry, and the output is + // sorted and adjacent-deduped. The pair set must therefore name the NEW shape once + // and the dead one never. + _ = try bp.insert(gpa, .dynamic, boxCe(.{ 0, 0, 0 }, 0.5), 100); + const doomed = try bp.insertUnbounded(gpa, .static, ground, 7); + bp.remove(doomed); + const reborn = try bp.insertUnbounded(gpa, .static, ground, 8); + try std.testing.expectEqual(doomed.id, reborn.id); // the same slot, reused + try bp.computePairs(gpa, &pairs); + + try std.testing.expect(hasPair(pairs.items, 8, 100)); + var involving_dead: u32 = 0; + var involving_live: u32 = 0; + for (pairs.items) |p| { + if (p.a == 7 or p.b == 7) involving_dead += 1; + if (p.a == 8 or p.b == 8) involving_live += 1; + } + try std.testing.expectEqual(@as(u32, 0), involving_dead); + try std.testing.expectEqual(@as(u32, 1), involving_live); // once, not twice +} + +test "at full capacity with a free slot, an insertion needs no allocation at all" { + const gpa = std.testing.allocator; + var bp = BphF.init(.{}); + defer bp.deinit(gpa); + const list = &bp.unbounded[@intFromEnum(Layer.static)]; + var pairs: std.ArrayListUnmanaged(BphF.Pair) = .empty; + defer pairs.deinit(gpa); + + // The ONE configuration that separates an unconditional reserve from a conditional + // one, and it is not reachable by accident: the slot list must be EXACTLY at capacity + // (so `ensureUnusedCapacity(gpa, 1)` would grow it) while a free slot is available (so + // no growth is actually needed). `ensureUnusedCapacity` guarantees room for `len + 1`, + // which is why "at capacity" is the discriminating state and "below capacity" is not. + var held: [64]BphF.Proxy = undefined; + var n: usize = 0; + while (list.items.len != list.capacity or n == 0) { + std.debug.assert(n < held.len); + held[n] = try bp.insertUnbounded(gpa, .static, ground, @intCast(n)); + n += 1; + } + try std.testing.expectEqual(list.capacity, list.items.len); + + // Drain the moved log so ITS unconditional reserve is genuinely free: it keeps its + // capacity across `clearRetainingCapacity`, so after this the only allocation the + // insertion could still want is the slot-list growth under test. + try bp.computePairs(gpa, &pairs); + try std.testing.expectEqual(@as(usize, 0), bp.moved_unbounded[@intFromEnum(Layer.static)].items.len); + try std.testing.expect(bp.moved_unbounded[@intFromEnum(Layer.static)].capacity >= 1); + + // One free slot, list still exactly at capacity. + bp.remove(held[0]); + try std.testing.expectEqual(list.capacity, list.items.len); + + // Fail the very next allocation. With the conditional reserve the insertion performs + // NONE, so it must succeed; with the unconditional one it grows the slot list and + // returns `error.OutOfMemory`. + var failing = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 }); + const p = try bp.insertUnbounded(failing.allocator(), .static, ground, 999); + try std.testing.expectEqual(held[0].id, p.id); // the recycled slot, not a new one + try std.testing.expectEqual(list.capacity, list.items.len); // and nothing grew + try std.testing.expectEqual(@as(usize, 0), failing.allocations); +} + +test "iteration follows the slot index, and an identical op sequence gives an identical order" { + const gpa = std.testing.allocator; + + // The WANTED property, pinned — not insertion order, and not a tolerated side effect. + // §1.11.15's 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. So A, B, C, retire + // A, insert D iterates D, B, C — D took A's slot. + // + // It suffices because no observable result depends on this order: the query entries + // sort by the §1.11.14 key and `computePairs` sorts by the canonical pair key and + // adjacent-dedupes. What M1.1.14 requires is that the order be a DETERMINISTIC FUNCTION + // OF THE OPERATION SEQUENCE, which the second half of this test is. + const Order = struct { + fn of(bp: *const BphF, out: *[8]u32) usize { + var n: usize = 0; + for (bp.unbounded[@intFromEnum(Layer.static)].items) |slot| { + if (!slot.live) continue; + out[n] = slot.user_data; + n += 1; + } + return n; + } + }; + + var bp = BphF.init(.{}); + defer bp.deinit(gpa); + const a = try bp.insertUnbounded(gpa, .static, ground, 'A'); + _ = try bp.insertUnbounded(gpa, .static, ground, 'B'); + _ = try bp.insertUnbounded(gpa, .static, ground, 'C'); + var seen: [8]u32 = undefined; + try std.testing.expectEqual(@as(usize, 3), Order.of(&bp, &seen)); + try std.testing.expectEqualSlices(u32, &.{ 'A', 'B', 'C' }, seen[0..3]); + + bp.remove(a); + _ = try bp.insertUnbounded(gpa, .static, ground, 'D'); + // D, B, C — D occupies A's index 0. A dense list would give B, C, D here; a + // swap-removing one would give C, B or B, C, D depending on its own convention. This + // asserts the index rule, so it discriminates against both. + try std.testing.expectEqual(@as(usize, 3), Order.of(&bp, &seen)); + try std.testing.expectEqualSlices(u32, &.{ 'D', 'B', 'C' }, seen[0..3]); + + // THE ACTUAL REQUIREMENT (M1.1.14): the same operation sequence yields the same order. + // A second broadphase, driven identically, must iterate identically — including through + // the free-list, whose head is itself a function of that sequence. + var bp2 = BphF.init(.{}); + defer bp2.deinit(gpa); + const a2 = try bp2.insertUnbounded(gpa, .static, ground, 'A'); + _ = try bp2.insertUnbounded(gpa, .static, ground, 'B'); + _ = try bp2.insertUnbounded(gpa, .static, ground, 'C'); + bp2.remove(a2); + _ = try bp2.insertUnbounded(gpa, .static, ground, 'D'); + var seen2: [8]u32 = undefined; + const n2 = Order.of(&bp2, &seen2); + try std.testing.expectEqualSlices(u32, seen[0..3], seen2[0..n2]); + + // And a DIFFERENT sequence reaching the same live set gives a different order, which is + // what proves the previous assertion has power: retiring B instead of A puts D at + // index 1. + var bp3 = BphF.init(.{}); + defer bp3.deinit(gpa); + _ = try bp3.insertUnbounded(gpa, .static, ground, 'A'); + const b3 = try bp3.insertUnbounded(gpa, .static, ground, 'B'); + _ = try bp3.insertUnbounded(gpa, .static, ground, 'C'); + bp3.remove(b3); + _ = try bp3.insertUnbounded(gpa, .static, ground, 'D'); + var seen3: [8]u32 = undefined; + const n3 = Order.of(&bp3, &seen3); + try std.testing.expectEqualSlices(u32, &.{ 'A', 'D', 'C' }, seen3[0..n3]); +} diff --git a/src/modules/forge/forge_3d/tests/overlap_test.zig b/src/modules/forge/forge_3d/tests/overlap_test.zig index 3e07ada..2488bc8 100644 --- a/src/modules/forge/forge_3d/tests/overlap_test.zig +++ b/src/modules/forge/forge_3d/tests/overlap_test.zig @@ -56,7 +56,7 @@ test "overlapShape returns the overlapping bodies and nothing else" { const probe = try world.store.createShape(gpa, .{ .sphere = .{ .radius = 1 } }); var out: [8]api.BodyId = undefined; - const n = query.overlapShape(&world.bp, &world.bm, &world.store, .{ + const n = try query.overlapShape(&world.bp, &world.bm, &world.store, .{ .shape = probe, .position = Vec3r.zero, }, &out); @@ -66,7 +66,7 @@ test "overlapShape returns the overlapping bodies and nothing else" { try testing.expectEqual(grazing, out[1]); // A probe far from everything returns nothing rather than the candidate set. - const empty = query.overlapShape(&world.bp, &world.bm, &world.store, .{ + const empty = try query.overlapShape(&world.bp, &world.bm, &world.store, .{ .shape = probe, .position = v(100, 0, 0), }, &out); @@ -162,7 +162,7 @@ test "overlapShape accepts bodies inside and touching, and rejects those clear o const probe = try world.store.createShape(gpa, .{ .sphere = .{ .radius = 1 } }); var out: [8]api.BodyId = undefined; - const n = query.overlapShape(&world.bp, &world.bm, &world.store, .{ + const n = try query.overlapShape(&world.bp, &world.bm, &world.store, .{ .shape = probe, .position = Vec3r.zero, }, &out); @@ -375,9 +375,13 @@ fn buildCluster(gpa: std.mem.Allocator, world: *harness.World, order: []const us } } -fn runEntry(world: *harness.World, entry: Entry, probe: api.ShapeId, filter: query.Filter, out: []api.BodyId) u32 { +/// Fallible since M1.1.11/E3: `overlapShape` takes a caller-supplied shape handle and +/// so carries `query.Error`, while the other two entries take none and stay total. The +/// helper propagates rather than swallowing, so a stale probe or an inadmissible one +/// would fail the calling test instead of reading as an empty answer. +fn runEntry(world: *harness.World, entry: Entry, probe: api.ShapeId, filter: query.Filter, out: []api.BodyId) !u32 { return switch (entry) { - .shape => query.overlapShape(&world.bp, &world.bm, &world.store, .{ + .shape => try query.overlapShape(&world.bp, &world.bm, &world.store, .{ .shape = probe, .position = Vec3r.zero, .filter = filter, @@ -405,12 +409,12 @@ test "the object mask filters every overlap entry" { var ents: [8]u32 = undefined; // FULL mask: all five, ordered by entity. - const all = runEntry(&world, entry, probe, .{}, &out); + const all = try runEntry(&world, entry, probe, .{}, &out); try testing.expectEqual(@as(u32, 5), all); try testing.expectEqualSlices(u32, &.{ 0, 1, 2, 3, 4 }, entitiesOf(&world, out[0..all], &ents)); // A mask naming layers 1 and 3 — the layer IS the entity index here. - const two = runEntry(&world, entry, probe, .{ .layer_mask = (@as(u32, 1) << 1) | (@as(u32, 1) << 3) }, &out); + const two = try runEntry(&world, entry, probe, .{ .layer_mask = (@as(u32, 1) << 1) | (@as(u32, 1) << 3) }, &out); if (two != 2) { std.debug.print("{s}: masked to two layers, got {d}\n", .{ entry_names[ei], two }); return error.MaskIgnored; @@ -418,7 +422,7 @@ test "the object mask filters every overlap entry" { try testing.expectEqualSlices(u32, &.{ 1, 3 }, entitiesOf(&world, out[0..two], &ents)); // EMPTY mask: nothing at all, which is the other end of the domain. - try testing.expectEqual(@as(u32, 0), runEntry(&world, entry, probe, .{ .layer_mask = 0 }, &out)); + try testing.expectEqual(@as(u32, 0), try runEntry(&world, entry, probe, .{ .layer_mask = 0 }, &out)); } } @@ -433,11 +437,11 @@ test "exclusions are honoured by every overlap entry" { var out: [8]api.BodyId = undefined; var ents: [8]u32 = undefined; - const all = runEntry(&world, entry, probe, .{}, &out); + const all = try runEntry(&world, entry, probe, .{}, &out); try testing.expectEqual(@as(u32, 5), all); // Exclude two of them by handle — the dominant real case being "myself". const excluded = [_]api.BodyId{ out[0], out[2] }; - const rest = runEntry(&world, entry, probe, .{ .exclude = &excluded }, &out); + const rest = try runEntry(&world, entry, probe, .{ .exclude = &excluded }, &out); try testing.expectEqual(@as(u32, 3), rest); try testing.expectEqualSlices(u32, &.{ 1, 3, 4 }, entitiesOf(&world, out[0..rest], &ents)); } @@ -460,14 +464,14 @@ test "a caller buffer that overflows keeps the best set under the ordering key" var two: [2]api.BodyId = undefined; var ents: [2]u32 = undefined; - const n = runEntry(&world, entry, probe, .{}, &two); + const n = try runEntry(&world, entry, probe, .{}, &two); try testing.expectEqual(@as(u32, 2), n); try testing.expectEqualSlices(u32, &.{ 0, 1 }, entitiesOf(&world, two[0..n], &ents)); // A zero-length buffer writes nothing and says so, rather than // overrunning or reporting what it could not store. var none: [0]api.BodyId = undefined; - try testing.expectEqual(@as(u32, 0), runEntry(&world, entry, probe, .{}, &none)); + try testing.expectEqual(@as(u32, 0), try runEntry(&world, entry, probe, .{}, &none)); } } } @@ -490,12 +494,12 @@ test "every overlap entry is invariant under creation-order permutation" { const probe = try world.store.createShape(gpa, .{ .sphere = .{ .radius = 1 } }); var out: [8]api.BodyId = undefined; var ents: [8]u32 = undefined; - const n = runEntry(&world, entry, probe, .{}, &out); + const n = try runEntry(&world, entry, probe, .{}, &out); try testing.expectEqualSlices(u32, &.{ 0, 1, 2, 3, 4 }, entitiesOf(&world, out[0..n], &ents)); // Two identical runs in the same world return bit-identical slices. var again: [8]api.BodyId = undefined; - const m = runEntry(&world, entry, probe, .{}, &again); + const m = try runEntry(&world, entry, probe, .{}, &again); try testing.expectEqual(n, m); try testing.expectEqualSlices(api.BodyId, out[0..n], again[0..m]); } @@ -560,7 +564,7 @@ test "a sleeping body answers every overlap entry and stays asleep" { // not depend on where the solver left it. var out: [8]api.BodyId = undefined; const n = switch (entry) { - .shape => query.overlapShape(&world.bp, &world.bm, &world.store, .{ .shape = probe, .position = centre }, &out), + .shape => try query.overlapShape(&world.bp, &world.bm, &world.store, .{ .shape = probe, .position = centre }, &out), .aabb => query.overlapAabb(&world.bp, &world.bm, &world.store, centre.sub(v(0.1, 0.1, 0.1)), centre.add(v(0.1, 0.1, 0.1)), .{}, &out), .point => query.pointQuery(&world.bp, &world.bm, &world.store, centre, .{}, &out), }; @@ -760,3 +764,65 @@ test "overlapAabb rejects an inverted query box and returns nothing" { // Degenerate on ONE axis only, well-formed on the others: still legal. try testing.expectEqual(@as(u32, 1), query.overlapAabb(&world.bp, &world.bm, &world.store, v(0, -1, -1), v(0, 1, 1), .{}, &out)); } + +// --------------------------------------------------------------------------- +// M1.1.11 / E3 — the three-way outcome of `overlapShape` +// --------------------------------------------------------------------------- + +test "overlapShape separates a stale handle, an inadmissible probe and an empty answer" { + const gpa = std.testing.allocator; + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + + // The mirror of the `shapeCast` case (`engine-physics-forge.md` §1.11.7), and the + // conflation was the same one: MEASURED on the pre-E3 tree, a stale probe handle + // returned `0` and a live probe overlapping nothing returned `0`. A count cannot + // carry a diagnosis, so the entry gains the channel instead of overloading its + // return value. + const probe = try world.store.createShape(gpa, .{ .sphere = .{ .radius = 1 } }); + const plane = try world.store.createShape(gpa, .{ .plane = .{} }); + const doomed = try world.store.createShape(gpa, .{ .sphere = .{ .radius = 1 } }); + world.store.destroyShape(doomed); + _ = try addSphere(gpa, &world, .{ 0, 0, 0 }, 1, 0); + + var out: [4]api.BodyId = undefined; + + // (1) STALE HANDLE → typed error. + try testing.expectError(error.InvalidShape, query.overlapShape( + &world.bp, + &world.bm, + &world.store, + .{ .shape = doomed, .position = Vec3r.zero }, + &out, + )); + + // (2) INADMISSIBLE PROBE → a distinct typed error. The exact kernel is GJK on the + // cores (§1.11.12) and a half-space has no bounded core, so the probe is not + // expressible — never an empty set, which would read as "nothing overlaps". + try testing.expectError(error.UnsupportedShape, query.overlapShape( + &world.bp, + &world.bm, + &world.store, + .{ .shape = plane, .position = Vec3r.zero }, + &out, + )); + + // (3) EMPTY ANSWER → `0`, and only now: a live probe 500 m away from the one body. + try testing.expectEqual(@as(u32, 0), try query.overlapShape( + &world.bp, + &world.bm, + &world.store, + .{ .shape = probe, .position = v(0, 500, 0) }, + &out, + )); + + // Positive control: the same probe on the body's own position finds it, so the + // three answers above are refusals and not a broken entry. + try testing.expectEqual(@as(u32, 1), try query.overlapShape( + &world.bp, + &world.bm, + &world.store, + .{ .shape = probe, .position = Vec3r.zero }, + &out, + )); +} diff --git a/src/modules/forge/forge_3d/tests/plane_test.zig b/src/modules/forge/forge_3d/tests/plane_test.zig new file mode 100644 index 0000000..50c11fe --- /dev/null +++ b/src/modules/forge/forge_3d/tests/plane_test.zig @@ -0,0 +1,1593 @@ +//! M1.1.11 acceptance suite for the infinite plane (half-space). +//! +//! The half-space is the first shape whose geometry the existing narrowphase +//! cannot express: `{x : n·x <= d}` has an UNBOUNDED support map, diverging in +//! every direction but `−n`, so GJK, EPA and the M1.1.10 cast kernel — all built +//! on the support map — do not apply to it (`engine-physics-forge.md` §1.11.15). +//! The answer is a taxonomy ABOVE the support map: the category is chosen before a +//! shape is converted into a `SupportShape`, which is why that conversion stops +//! being a total function of the store and becomes an asserted precondition of the +//! convex arm. +//! +//! Every expectation below is a CLOSED FORM computed in the comment above it, +//! never a value read back from the implementation. + +const std = @import("std"); +const config = @import("../config.zig"); +const shape_mod = @import("../shape.zig"); +const narrowphase = @import("../pipeline/narrowphase/root.zig"); +const bm_mod = @import("../body_manager.zig"); +const broadphase_mod = @import("../pipeline/broadphase.zig"); +const query = @import("../query/root.zig"); +const harness = @import("solver_test.zig"); +const api = @import("weld_forge"); +const foundation = @import("foundation"); + +const Real = config.Real; +const Vec3r = config.Vec3r; +const Quatr = config.Quatr; +const BodyManager = bm_mod.BodyManager; +const ShapeStore = shape_mod.ShapeStore; +const ShapeClass = shape_mod.ShapeClass; +const ApiVec3 = foundation.math.Vec3; +const testing = std.testing; + +/// A descriptor-precision (`f32`) `Vec3` literal — the plane descriptor's own type. +fn av3(x: f32, y: f32, z: f32) ApiVec3 { + return ApiVec3.fromArray(.{ x, y, z }); +} + +/// A `Vec3r` literal at solver precision. +fn vr(x: Real, y: Real, z: Real) Vec3r { + return Vec3r.fromArray(.{ x, y, z }); +} + +// --------------------------------------------------------------------------- +// E1 — the taxonomy, the stored half-space, and the unit-normal invariant +// --------------------------------------------------------------------------- + +test "the plane descriptor payload defaults to the +Y half-space through the origin" { + // The payload is the frozen `engine-tier-interfaces.md` §1 (v0.4) form: a unit + // `normal` and a `distance` in metres. Its default is `{x : y <= 0}` — a ground + // plane through the origin — and the default normal is EXACTLY unit, which is + // what lets `.plane = .{}` pass the creation-time domain assert unchanged. + const desc = api.ShapeDescriptor{ .plane = .{} }; + try testing.expect(desc.plane.normal.eql(ApiVec3.unit_y)); + try testing.expectEqual(@as(f32, 0), desc.plane.distance); + // Field types are the frozen ones: `Vec3` (f32) and `f32`. + try testing.expectEqual(ApiVec3, @TypeOf(desc.plane.normal)); + try testing.expectEqual(f32, @TypeOf(desc.plane.distance)); +} + +test "a plane carries the half_space class and sphere, box and capsule carry convex" { + const gpa = testing.allocator; + var store = ShapeStore{}; + defer store.deinit(gpa); + + // BOTH senses of the classifier, not just the new one: a classifier only ever + // seen to answer `half_space` would pass while answering `half_space` to + // everything. + const plane = try store.createShape(gpa, .{ .plane = .{} }); + try testing.expectEqual(ShapeClass.half_space, store.get(plane).?.class()); + + const sphere_id = try store.createShape(gpa, .{ .sphere = .{ .radius = 1 } }); + const box_id = try store.createShape(gpa, .{ .box = .{ .half_extents = av3(1, 2, 3) } }); + const capsule_id = try store.createShape(gpa, .{ .capsule = .{ .radius = 0.3, .half_height = 0.9 } }); + for ([_]api.ShapeId{ sphere_id, box_id, capsule_id }) |id| { + try testing.expectEqual(ShapeClass.convex, store.get(id).?.class()); + } + + // Two variants exactly. The mesh is the THIRD category of §1.11.15 and arrives + // at M1.1.11.1; pinning the count is what makes its arrival a deliberate act + // rather than a silent widening, since every switch on the class is exhaustive + // and will stop compiling. + try testing.expectEqual(@as(usize, 2), @typeInfo(ShapeClass).@"enum".fields.len); +} + +test "the stored plane normal is unit, and independent of distance" { + const gpa = testing.allocator; + var store = ShapeStore{}; + defer store.deinit(gpa); + + // TWO bounds, and they are deliberately not the same bound — the §1.11.4 bis + // separation between a normal's LENGTH and its ORIENTATION, applied here at the + // creation boundary instead of the far field. + // + // The LENGTH is a structural invariant of the normalisation, so it is asserted + // TIGHT at the SOLVER precision: `|n|² == 1` up to the float noise of + // `normalize` itself — the squared length costs ~1.5 ulp, the square root 0.5, + // the reciprocal 0.5 and the product 0.5, so under 8 ulp of 1. + const length_tight: Real = 8 * std.math.floatEps(Real); + // The ORIENTATION can only ever carry the resolution of its INPUT, and the input + // is `f32` by design (§1.11.8: the public surface stays f32 whatever the solver + // scalar). So the direction is pinned to one ulp of `f32` AT BOTH PRECISIONS — + // asserting it at `floatEps(Real)` would be asserting that an f64 solver recovers + // information the f32 descriptor never carried. + const direction_tol: Real = std.math.floatEps(f32); + // MEASURED on the oblique normal below, in the build, at both precisions: + // + // leg |n|² − 1 direction vs the comparison target + // f32 0 (0 ulp f32) 0 (0 ulp f32) + // f64 2.220446e-16 (1 ulp f64) 9.123160e-9 (0.0765 ulp f32) + // + // The f32 leg is EXACTLY zero on both, and that is a property rather than a + // coincidence: the descriptor is already an f32-unit vector, so its squared + // length rounds to exactly 1, the square root of 1 is 1, its reciprocal is 1, + // and multiplying by 1 is the identity — at f32 the normalisation costs nothing + // because it has nothing to correct, and the comparison target `vr(2/7, …)` is + // bit-identical to the `av3(2/7, …)` that was stored. The f64 leg is where the + // widening it exists to correct is visible at all. + + // `distance` cannot enter the normal — it is an offset along it — and the sweep + // spans nine orders of magnitude to say so with evidence rather than by + // inspection of the constructor. + const distances = [_]f32{ 0, -1, 1, 0.5, -1000, 1e6, -1e-6, 12345.678 }; + // A normal that is NOT axis-aligned, so a lazy `normalize` that happened to be + // a no-op on a unit axis would be caught: (2, −3, 6)/7, an exact Pythagorean + // quadruple (4 + 9 + 36 = 49), so the closed form is a representable rational + // and the direction check has something exact to compare against. + const oblique = av3(2.0 / 7.0, -3.0 / 7.0, 6.0 / 7.0); + for (distances) |d| { + const id = try store.createShape(gpa, .{ .plane = .{ .normal = oblique, .distance = d } }); + const record = store.get(id).?; + try testing.expect(@abs(record.normal.lengthSq() - 1) <= length_tight); + // The direction is preserved, not merely the length: (2, −3, 6)/7. + try testing.expect(record.normal.approxEql(vr(2.0 / 7.0, -3.0 / 7.0, 6.0 / 7.0), direction_tol)); + // `distance` is stored as given, widened and nothing else. + try testing.expectEqual(@as(Real, d), record.distance); + } +} + +test "a non-unit plane normal is normalised at creation, direction preserved" { + const gpa = testing.allocator; + var store = ShapeStore{}; + defer store.deinit(gpa); + + // The `Body.rotation` pattern verbatim: the descriptor is asserted unit at `f32` + // tolerance and the WIDENED value is normalised once at creation, so no call site + // ever re-normalises. The two are one mechanism — the assert is what makes the + // normalisation total in what it does (it corrects the widening; it does not + // rescue an invalid input), and the widening is what makes it necessary: an + // f32-unit vector widened to f64 is off by up to ~6e-8 in its squared norm. + // + // Input: the f32 normalisation of (1, 1, 1), whose squared norm at `Real` is + // 1 ± float noise. Expected stored value: (1, 1, 1)/√3 at `Real`. + // + // This case admits a TIGHT direction bound at both precisions, and for a reason + // rather than by luck: the three components are EQUAL, and normalising `(c, c, c)` + // yields `(1/√3, 1/√3, 1/√3)` whatever `c` is, so the f32 quantisation of the + // input cancels out entirely instead of surviving into the direction the way it + // does for the oblique normal above. MEASURED, in the build: 5.960465e-8 at f32 + // and 1.110223e-16 at f64 — half an ulp of `Real` on both legs. + const nearly = av3(1, 1, 1).normalize(); + const id = try store.createShape(gpa, .{ .plane = .{ .normal = nearly } }); + const record = store.get(id).?; + + const inv_root3: Real = 1.0 / @sqrt(@as(Real, 3)); + const tight: Real = 8 * std.math.floatEps(Real); + try testing.expect(@abs(record.normal.lengthSq() - 1) <= tight); + try testing.expect(record.normal.approxEql(vr(inv_root3, inv_root3, inv_root3), 4 * tight)); +} + +test "a plane shape has no inflation radius and reports its own type" { + const gpa = testing.allocator; + var store = ShapeStore{}; + defer store.deinit(gpa); + const id = try store.createShape(gpa, .{ .plane = .{ .normal = av3(0, 0, -1), .distance = 3 } }); + const record = store.get(id).?; + + try testing.expectEqual(api.ShapeType.plane, record.shape_type); + // A half-space is not a core plus an inflation: it IS the solid, so its radius + // is exactly zero and the `− r_b` term of §1.11.15's separation formula belongs + // to the OTHER shape, never to this one. + try testing.expectEqual(@as(Real, 0), record.radius); + try testing.expect(record.normal.approxEql(vr(0, 0, -1), 0)); + try testing.expectEqual(@as(Real, 3), record.distance); +} + +test "a plane shape occupies a store slot like any other and reuses it LIFO" { + const gpa = testing.allocator; + var store = ShapeStore{}; + defer store.deinit(gpa); + + // A half-space owns no memory — it is POD — so it takes the ordinary + // generational slot path and `destroyShape` frees nothing. Pinned because the + // mesh (M1.1.11.1) is the shape that changes this, and the change must be + // visible against a baseline. + const a = try store.createShape(gpa, .{ .plane = .{} }); + const b = try store.createShape(gpa, .{ .plane = .{ .normal = av3(1, 0, 0), .distance = -2 } }); + try testing.expectEqual(@as(u32, 2), store.count()); + + store.destroyShape(b); + try testing.expect(store.get(b) == null); // stale ⇒ the safe getter says so + const c = try store.createShape(gpa, .{ .sphere = .{ .radius = 1 } }); // reuses b's slot + try testing.expectEqual(api.PackedId.unpack(b).index, api.PackedId.unpack(c).index); + try testing.expectEqual(api.PackedId.unpack(b).generation +% 1, api.PackedId.unpack(c).generation); + try testing.expectEqual(ShapeClass.half_space, store.get(a).?.class()); + try testing.expectEqual(ShapeClass.convex, store.get(c).?.class()); +} + +// --------------------------------------------------------------------------- +// E2 — the two poisoned fields, observable +// --------------------------------------------------------------------------- + +test "a plane's local aabb and unit inertia are NaN on every component" { + const gpa = testing.allocator; + var store = ShapeStore{}; + defer store.deinit(gpa); + + // The poison is a PINNED BEHAVIOUR, not an intention in a comment. `local_aabb` + // and `unit_inertia` have no meaning for an unbounded shape with no finite volume, + // and their readers assert `class() == .convex` — but a `std.debug.assert` is + // compiled OUT of ReleaseFast, which is the mode the benches run in with a plane in + // the scene. So the fields carry NaN as well: the assert is the primary guard in a + // safe build, the NaN is the one that outlives it and propagates loudly through any + // arithmetic that reaches it. + // + // The alternative was measured on the E1 commit, where the fields were `undefined`: + // a plane's sleep radius came out 5.2510e-13 at f32 and 6.4444e-104 at f64, and the + // Debug 0xAA fill reads as −3.0316e-13 — all three finite, small and entirely + // plausible, which is precisely why nobody would ever notice one. + const id = try store.createShape(gpa, .{ .plane = .{ .normal = av3(0, 0, 1), .distance = 5 } }); + const record = store.get(id).?; + inline for (0..3) |i| { + try testing.expect(std.math.isNan(record.local_aabb.min.toArray()[i])); + try testing.expect(std.math.isNan(record.local_aabb.max.toArray()[i])); + try testing.expect(std.math.isNan(record.unit_inertia.toArray()[i])); + } + + // The geometry that DOES have meaning is untouched by the poison, so a reader that + // wants the plane finds it: the normal, the offset, and a zero radius. + try testing.expect(record.normal.approxEql(vr(0, 0, 1), 0)); + try testing.expectEqual(@as(Real, 5), record.distance); + try testing.expectEqual(@as(Real, 0), record.radius); + + // A convex keeps both fields finite — the poison is the half-space's, not a + // property of the store. + const convex = store.get(try store.createShape(gpa, .{ .sphere = .{ .radius = 1 } })).?; + inline for (0..3) |i| { + try testing.expect(!std.math.isNan(convex.local_aabb.min.toArray()[i])); + try testing.expect(!std.math.isNan(convex.unit_inertia.toArray()[i])); + } +} + +// --------------------------------------------------------------------------- +// E4 — the analytic kernels of §1.11.15's table +// --------------------------------------------------------------------------- + +/// The half-space `{ x : normal·x <= distance }` at solver precision. +const HS = narrowphase.plane.HalfSpace(Real); +const SS = narrowphase.SupportShape(Real); +const RP = narrowphase.RelativePose(Real); + +/// The three planes every kernel below is exercised against: two axis-aligned with +/// opposite signs, and one OBLIQUE — `(2, −3, 6)/7`, an exact Pythagorean quadruple +/// (4 + 9 + 36 = 49) so its components are representable rationals and the closed +/// forms stay exact. An axis-aligned-only suite would never see a normal whose +/// components interact. +const planes = [_]struct { name: []const u8, n: Vec3r }{ + .{ .name = "+Y", .n = Vec3r.unit_y }, + .{ .name = "-X", .n = Vec3r.unit_x.neg() }, + .{ .name = "oblique", .n = Vec3r.fromArray(.{ 2.0 / 7.0, -3.0 / 7.0, 6.0 / 7.0 }) }, +}; + +/// Relative slack for a closed-form scalar at the scale of the tens of metres these +/// scenes use — float noise, not geometric slack. Same shape as +/// `shapecast_test.zig`'s `tol`. +const tol: Real = if (Real == f32) 1e-5 else 1e-12; + +/// A sphere support shape: a point core of inflation radius `r`. +fn sphere(r: Real) SS { + return .{ .core = .point, .radius = r }; +} + +/// A box support shape: the full box, radius 0 (a box has no convex radius). +fn boxShape(hx: Real, hy: Real, hz: Real) SS { + return .{ .core = .{ .box = vr(hx, hy, hz) }, .radius = 0 }; +} + +/// A capsule support shape: a Y-segment core of half-height `h`, inflation `r`. +fn capsule(r: Real, h: Real) SS { + return .{ .core = .{ .segment = h }, .radius = r }; +} + +/// B translated by `p` with no rotation, relative to a half-space A at the origin — +/// the pose `separation` takes. +fn poseAt(p: Vec3r) RP { + return RP.init(Vec3r.zero, Quatr.identity, p, Quatr.identity); +} + +test "separation against a sphere subtracts the radius" { + // THE DISCRIMINATING TEST OF THE WHOLE KERNEL (§1.11.15). `SupportShape.support` + // returns the support of the CORE, radius EXCLUDED, and a sphere's core is a single + // point at its centre. So a unit sphere whose CENTRE lies exactly on the boundary + // plane is penetrating by its whole radius — it is NOT touching. + // + // sep = n · supportCore_B(−n) − r_b − d + // = n · centre − 1 − 0 with the centre on the plane, n·c = d = 0 + // = −1 + // + // The refuted form, `n · supportCore_B(−n) − d` with no radius term, gives 0 — + // "touching". Both values are asserted, so the test can TELL THE TWO APART: were + // the term dropped, the first assertion would fail with exactly the second's value. + for (planes) |p| { + const plane = HS{ .normal = p.n, .distance = 0 }; + // Centre exactly on the plane through the origin: any point with n·c = 0. Take + // the origin itself, which lies on every plane through it. + const sep = narrowphase.plane.separation(Real, plane, poseAt(Vec3r.zero), sphere(1)); + try testing.expectApproxEqAbs(@as(Real, -1), sep, tol); + // The radius-free form, written out here rather than described: it is the same + // support call without `− r_b`. + const radius_free = plane.signedDistance(poseAt(Vec3r.zero).supportB(sphere(1), p.n.neg())); + try testing.expectApproxEqAbs(@as(Real, 0), radius_free, tol); + // …and they differ by EXACTLY the radius, which is the failure mode's amplitude. + try testing.expectApproxEqAbs(@as(Real, 1), radius_free - sep, tol); + + // A sphere whose centre is 3 m outside is separated by 3 − 1 = 2. + const outside = narrowphase.plane.separation(Real, plane, poseAt(p.n.scale(3)), sphere(1)); + try testing.expectApproxEqAbs(@as(Real, 2), outside, tol); + // Its own surface exactly touching: centre at 1 m out, radius 1 ⇒ sep = 0. + const touching = narrowphase.plane.separation(Real, plane, poseAt(p.n.scale(1)), sphere(1)); + try testing.expectApproxEqAbs(@as(Real, 0), touching, tol); + } +} + +test "separation against a box agrees with the radius-free form, which is why a box-only suite would pass" { + // A box's core radius is 0, so `− r_b` changes nothing for it: the two forms agree + // EXACTLY. Asserted, because it is the reason the sphere test above exists and may + // not be dropped — a suite that only tested boxes would pass with the term missing. + // + // Closed form for a box of half-extents `h` at centre `c`, identity rotation: the + // core support along `−n` minimises `n·x` over the box, giving + // n·c − (|n_x|·h_x + |n_y|·h_y + |n_z|·h_z) − 0 − d. + const half = vr(1, 2, 3); + for (planes) |p| { + const plane = HS{ .normal = p.n, .distance = 0 }; + const na = p.n.abs().toArray(); + const projected = na[0] * 1 + na[1] * 2 + na[2] * 3; // the box's extent along n + for ([_]Real{ 0, 5, -5, 12.5 }) |offset| { + const centre = p.n.scale(offset); + const sep = narrowphase.plane.separation(Real, plane, poseAt(centre), boxShape(half.toArray()[0], half.toArray()[1], half.toArray()[2])); + try testing.expectApproxEqAbs(offset - projected, sep, tol); + const radius_free = plane.signedDistance(poseAt(centre).supportB(boxShape(1, 2, 3), p.n.neg())); + try testing.expectApproxEqAbs(radius_free, sep, 0); // EXACTLY equal: r_b = 0 + } + } +} + +test "separation against a capsule subtracts the radius and reads its axis" { + // Closed form for a Y-segment core of half-height `h` and radius `r` at centre `c`, + // identity rotation: the extremal endpoint along `−n` contributes `−|n_y|·h`, so + // sep = n·c − |n_y|·h − r − d. + const h: Real = 0.9; + const r: Real = 0.3; + for (planes) |p| { + const plane = HS{ .normal = p.n, .distance = 0 }; + const ny = @abs(p.n.toArray()[1]); + for ([_]Real{ 0, 4, -4 }) |offset| { + const sep = narrowphase.plane.separation(Real, plane, poseAt(p.n.scale(offset)), capsule(r, h)); + try testing.expectApproxEqAbs(offset - ny * h - r, sep, tol); + } + // ROTATED: 90° about +Z maps the capsule's local +Y axis onto world −X, so the + // axis term becomes `|n_x|·h` instead of `|n_y|·h`. That is the case an + // identity-rotation-only suite would miss entirely. + const nx = @abs(p.n.toArray()[0]); + const spun = RP.init(Vec3r.zero, Quatr.identity, Vec3r.zero, Quatr.fromAxisAngle(Vec3r.unit_z, std.math.pi / 2.0)); + try testing.expectApproxEqAbs(-nx * h - r, narrowphase.plane.separation(Real, plane, spun, capsule(r, h)), tol); + } +} + +test "raycast against a half-space" { + for (planes) |p| { + // Solid is `{ x : n·x <= 0 }`. A point at `n·t` is at signed distance `t` + // (`|n| = 1`), so the four cases below have closed-form answers in `t`. + const plane = HS{ .normal = p.n, .distance = 0 }; + const outside = p.n.scale(5); // 5 m outside + const inside = p.n.scale(-5); // 5 m inside + // A direction in the boundary plane: any unit vector orthogonal to `n`. The + // cross product with the least-aligned axis is non-degenerate for every `n`. + const tangent = p.n.cross(if (@abs(p.n.toArray()[0]) < 0.9) Vec3r.unit_x else Vec3r.unit_y).normalize(); + + // (1) From OUTSIDE, crossing: travelling along `−n` covers the 5 m exactly. + const crossing = narrowphase.plane.rayShape(Real, plane, outside, p.n.neg()).?; + try testing.expectApproxEqAbs(@as(Real, 5), crossing.distance, tol); + // The normal is the STORED normal, bit-for-bit — no arithmetic produced it. + try testing.expect(crossing.normal.eql(p.n)); + try testing.expect(crossing.normal.dot(p.n.neg()) <= 0); + + // (2) From OUTSIDE, receding: `n·dir = +1 >= 0` ⇒ miss. + try testing.expect(narrowphase.plane.rayShape(Real, plane, outside, p.n) == null); + + // (3) From OUTSIDE, PARALLEL: `n·dir` is EXACTLY 0 ⇒ miss. The guard is at true + // zero and this is the case that exercises it. + try testing.expectEqual(@as(Real, 0), p.n.dot(tangent) * 0); // the product is the guarded quantity + try testing.expect(narrowphase.plane.rayShape(Real, plane, outside, tangent) == null); + + // (4) From INSIDE, PARALLEL: solid membership answers FIRST, so the division is + // never reached — a hit at distance 0 with normal `−direction`. + const in_parallel = narrowphase.plane.rayShape(Real, plane, inside, tangent).?; + try testing.expectEqual(@as(Real, 0), in_parallel.distance); + try testing.expect(in_parallel.normal.eql(tangent.neg())); + + // (5) An origin INSIDE, any direction: hit at 0, normal `−direction` (§1.11.4). + for ([_]Vec3r{ p.n, p.n.neg(), tangent }) |d| { + const hit = narrowphase.plane.rayShape(Real, plane, inside, d).?; + try testing.expectEqual(@as(Real, 0), hit.distance); + try testing.expect(hit.normal.eql(d.neg())); + try testing.expect(hit.normal.dot(d) <= 0); + } + + // (6) Exactly ON the boundary is INSIDE (closed half-space): distance 0. + const on = narrowphase.plane.rayShape(Real, plane, Vec3r.zero, p.n).?; + try testing.expectEqual(@as(Real, 0), on.distance); + + // (7) An oblique incidence, so the quotient is not just ±1: a direction at 45° + // to the plane covers `5·√2` before reaching it. + const oblique_dir = p.n.neg().add(tangent).normalize(); + const slanted = narrowphase.plane.rayShape(Real, plane, outside, oblique_dir).?; + try testing.expectApproxEqAbs(5 * @sqrt(@as(Real, 2)), slanted.distance, tol); + } +} + +test "solid membership includes the boundary" { + for (planes) |p| { + const plane = HS{ .normal = p.n, .distance = 1 }; + // The boundary is `n·x = 1`, so `n·1` is exactly on it. + const on_boundary = p.n.scale(1); + try testing.expect(narrowphase.plane.containsPoint(Real, plane, on_boundary)); + // Strictly inside. + try testing.expect(narrowphase.plane.containsPoint(Real, plane, p.n.scale(0.5))); + try testing.expect(narrowphase.plane.containsPoint(Real, plane, p.n.scale(-1000))); + // ONE ULP outside. `1 + floatEps` is the next representable above 1 and + // `(1 + eps) − 1` is exactly `eps`, so the signed distance is strictly positive + // by the smallest amount the scalar can express at this magnitude. + const one_ulp_out = p.n.scale(1 + std.math.floatEps(Real)); + try testing.expect(!narrowphase.plane.containsPoint(Real, plane, one_ulp_out)); + // And the boundary case is decided by `<=`, not `<`: shifting the plane instead + // of the point gives the same verdict. + try testing.expect(narrowphase.plane.containsPoint(Real, HS{ .normal = p.n, .distance = 0 }, Vec3r.zero)); + } +} + +test "closest point projects orthogonally and answers the point itself inside" { + for (planes) |p| { + const plane = HS{ .normal = p.n, .distance = 0 }; + // A point 5 m outside plus an arbitrary in-plane offset: the projection removes + // the normal component and keeps the tangential one, so distance = 5 exactly and + // the position is the point minus 5·n. + const tangent = p.n.cross(if (@abs(p.n.toArray()[0]) < 0.9) Vec3r.unit_x else Vec3r.unit_y).normalize(); + const outside = p.n.scale(5).add(tangent.scale(3)); + const out = narrowphase.plane.closestPoint(Real, plane, outside); + try testing.expectApproxEqAbs(@as(Real, 5), out.distance, tol); + try testing.expect(out.position.approxEql(tangent.scale(3), tol)); + // The projection lies ON the boundary. + try testing.expectApproxEqAbs(@as(Real, 0), plane.signedDistance(out.position), tol); + + // INSIDE — boundary included — is distance 0 at the point ITSELF, not its + // projection onto the boundary (§1.11.13's solidity convention). The two differ, + // which is what makes this assertion discriminating. + const inside = p.n.scale(-5).add(tangent.scale(3)); + const in = narrowphase.plane.closestPoint(Real, plane, inside); + try testing.expectEqual(@as(Real, 0), in.distance); + try testing.expect(in.position.eql(inside)); + try testing.expect(!in.position.approxEql(tangent.scale(3), tol)); // NOT the projection + + // Exactly on the boundary: inside, so the point itself. + const on = narrowphase.plane.closestPoint(Real, plane, tangent.scale(3)); + try testing.expectEqual(@as(Real, 0), on.distance); + try testing.expect(on.position.eql(tangent.scale(3))); + } +} + +test "shape cast against a half-space" { + // Everything is in A's frame — A is the shape being cast, untransformed there — so + // the plane is expressed in that frame and the closed forms read directly off the + // probe's lowest point along `−n`. + // + // Plane: `{ x : y <= −10 }`. Each probe's lowest point along −Y is at + // sphere(0.5) → y = −0.5 ⇒ travel 9.5 + // box(1, 2, 3) → y = −2 ⇒ travel 8 + // capsule(0.3, 0.9)→ y = −1.2 ⇒ travel 8.8 + const plane = HS{ .normal = Vec3r.unit_y, .distance = -10 }; + const down = Vec3r.unit_y.neg(); + const cases = [_]struct { probe: SS, travel: Real }{ + .{ .probe = sphere(0.5), .travel = 9.5 }, + .{ .probe = boxShape(1, 2, 3), .travel = 8 }, + .{ .probe = capsule(0.3, 0.9), .travel = 8.8 }, + }; + for (cases) |c| { + const hit = narrowphase.plane.castShape(Real, plane, c.probe, down, 100).?; + try testing.expectApproxEqAbs(c.travel, hit.distance, tol); + // The witness is ON the boundary plane, and the normal is the plane's, unit and + // opposing the sweep. + try testing.expectApproxEqAbs(@as(Real, 0), plane.signedDistance(hit.point), tol); + try testing.expect(hit.normal.eql(Vec3r.unit_y)); + try testing.expect(hit.normal.dot(down) <= 0); + + // The interval is CLOSED: a bound exactly at the answer is a hit, and STRICT + // exceedance is the miss (§1.11.11). + try testing.expect(narrowphase.plane.castShape(Real, plane, c.probe, down, c.travel) != null); + try testing.expect(narrowphase.plane.castShape(Real, plane, c.probe, down, c.travel - 4 * tol) == null); + + // RECEDING: `n·dir = +1 >= 0` ⇒ miss, whatever the bound. + try testing.expect(narrowphase.plane.castShape(Real, plane, c.probe, Vec3r.unit_y, 1e6) == null); + // GRAZING — the sweep runs parallel to the boundary, `n·dir` EXACTLY 0. The + // guard is at true zero and this is the case that exercises it. + try testing.expect(narrowphase.plane.castShape(Real, plane, c.probe, Vec3r.unit_x, 1e6) == null); + try testing.expect(narrowphase.plane.castShape(Real, plane, c.probe, Vec3r.unit_z.neg(), 1e6) == null); + } + + // An OBLIQUE sweep, so the quotient is not ±1: at 45° between −Y and +X the sphere + // covers 9.5·√2. + const diagonal = Vec3r.unit_y.neg().add(Vec3r.unit_x).normalize(); + const slanted = narrowphase.plane.castShape(Real, plane, sphere(0.5), diagonal, 100).?; + try testing.expectApproxEqAbs(9.5 * @sqrt(@as(Real, 2)), slanted.distance, tol); + + // INITIAL CONTACT, and the witness is NOT the cast origin. Plane `{ y <= −1 }` with + // a box(1,2,3) at A's origin: its lowest point along −Y is (1, −2, 3) — the `>= 0` + // tie-break picking `+h` on the two zero components of the direction — so + // `sep₀ = −2 − (−1) = −1 <= 0` and the witness is that point projected onto the + // boundary, (1, −1, 3). The cast origin is (0, 0, 0): the two are demonstrably + // different, which is what makes this assertion able to refute `position = origin`. + const touching = HS{ .normal = Vec3r.unit_y, .distance = -1 }; + const initial = narrowphase.plane.castShape(Real, touching, boxShape(1, 2, 3), down, 100).?; + try testing.expectEqual(@as(Real, 0), initial.distance); + try testing.expect(initial.point.approxEql(vr(1, -1, 3), tol)); + // …and NOT the cast origin, which the analogy with a ray would have suggested. + try testing.expect(!initial.point.approxEql(Vec3r.zero, tol)); + try testing.expectApproxEqAbs(@as(Real, 0), touching.signedDistance(initial.point), tol); +} + +test "an oblique far-field configuration keeps a unit normal" { + // The §1.11.4 bis obligation, and §1.11.15 states how it decomposes DIFFERENTLY for + // a half-space. There the normal is RECONSTRUCTED, so only its length is invariant + // with distance. Here it is the STORED `n` returned VERBATIM, with no intermediate + // arithmetic at all — so length AND orientation are exact at any range, and the + // assertion is BIT EQUALITY rather than a bound. Which is why this suite asserts the + // normal tight and everywhere, and reserves the scale-relative bound for the scalar + // alone. + // + // What carries the residue instead is the SCALAR `signedDistance = n·p − d`, a + // difference of two quantities that both grow with `|p|`. Its absolute error grows + // like `floatEps(T)·|p|`, which is the same structural worldspace limit + // `-Dphysics_f64` answers (§1.11.8) — characterised here, not hidden. + const n = Vec3r.fromArray(.{ 2.0 / 7.0, -3.0 / 7.0, 6.0 / 7.0 }); // oblique, exact rational + const tangent = n.cross(Vec3r.unit_x).normalize(); + + for ([_]Real{ 1, 1e3, 5e3, 5e4 }) |range| { + // A plane whose boundary passes `range` metres from the origin, and a ray aimed + // at it from `range` further out along `n`, obliquely — an axis-aligned ray sees + // exactly none of this, the cancellation there being zero. + const plane = HS{ .normal = n, .distance = range }; + const origin = n.scale(2 * range).add(tangent.scale(range)); + const hit = narrowphase.plane.rayShape(Real, plane, origin, n.neg()).?; + + // The normal is EXACT — bit-identical to the stored one, at every range. + try testing.expect(hit.normal.eql(n)); + try testing.expectEqual(n.lengthSq(), hit.normal.lengthSq()); + + // The distance is `range` in closed form (the tangential offset costs nothing + // along `n`), and its error is bounded by the scalar's own resolution at the + // magnitude the subtraction saw — NOT by a fixed tolerance, which would either + // pass vacuously far out or fail near in. + const scale_bound = 32 * std.math.floatEps(Real) * (2 * range + range); + try testing.expectApproxEqAbs(range, hit.distance, @max(scale_bound, tol)); + + // And the cast normal is exact at range too, for the same reason. + const cast = narrowphase.plane.castShape(Real, plane, sphere(1), n.neg(), 4 * range).?; + try testing.expect(cast.normal.eql(n)); + } +} + +// --------------------------------------------------------------------------- +// E4 — the five `BodyManager` adapters, at the BODY grain +// --------------------------------------------------------------------------- +// +// Driven through `bm.addBody` and the adapters DIRECTLY, with no test harness. That +// is not a shortcut: `harness.World.addBody` calls `bm.bodyAabb(...).?` to insert a +// broadphase proxy, and a half-space has no world AABB — the class assert fires. The +// harness learns the half-space at E5, together with the unbounded lists it belongs +// in; until then the body-grain adapters are what can be exercised, and they are what +// E4 owns. +// +// **The world-frame oracle is derived once, here, and hard-coded below.** A plane body +// at `(10, 20, 30)` rotated +90° about +Z, carrying the LOCAL half-space `{ y <= 0 }`: +// +// n_world = Rz(90°)·(0, 1, 0) = (−1, 0, 0) +// d_world = d_local + n_world·pos = 0 + (−1, 0, 0)·(10, 20, 30) = −10 +// +// so the solid is `{ x : −x <= −10 }`, i.e. the half-space `x >= 10`. Every expectation +// below is written on THAT, not on a value recomputed with the same rotation call the +// implementation makes — otherwise the test and the code would share their mistake. + +/// The plane body every adapter test below uses, plus a `ShapeStore` holding it. +const PlaneScene = struct { + store: ShapeStore = .{}, + bm: BodyManager = .{}, + body: api.BodyId = 0, + + /// World-space normal of the scene's plane, by the derivation above. + const n_world = Vec3r.fromArray(.{ -1, 0, 0 }); + /// World-space offset of the scene's plane, by the derivation above. + const d_world: Real = -10; + + fn init(gpa: std.mem.Allocator) !PlaneScene { + var scene = PlaneScene{}; + const shape = try scene.store.createShape(gpa, .{ .plane = .{ .normal = av3(0, 1, 0), .distance = 0 } }); + scene.body = try scene.bm.addBody(gpa, &scene.store, .{ + .shape = shape, + .body_type = .static, + .position = av3(10, 20, 30), + .rotation = foundation.math.Quatf.fromAxisAngle(ApiVec3.unit_z, std.math.pi / 2.0), + .entity = .{ .index = 7, .generation = 0 }, + }); + return scene; + } + + fn deinit(self: *PlaneScene, gpa: std.mem.Allocator) void { + self.store.deinit(gpa); + self.bm.deinit(gpa); + } + + /// `n_world·p − d_world`, the oracle's own signed distance. + fn oracleSigned(p: Vec3r) Real { + return n_world.dot(p) - d_world; + } +}; + +test "containsPointBody answers the world half-space, boundary included" { + const gpa = testing.allocator; + var scene = try PlaneScene.init(gpa); + defer scene.deinit(gpa); + + // Solid is `x >= 10`. + try testing.expect(scene.bm.containsPointBody(&scene.store, scene.body, vr(15, 1, 2)).?); + try testing.expect(scene.bm.containsPointBody(&scene.store, scene.body, vr(1000, -50, 7)).?); + try testing.expect(!scene.bm.containsPointBody(&scene.store, scene.body, vr(5, 1, 2)).?); + try testing.expect(!scene.bm.containsPointBody(&scene.store, scene.body, vr(-1000, 0, 0)).?); + // Exactly on the boundary is INSIDE. The transported plane is unit to a few ULPs of + // the rotation, so the boundary decision at exactly `x = 10` is asserted through the + // oracle's own sign rather than as a raw bool: the two agree, which is the claim. + try testing.expectApproxEqAbs(@as(Real, 0), PlaneScene.oracleSigned(vr(10, 1, 2)), tol); + try testing.expect(scene.bm.containsPointBody(&scene.store, scene.body, vr(10 + 4 * tol, 1, 2)).?); + try testing.expect(!scene.bm.containsPointBody(&scene.store, scene.body, vr(10 - 4 * tol, 1, 2)).?); + + // A stale handle is still null, not false — the adapter's pre-existing contract. + var doomed = scene; + _ = &doomed; + scene.bm.removeBody(scene.body); + try testing.expectEqual(@as(?bool, null), scene.bm.containsPointBody(&scene.store, scene.body, vr(15, 1, 2))); +} + +test "raycastBody hits the world half-space and its normal rotates back to n_world" { + const gpa = testing.allocator; + var scene = try PlaneScene.init(gpa); + defer scene.deinit(gpa); + + // A world ray from `(0, 1, 2)` along `+X` reaches the boundary `x = 10` after 10 m. + const ray = broadphase_mod.Ray(Real).init(vr(0, 1, 2), Vec3r.unit_x); + const hit = scene.bm.raycastBody(&scene.store, scene.body, ray).?; + try testing.expectApproxEqAbs(@as(Real, 10), hit.distance, tol); + // The kernel answers in the BODY's local frame; the query layer rotates the normal + // to world. Doing that here shows the local normal IS the transported plane's. + const world_normal = scene.bm.rotation(scene.body).?.rotateVec3(hit.normal); + try testing.expect(world_normal.approxEql(PlaneScene.n_world, tol)); + try testing.expect(world_normal.dot(Vec3r.unit_x) <= 0); + + // Away from the solid: `+X` reversed recedes, so no hit. + const receding = broadphase_mod.Ray(Real).init(vr(0, 1, 2), Vec3r.unit_x.neg()); + try testing.expect(scene.bm.raycastBody(&scene.store, scene.body, receding) == null); + // PARALLEL to the boundary, from outside — and §1.11.15 is explicit about what this + // composition does: **a true-zero guard is exact in the frame it is evaluated in, and + // that does not compose.** A rigid transform does not preserve EXACT orthogonality: + // this ray is parallel in WORLD, but `raycastBody` transports it into the body's + // local frame through a quaternion built in floating point, and the transported dot + // product arrives about one ULP from zero rather than at zero. The guard does not + // fire, and the kernel reports — correctly — a crossing at `sep / |n·dir|`. + // + // The corollary §1.11.15 draws is the one this test obeys: **a test that expects + // `null` from a ray parallel in WORLD is testing a property the model does not + // promise.** The exactly-parallel case, where the guard does fire, is exercised at + // KERNEL grain in local coordinates, in the raycast test above. + // + // MEASURED, in the build, at both precisions — and the two quantities are separated + // because only one of them is exact: + // + // leg transported n·dir ratio to floatEps returned t + // f32 −1.1920929e-7 1.000000000 8.388612e7 + // f64 −2.220446049250313e-16 1.000000000 4.503599627370497e16 + // + // The dot product is EXACTLY `−floatEps(Real)` on both legs, which is why `t` is of + // order `sep / floatEps(Real)`. It is not exactly `10 / floatEps(Real)` + // (8.388608e7 and 4.503599627370496e16): the excess is `sep`'s own rounding, the + // transported signed distance being 10 m only to the precision the same rotation + // carries. Both figures recompute from the table. + // + // What rejects such a ray is the QUERY entry's finite `max_distance`, which §1.11.4 + // requires to be finite — not the kernel, which has no bound of its own and no + // business inventing a geometric epsilon to fabricate one. The EXACTLY-parallel + // case, where the guard does fire, is the kernel-level test above. + const parallel = broadphase_mod.Ray(Real).init(vr(0, 1, 2), Vec3r.unit_y); + const grazing = scene.bm.raycastBody(&scene.store, scene.body, parallel).?; + try testing.expect(grazing.distance >= 10 / (8 * std.math.floatEps(Real))); + // From INSIDE the solid: distance 0, normal `−direction` once back in world. + const inside = broadphase_mod.Ray(Real).init(vr(50, 1, 2), Vec3r.unit_y); + const in_hit = scene.bm.raycastBody(&scene.store, scene.body, inside).?; + try testing.expectEqual(@as(Real, 0), in_hit.distance); + try testing.expect(scene.bm.rotation(scene.body).?.rotateVec3(in_hit.normal).approxEql(Vec3r.unit_y.neg(), tol)); +} + +test "closestPointBody projects onto the world boundary and answers the point inside" { + const gpa = testing.allocator; + var scene = try PlaneScene.init(gpa); + defer scene.deinit(gpa); + + // Outside at `(5, 1, 2)`: signed distance `(−1)·5 − (−10) = 5`, and the projection + // removes `5·n_world = (−5, 0, 0)`, landing on `(10, 1, 2)`. + const out = scene.bm.closestPointBody(&scene.store, scene.body, vr(5, 1, 2)).?; + try testing.expectApproxEqAbs(@as(Real, 5), out.distance, tol); + try testing.expect(out.position.approxEql(vr(10, 1, 2), tol)); + + // Inside at `(50, 1, 2)`: distance 0 and the point ITSELF, not its projection onto + // the boundary — the two differ by 40 m here, so the assertion discriminates. + const in = scene.bm.closestPointBody(&scene.store, scene.body, vr(50, 1, 2)).?; + try testing.expectEqual(@as(Real, 0), in.distance); + try testing.expect(in.position.approxEql(vr(50, 1, 2), tol)); + try testing.expect(!in.position.approxEql(vr(10, 1, 2), tol)); +} + +test "castShapeBody sweeps onto the world boundary" { + const gpa = testing.allocator; + var scene = try PlaneScene.init(gpa); + defer scene.deinit(gpa); + + // A sphere probe of radius 0.5 centred at `(0, 1, 2)` swept along `+X`: its surface + // reaches `x = 0.5` already, so it travels `10 − 0.5 = 9.5` to touch `x = 10`, and + // the witness is on the boundary at `(10, 1, 2)`. + const probe = narrowphase.SupportShape(Real){ .core = .point, .radius = 0.5 }; + const hit = scene.bm.castShapeBody( + &scene.store, + scene.body, + probe, + vr(0, 1, 2), + Quatr.identity, + Vec3r.unit_x, + 100, + ).?; + try testing.expectApproxEqAbs(@as(Real, 9.5), hit.distance, tol); + try testing.expect(hit.position.approxEql(vr(10, 1, 2), tol)); + try testing.expect(hit.normal.approxEql(PlaneScene.n_world, tol)); + try testing.expect(hit.normal.dot(Vec3r.unit_x) <= 0); + + // Receding and grazing both miss, at the body grain as at the kernel grain. + try testing.expect(scene.bm.castShapeBody(&scene.store, scene.body, probe, vr(0, 1, 2), Quatr.identity, Vec3r.unit_x.neg(), 1e6) == null); + try testing.expect(scene.bm.castShapeBody(&scene.store, scene.body, probe, vr(0, 1, 2), Quatr.identity, Vec3r.unit_z, 1e6) == null); + // A bound shorter than the answer misses; one exactly at it hits (closed interval). + try testing.expect(scene.bm.castShapeBody(&scene.store, scene.body, probe, vr(0, 1, 2), Quatr.identity, Vec3r.unit_x, 9.5 - 8 * tol) == null); + try testing.expect(scene.bm.castShapeBody(&scene.store, scene.body, probe, vr(0, 1, 2), Quatr.identity, Vec3r.unit_x, 9.5 + 8 * tol) != null); +} + +test "overlapShapeBody answers by the sign of the separation" { + const gpa = testing.allocator; + var scene = try PlaneScene.init(gpa); + defer scene.deinit(gpa); + + // A unit sphere probe: it overlaps the solid `x >= 10` exactly when its surface + // reaches it, i.e. when `centre_x + 1 >= 10`, i.e. `centre_x >= 9`. + // sep = n_world·centre − r − d_world = −centre_x − 1 + 10 = 9 − centre_x + const probe = narrowphase.SupportShape(Real){ .core = .point, .radius = 1 }; + const cases = [_]struct { x: Real, overlap: bool }{ + .{ .x = 8.0, .overlap = false }, // sep = +1 + .{ .x = 8.9, .overlap = false }, // sep = +0.1 + .{ .x = 9.2, .overlap = true }, // sep = −0.2 + .{ .x = 50.0, .overlap = true }, // deep inside + .{ .x = -100.0, .overlap = false }, // far outside + }; + for (cases) |c| { + try testing.expectEqual(c.overlap, scene.bm.overlapShapeBody( + &scene.store, + scene.body, + probe, + vr(c.x, 1, 2), + Quatr.identity, + ).?); + } + // A BOX probe, so the answer is not a property of the point core alone: half-extents + // (2, 1, 1) at `x = 7` reach `x = 9`, one metre short; at `x = 8.5` they reach 10.5. + const box_probe = narrowphase.SupportShape(Real){ .core = .{ .box = vr(2, 1, 1) }, .radius = 0 }; + try testing.expect(!scene.bm.overlapShapeBody(&scene.store, scene.body, box_probe, vr(7, 1, 2), Quatr.identity).?); + try testing.expect(scene.bm.overlapShapeBody(&scene.store, scene.body, box_probe, vr(8.5, 1, 2), Quatr.identity).?); +} + +// --------------------------------------------------------------------------- +// E5 — the eight query entries, at ENTRY grain, with a plane in the scene +// --------------------------------------------------------------------------- +// +// Unblocked by the harness learning the half-space: until E5 a plane body could not be +// added to a `harness.World` at all, `addBody` calling `bodyAabb` to build a broadphase +// proxy. Now it goes into the layer's unbounded list instead, and the entries can be +// exercised where a caller actually meets them. +// +// The scene is a GROUND plane — local `{ y <= 0 }` at identity pose, so the world +// half-space is the same `{ y <= 0 }` — plus, where a second body is needed, a unit +// sphere centred at `(0, 3, 0)` whose lowest point is `y = 2`. Every closed form below +// reads off those two. + +/// Add the ground plane `{ y <= 0 }` to `world` as a static body. +fn addGround(gpa: std.mem.Allocator, world: *harness.World, entity_index: u32) !api.BodyId { + const shape = try world.store.createShape(gpa, .{ .plane = .{ .normal = av3(0, 1, 0), .distance = 0 } }); + return world.addBody(gpa, .{ + .shape = shape, + .body_type = .static, + .entity = .{ .index = entity_index, .generation = 0 }, + }); +} + +/// Add a unit sphere at `centre`, static so nothing has to be stepped. +fn addUnitSphere(gpa: std.mem.Allocator, world: *harness.World, centre: [3]f32, entity_index: u32) !api.BodyId { + const shape = try world.store.createShape(gpa, .{ .sphere = .{ .radius = 1 } }); + return world.addBody(gpa, .{ + .shape = shape, + .body_type = .static, + .position = av3(centre[0], centre[1], centre[2]), + .entity = .{ .index = entity_index, .generation = 0 }, + }); +} + +test "all eight query entries answer a half-space body" { + const gpa = testing.allocator; + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + const ground = try addGround(gpa, &world, 0); + + // (1) raycast — from (0, 5, 0) straight down: the boundary is at y = 0, so t = 5, + // the hit point is the origin and the normal is +Y. + const hit = query.raycast(&world.bp, &world.bm, &world.store, .{ + .origin = vr(0, 5, 0), + .direction = vr(0, -1, 0), + .max_distance = 100, + }).?; + try testing.expectEqual(ground, hit.body); + try testing.expectApproxEqAbs(@as(Real, 5), hit.distance, tol); + try testing.expect(hit.position.approxEql(Vec3r.zero, tol)); + try testing.expect(hit.normal.approxEql(Vec3r.unit_y, tol)); + + // (2) raycastAny — the same ray, as a boolean. + try testing.expect(query.raycastAny(&world.bp, &world.bm, &world.store, .{ + .origin = vr(0, 5, 0), + .direction = vr(0, -1, 0), + .max_distance = 100, + })); + // …and a ray pointing away answers false, so the entry is not answering true + // unconditionally now that an unbounded shape is offered to every collector. + try testing.expect(!query.raycastAny(&world.bp, &world.bm, &world.store, .{ + .origin = vr(0, 5, 0), + .direction = vr(0, 1, 0), + .max_distance = 100, + })); + + // (3) raycastAll — with a sphere at (0, 3, 0) the same downward ray meets its top at + // y = 4 (t = 1) and then the plane at y = 0 (t = 5), in that order. + const sphere_body = try addUnitSphere(gpa, &world, .{ 0, 3, 0 }, 1); + var hits: [4]query.RayHit = undefined; + const n = query.raycastAll(&world.bp, &world.bm, &world.store, .{ + .origin = vr(0, 5, 0), + .direction = vr(0, -1, 0), + .max_distance = 100, + }, &hits); + try testing.expectEqual(@as(u32, 2), n); + try testing.expectEqual(sphere_body, hits[0].body); + try testing.expectApproxEqAbs(@as(Real, 1), hits[0].distance, tol); + try testing.expectEqual(ground, hits[1].body); + try testing.expectApproxEqAbs(@as(Real, 5), hits[1].distance, tol); + + // (4) shapeCast — a sphere probe of radius 0.5 from (0, 5, 0) downward: its surface + // is at y = 4.5, so it travels 4.5 to touch the boundary, witness at the origin. The + // sphere body at (0, 3, 0) is nearer, so aim past it in X to isolate the plane. + const probe = try world.store.createShape(gpa, .{ .sphere = .{ .radius = 0.5 } }); + const cast = (try query.shapeCast(&world.bp, &world.bm, &world.store, .{ + .shape = probe, + .origin = vr(20, 5, 0), + .direction = vr(0, -1, 0), + .max_distance = 100, + })).?; + try testing.expectEqual(ground, cast.body); + try testing.expectApproxEqAbs(@as(Real, 4.5), cast.distance, tol); + try testing.expect(cast.position.approxEql(vr(20, 0, 0), tol)); + try testing.expect(cast.normal.approxEql(Vec3r.unit_y, tol)); + + // (5) overlapShape — a unit-sphere probe centred at (20, 0.5, 0) reaches y = −0.5, + // inside the solid; at (20, 1.5, 0) it reaches y = 0.5 and does not. + var bodies: [4]api.BodyId = undefined; + const probe_unit = try world.store.createShape(gpa, .{ .sphere = .{ .radius = 1 } }); + try testing.expectEqual(@as(u32, 1), try query.overlapShape(&world.bp, &world.bm, &world.store, .{ + .shape = probe_unit, + .position = vr(20, 0.5, 0), + }, &bodies)); + try testing.expectEqual(ground, bodies[0]); + try testing.expectEqual(@as(u32, 0), try query.overlapShape(&world.bp, &world.bm, &world.store, .{ + .shape = probe_unit, + .position = vr(20, 1.5, 0), + }, &bodies)); + + // (6) overlapAabb — THE OBLIGATION DEFERRED FROM E2. Its collector used to call + // `bodyAabb` on every candidate, which asserts on a half-space; it goes through + // `aabbOverlapsBody` now, whose half-space arm is the corner predicate. A box + // straddling y = 0 meets the solid; one entirely above does not. + try testing.expectEqual(@as(u32, 1), query.overlapAabb(&world.bp, &world.bm, &world.store, vr(19, -1, -1), vr(21, 1, 1), .{}, &bodies)); + try testing.expectEqual(ground, bodies[0]); + try testing.expectEqual(@as(u32, 0), query.overlapAabb(&world.bp, &world.bm, &world.store, vr(19, 1, -1), vr(21, 2, 1), .{}, &bodies)); + // A box exactly touching the boundary from above counts — the half-space is CLOSED. + try testing.expectEqual(@as(u32, 1), query.overlapAabb(&world.bp, &world.bm, &world.store, vr(19, 0, -1), vr(21, 2, 1), .{}, &bodies)); + + // (7) pointQuery — solid, boundary included. + try testing.expectEqual(@as(u32, 1), query.pointQuery(&world.bp, &world.bm, &world.store, vr(20, -1, 0), .{}, &bodies)); + try testing.expectEqual(ground, bodies[0]); + try testing.expectEqual(@as(u32, 1), query.pointQuery(&world.bp, &world.bm, &world.store, vr(20, 0, 0), .{}, &bodies)); + try testing.expectEqual(@as(u32, 0), query.pointQuery(&world.bp, &world.bm, &world.store, vr(20, 1, 0), .{}, &bodies)); + + // (8) closestPoint — from (20, 5, 0) the nearest solid point is (20, 0, 0), 5 m away. + const closest = query.closestPoint(&world.bp, &world.bm, &world.store, vr(20, 5, 0), 10, .{}).?; + try testing.expectEqual(ground, closest.body); + try testing.expectApproxEqAbs(@as(Real, 5), closest.distance, tol); + try testing.expect(closest.position.approxEql(vr(20, 0, 0), tol)); + // Outside the radius: nothing. + try testing.expect(query.closestPoint(&world.bp, &world.bm, &world.store, vr(20, 5, 0), 4, .{}) == null); +} + +test "the object mask and the exclusions filter a half-space body" { + const gpa = testing.allocator; + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + const shape = try world.store.createShape(gpa, .{ .plane = .{ .normal = av3(0, 1, 0), .distance = 0 } }); + const ground = try world.bm.addBody(gpa, &world.store, .{ + .shape = shape, + .body_type = .static, + .collision_layer = 3, + .entity = .{ .index = 0, .generation = 0 }, + }); + const world_plane = shape_mod.halfSpace(world.store.get(shape).?) + .transformed(world.bm.rotation(ground).?, world.bm.position(ground).?); + _ = try world.bp.insertUnbounded(gpa, .static, .{ .normal = world_plane.normal, .distance = world_plane.distance }, ground); + + const down = query.RayQuery{ .origin = vr(0, 5, 0), .direction = vr(0, -1, 0), .max_distance = 100 }; + // The full mask sees it; a mask naming only layer 3 sees it; one naming only layer 4 + // does not — so the mask is read from the body and not ignored for an unbounded one. + try testing.expect(query.raycast(&world.bp, &world.bm, &world.store, down) != null); + var only_3 = down; + only_3.filter.layer_mask = @as(u32, 1) << 3; + try testing.expect(query.raycast(&world.bp, &world.bm, &world.store, only_3) != null); + var only_4 = down; + only_4.filter.layer_mask = @as(u32, 1) << 4; + try testing.expect(query.raycast(&world.bp, &world.bm, &world.store, only_4) == null); + + // Excluding the body itself removes it, on every entry that takes a filter. + var excluded = down; + const exclude_list = [_]api.BodyId{ground}; + excluded.filter.exclude = &exclude_list; + try testing.expect(query.raycast(&world.bp, &world.bm, &world.store, excluded) == null); + try testing.expect(!query.raycastAny(&world.bp, &world.bm, &world.store, excluded)); + var bodies: [4]api.BodyId = undefined; + try testing.expectEqual(@as(u32, 0), query.pointQuery(&world.bp, &world.bm, &world.store, vr(0, -1, 0), .{ .exclude = &exclude_list }, &bodies)); + try testing.expectEqual(@as(u32, 0), query.overlapAabb(&world.bp, &world.bm, &world.store, vr(-1, -1, -1), vr(1, 1, 1), .{ .exclude = &exclude_list }, &bodies)); + try testing.expect(query.closestPoint(&world.bp, &world.bm, &world.store, vr(0, 5, 0), 10, .{ .exclude = &exclude_list }) == null); +} + +test "a sleeping body answers and stays asleep with a plane in the scene" { + const gpa = testing.allocator; + var world = harness.World.init(vr(0, -9.81, 0), 1.0 / 60.0); // sleeping ENABLED + defer world.deinit(gpa); + + // The plane is added LAST, on purpose and twice over: it is the pairing direction a + // naive suite omits, and adding a body must not wake anyone — insertion touches no + // other body, and the wake fixpoint lives in `build`, which is not run here. + const box = try harness.groundAndBox(gpa, &world, 1.0, 0); + var ticks: u32 = 0; + while (ticks < 400 and !(world.bm.isSleeping(box) orelse false)) : (ticks += 1) { + try world.step(gpa); + } + try testing.expect(world.bm.isSleeping(box).?); + + const ground = try addGround(gpa, &world, 9); + try testing.expect(world.bm.isSleeping(box).?); // adding the plane woke nobody + + // Both answer, and the sleeper is still asleep afterwards: a query takes + // `*const BodyManager`, so it cannot wake anything — structurally, not by + // convention. + var bodies: [8]api.BodyId = undefined; + const found = query.overlapAabb(&world.bp, &world.bm, &world.store, vr(-10, -10, -10), vr(10, 10, 10), .{}, &bodies); + try testing.expect(found >= 2); + var saw_plane = false; + var saw_sleeper = false; + for (bodies[0..found]) |b| { + if (b == ground) saw_plane = true; + if (b == box) saw_sleeper = true; + } + try testing.expect(saw_plane and saw_sleeper); + try testing.expect(world.bm.isSleeping(box).?); +} + +test "the answer is invariant under creation-order permutation, and two runs are bit-identical" { + const gpa = testing.allocator; + + // The SAME scene built in two orders — plane first, then plane last — must give the + // same answer, identities included. That is §1.11.6's invariance, and here it also + // exercises BOTH pairing directions at entry grain: with the plane first the bodies' + // own insertions cross it, with the plane last only the unbounded-insertion path can. + const Answer = struct { body: api.BodyId, entity: u32, distance: Real, position: [3]Real }; + var answers: [2]Answer = undefined; + for (0..2) |order| { + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + if (order == 0) { + _ = try addGround(gpa, &world, 0); + _ = try addUnitSphere(gpa, &world, .{ 0, 3, 0 }, 1); + } else { + _ = try addUnitSphere(gpa, &world, .{ 0, 3, 0 }, 1); + _ = try addGround(gpa, &world, 0); + } + // Aim past the sphere so the plane is the answer, and the answer's ENTITY is what + // is compared — `BodyId` is a slot index and encodes creation order, which is + // exactly what must not leak into the result (§1.11.14). + const hit = query.raycast(&world.bp, &world.bm, &world.store, .{ + .origin = vr(20, 5, 0), + .direction = vr(0, -1, 0), + .max_distance = 100, + }).?; + answers[order] = .{ + .body = hit.body, + .entity = world.bm.entity(hit.body).?.index, + .distance = hit.distance, + .position = hit.position.toArray(), + }; + } + // The ENTITY is identical across the two orders, and so are the geometric answers — + // bit-identically, no tolerance: the same arithmetic ran on the same inputs. + try testing.expectEqual(@as(u32, 0), answers[0].entity); + try testing.expectEqual(answers[0].entity, answers[1].entity); + try testing.expectEqual(answers[0].distance, answers[1].distance); + inline for (0..3) |i| try testing.expectEqual(answers[0].position[i], answers[1].position[i]); + + // And two identical runs in one world are bit-identical. + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + _ = try addGround(gpa, &world, 0); + const q = query.RayQuery{ .origin = vr(20, 5, 0), .direction = vr(0, -1, 0), .max_distance = 100 }; + const first = query.raycast(&world.bp, &world.bm, &world.store, q).?; + const again = query.raycast(&world.bp, &world.bm, &world.store, q).?; + try testing.expectEqual(first.body, again.body); + try testing.expectEqual(first.distance, again.distance); + inline for (0..3) |i| try testing.expectEqual(first.position.toArray()[i], again.position.toArray()[i]); + inline for (0..3) |i| try testing.expectEqual(first.normal.toArray()[i], again.normal.toArray()[i]); +} + +// --------------------------------------------------------------------------- +// E6 — the contact path +// --------------------------------------------------------------------------- + +/// A static plane body carrying the LOCAL half-space `(normal, distance)` at the +/// origin, added through the harness (so it lands in the layer's unbounded list). +fn addPlaneBody(gpa: std.mem.Allocator, world: *harness.World, normal: ApiVec3, distance: f32, entity_index: u32) !api.BodyId { + const shape = try world.store.createShape(gpa, .{ .plane = .{ .normal = normal, .distance = distance } }); + return world.addBody(gpa, .{ + .shape = shape, + .body_type = .static, + .entity = .{ .index = entity_index, .generation = 0 }, + }); +} + +/// A dynamic box of half-extents `he` at `centre`. +fn addBox(gpa: std.mem.Allocator, world: *harness.World, he: [3]f32, centre: [3]f32, entity_index: u32) !api.BodyId { + const shape = try world.store.createShape(gpa, .{ .box = .{ .half_extents = av3(he[0], he[1], he[2]) } }); + return world.addBody(gpa, .{ + .shape = shape, + .body_type = .dynamic, + .position = av3(centre[0], centre[1], centre[2]), + .entity = .{ .index = entity_index, .generation = 0 }, + }); +} + +test "a plane manifold puts a box on four contacts" { + const gpa = testing.allocator; + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + + // Ground `{ y <= 0 }` and a unit box (half-extents 0.5) centred at y = 0.375, so its + // bottom face sits at y = −0.125. CLOSED FORMS, all four identical by symmetry: + // + // surface point of the box y = −0.125 ⇒ sep = −0.125 + // penetration −sep = 0.125 + // contact position midpoint of y = −0.125 and its projection y = 0 + // ⇒ y = −0.0625, x = ±0.5, z = ±0.5 + // normal (A→B, plane→box) +Y + // + // **Every literal here is a dyadic rational — 3/8, 1/2, 1/8, 1/16 — exactly + // representable at `f32` AND at `f64`.** That is deliberate and it is not decoration: + // a body's position comes from an `f32` DESCRIPTOR (§1.11.8 keeps the public surface + // f32 whatever the solver scalar), so a centre of 0.4 is stored as 0.4000000059604645 + // and the penetration is not 0.1 but 0.09999999403953552 — which passes at f32, where + // the tolerance is coarse, and fails at f64, where it is not. Choosing exact inputs + // removes the quantisation instead of widening a bound to tolerate it. + const ground = try addPlaneBody(gpa, &world, av3(0, 1, 0), 0, 0); + const box = try addBox(gpa, &world, .{ 0.5, 0.5, 0.5 }, .{ 0, 0.375, 0 }, 1); + + const m = world.bm.collidePair(&world.store, ground, box).?; + try testing.expectEqual(@as(u8, 4), m.count); + try testing.expect(m.normal.approxEql(Vec3r.unit_y, tol)); + + var seen_ids: [4]u32 = undefined; + for (0..4) |i| { + const p = m.points[i]; + try testing.expectApproxEqAbs(@as(Real, 0.125), p.penetration, tol); + try testing.expectApproxEqAbs(@as(Real, -0.0625), p.position.toArray()[1], tol); + // The four are the bottom face's corners: |x| = |z| = 0.5. + try testing.expectApproxEqAbs(@as(Real, 0.5), @abs(p.position.toArray()[0]), tol); + try testing.expectApproxEqAbs(@as(Real, 0.5), @abs(p.position.toArray()[2]), tol); + seen_ids[i] = p.feature_id; + } + // The four ids are DISTINCT — the warm-start cache keys on them, so two contacts of + // one manifold sharing an id would collapse into a single cached impulse. + for (0..4) |i| { + for (i + 1..4) |j| try testing.expect(seen_ids[i] != seen_ids[j]); + } + + // …and the two anchors §3 defines reconstruct exactly, which is what makes the NGS + // position pass need no case for this shape: `position + ½·pen·n` is the PLANE's + // surface point (y = 0) and `position − ½·pen·n` is the BOX's (y = −0.1). + for (0..4) |i| { + const p = m.points[i]; + const surface_a = p.position.add(m.normal.scale(p.penetration * 0.5)); + const surface_b = p.position.sub(m.normal.scale(p.penetration * 0.5)); + try testing.expectApproxEqAbs(@as(Real, 0), surface_a.toArray()[1], tol); + try testing.expectApproxEqAbs(@as(Real, -0.125), surface_b.toArray()[1], tol); + } + + // Order-independence: the swapped call negates the normal and leaves the positions + // and penetrations alone (§3's contract, inherited here rather than re-derived). + const swapped = world.bm.collidePair(&world.store, box, ground).?; + try testing.expectEqual(m.count, swapped.count); + try testing.expect(swapped.normal.approxEql(Vec3r.unit_y.neg(), tol)); + for (0..4) |i| { + try testing.expectApproxEqAbs(m.points[i].penetration, swapped.points[i].penetration, tol); + try testing.expect(m.points[i].position.approxEql(swapped.points[i].position, tol)); + } +} + +test "the half-space feature-id class is disjoint from every other producer, by mask" { + const gpa = testing.allocator; + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + const ground = try addPlaneBody(gpa, &world, av3(0, 1, 0), 0, 0); + const box = try addBox(gpa, &world, .{ 0.5, 0.5, 0.5 }, .{ 0, 0.375, 0 }, 1); + + // BY CONSTRUCTION, on the MASK — not by enumerating which of the existing pairs + // happen to be taken. `class_plane` is a FOURTH value of the 2-bit class field, and + // `manifold.zig`'s comptime block asserts the four tags are pairwise distinct and + // each exactly a class value. So a producer that ORs one of the other three into its + // reference half cannot emit this one, whatever pair of halves it chooses — and the + // pair `(class_a, class_c)` was already the single-witness producer's, which is why a + // free pair would not have been free. + const m = world.bm.collidePair(&world.store, ground, box).?; + for (0..m.count) |i| { + const reference_half: u16 = @intCast(m.points[i].feature_id >> 16); + try testing.expectEqual(narrowphase.feature_class_plane, reference_half & narrowphase.feature_class_mask); + } + + // A convex pair through the SAME entry never carries that class in its reference + // half — the other side of the disjointness, measured rather than assumed. + const other = try addBox(gpa, &world, .{ 0.5, 0.5, 0.5 }, .{ 0, 1.25, 0 }, 2); + const convex = world.bm.collidePair(&world.store, box, other).?; + for (0..convex.count) |i| { + const reference_half: u16 = @intCast(convex.points[i].feature_id >> 16); + try testing.expect(reference_half & narrowphase.feature_class_mask != narrowphase.feature_class_plane); + } +} + +test "a sphere and a capsule on a plane get the contact count their supporting face returns" { + const gpa = testing.allocator; + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + const ground = try addPlaneBody(gpa, &world, av3(0, 1, 0), 0, 0); + + // A SPHERE's core is a single POINT, so its supporting face has ONE vertex whatever + // the direction — one contact, not the several a curved surface might suggest. + // Radius 1 centred at y = 0.875 (7/8, exact at both precisions — see the four-contact + // test on why every literal here is dyadic): surface point y = −0.125, penetration + // 0.125, contact at y = −0.0625 on the axis. + const sphere_shape = try world.store.createShape(gpa, .{ .sphere = .{ .radius = 1 } }); + const ball = try world.addBody(gpa, .{ + .shape = sphere_shape, + .body_type = .dynamic, + .position = av3(0, 0.875, 0), + .entity = .{ .index = 1, .generation = 0 }, + }); + const ms = world.bm.collidePair(&world.store, ground, ball).?; + try testing.expectEqual(@as(u8, 1), ms.count); + try testing.expectApproxEqAbs(@as(Real, 0.125), ms.points[0].penetration, tol); + try testing.expect(ms.points[0].position.approxEql(vr(0, -0.0625, 0), tol)); + + // A STANDING capsule is END-ON to `−n`, so `supportingFace` returns its single + // extremal endpoint: ONE contact. Radius 0.25, half-height 0.875, centred at y = 1 ⇒ + // the lower cap's surface is at 1 − 0.875 − 0.25 = −0.125. + const cap_shape = try world.store.createShape(gpa, .{ .capsule = .{ .radius = 0.25, .half_height = 0.875 } }); + const standing = try world.addBody(gpa, .{ + .shape = cap_shape, + .body_type = .dynamic, + .position = av3(4, 1, 0), + .entity = .{ .index = 2, .generation = 0 }, + }); + const mc = world.bm.collidePair(&world.store, ground, standing).?; + try testing.expectEqual(@as(u8, 1), mc.count); + try testing.expectApproxEqAbs(@as(Real, 0.125), mc.points[0].penetration, tol); + + // A LYING capsule is PERPENDICULAR to `−n`, so the same feature is the whole + // segment: TWO contacts, one per endpoint, 2·half_height apart. Rotated +90° about + // +Z maps its local +Y axis onto world −X. Centred at y = 0.125 ⇒ the wall's surface + // is at 0.125 − 0.25 = −0.125. + const lying = try world.addBody(gpa, .{ + .shape = cap_shape, + .body_type = .dynamic, + .position = av3(8, 0.125, 0), + .rotation = foundation.math.Quatf.fromAxisAngle(ApiVec3.unit_z, std.math.pi / 2.0), + .entity = .{ .index = 3, .generation = 0 }, + }); + const ml = world.bm.collidePair(&world.store, ground, lying).?; + try testing.expectEqual(@as(u8, 2), ml.count); + for (0..2) |i| { + try testing.expectApproxEqAbs(@as(Real, 0.125), ml.points[i].penetration, tol); + try testing.expectApproxEqAbs(@as(Real, -0.0625), ml.points[i].position.toArray()[1], tol); + } + // …and they are 2·half_height = 1.75 m apart along the capsule's axis, world X here. + const span = @abs(ml.points[0].position.toArray()[0] - ml.points[1].position.toArray()[0]); + try testing.expectApproxEqAbs(@as(Real, 1.75), span, tol); + // Distinct ids: the two endpoints carry `vert_id` 0 and 1. + try testing.expect(ml.points[0].feature_id != ml.points[1].feature_id); +} + +test "an oblique plane against a rotated box keeps the stored normal as the contact normal" { + const gpa = testing.allocator; + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + + // A plane whose normal is NOT axis-aligned — `(2, −3, 6)/7`, the exact Pythagorean + // quadruple — and a box rotated off every axis. The contact normal must be the + // STORED normal mapped to world, tight: it is returned with no arithmetic beyond one + // rotation, so unlike a reconstructed normal it carries no residue of the geometry. + const n = av3(2.0 / 7.0, -3.0 / 7.0, 6.0 / 7.0); + const ground = try addPlaneBody(gpa, &world, n, 0, 0); + + const box_shape = try world.store.createShape(gpa, .{ .box = .{ .half_extents = av3(0.5, 0.5, 0.5) } }); + const spun = try world.addBody(gpa, .{ + .shape = box_shape, + .body_type = .dynamic, + // Placed just inside the solid along the normal, so contact is guaranteed + // whatever face the rotation turns toward the plane. + .position = av3(-0.2 * 2.0 / 7.0, 0.2 * 3.0 / 7.0, -0.2 * 6.0 / 7.0), + .rotation = foundation.math.Quatf.fromAxisAngle(av3(1, 2, 3).normalize(), 0.7), + .entity = .{ .index = 1, .generation = 0 }, + }); + + const m = world.bm.collidePair(&world.store, ground, spun).?; + try testing.expect(m.count >= 1); + // TWO bounds again, and the SAME rule as the creation test above — see "the stored + // plane normal is unit, and independent of distance" for the measurement. + // + // The NORM is tight at the SOLVER scalar: the plane body is at identity rotation, so + // the returned normal is the stored unit value carried through one rotation and + // nothing else. + try testing.expectApproxEqAbs(@as(Real, 1), m.normal.length(), 8 * std.math.floatEps(Real)); + // The ORIENTATION is pinned at one ulp of `f32`, because that is the resolution its + // INPUT had: `(2, −3, 6)/7` reached the store through an `f32` descriptor, and + // comparing it against the exact `f64` rational at `floatEps(Real)` would be + // asserting that an f64 solver recovers information the descriptor never carried. + // Measured at 0.077 ulp of f32 on the f64 leg. + try testing.expect(m.normal.approxEql(vr(2.0 / 7.0, -3.0 / 7.0, 6.0 / 7.0), std.math.floatEps(f32))); + // Every contact is genuinely penetrating and its anchors straddle the boundary. + const plane_world = narrowphase.plane.HalfSpace(Real){ + .normal = vr(2.0 / 7.0, -3.0 / 7.0, 6.0 / 7.0), + .distance = 0, + }; + for (0..m.count) |i| { + const p = m.points[i]; + try testing.expect(p.penetration > 0); + const surface_a = p.position.add(m.normal.scale(p.penetration * 0.5)); + // The oracle plane is built from the EXACT rational while the body's is the f32 + // descriptor's, so the residual is the descriptor's resolution at this scale — + // the same budget as the orientation above, not `tol`. + const budget: Real = 8 * std.math.floatEps(f32) * (1 + surface_a.length()); + try testing.expectApproxEqAbs(@as(Real, 0), plane_world.signedDistance(surface_a), @max(budget, tol)); + } +} + +test "a full tick cycle runs with a plane body, and a falling box comes to rest on it" { + const gpa = testing.allocator; + var world = harness.World.initNoSleep(vr(0, -9.81, 0), 1.0 / 60.0); + defer world.deinit(gpa); + + // THE RESIDUAL E5 NAMED, CLOSED. Until this gate `world.step()` panicked in + // `collidePair` the moment the broadphase emitted a pair containing a plane body: + // the plane reached `supportShape`, whose precondition is the convex class. A FULL + // cycle now, all eleven steps, not a kernel call. + const plane_body = try addPlaneBody(gpa, &world, av3(0, 1, 0), 0, 0); + const box = try addBox(gpa, &world, .{ 0.5, 0.5, 0.5 }, .{ 0, 3, 0 }, 1); + + var ticks: u32 = 0; + while (ticks < 400) : (ticks += 1) try world.step(gpa); + + // It fell, it stopped, and it stopped ON the plane. The resting centre is the box's + // half-extent above the boundary, less the residual penetration the NGS pass leaves + // at its fixed point — the slop, `SolverConfig.penetration_slop = 0.005`. + // + // MEASURED at 400 ticks, both legs: + // + // leg resting centre_y penetration vy + // f32 0.495073940 0.004926056 −3.7252903e-9 + // f64 0.495074006 0.004925994 6.9388939e-18 + // + // and the settled manifold carries FOUR contacts, 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). The difference is not a discrepancy — + // the correction factor is 0.2, so the last step lands within one step of the fixed + // point on 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 approach from one side. + // + // The bound is therefore stated on the slop and one correction step, not on an exact + // zero and not as a loose window that would pass on a box halfway through its fall. + const slop: Real = world.cfg.penetration_slop; + const centre_y = world.bm.position(box).?.toArray()[1]; + const penetration = 0.5 - centre_y; + try testing.expect(penetration > 0); // it IS resting on the plane, not hovering + try testing.expect(penetration <= 2 * slop); // and it did NOT sink through + const velocity_y = world.bm.linearVelocity(box).?.toArray()[1]; + try testing.expectApproxEqAbs(@as(Real, 0), velocity_y, 1e-6); + // The settled contact is the FOUR-point face manifold, not a degenerate single point. + try testing.expectEqual(@as(u8, 4), world.bm.collidePair(&world.store, plane_body, box).?.count); + // Nothing became NaN on the way — the poison of E2 never entered an arithmetic path. + for (world.bm.position(box).?.toArray()) |v| try testing.expect(!std.math.isNan(v)); + for (world.bm.linearVelocity(box).?.toArray()) |v| try testing.expect(!std.math.isNan(v)); +} + +// --------------------------------------------------------------------------- +// E7 / I1 — the bit-agreement between the overlap predicate and the generator +// --------------------------------------------------------------------------- + +test "overlapShapeBody and collidePlane agree to the bit on whether a pair touches" { + const gpa = testing.allocator; + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + const ground = try addPlaneBody(gpa, &world, av3(0, 1, 0), 0, 0); + + // E6 CLAIMED this agreement as a consequence of NOT copying the generic generator's + // `keep_eps`: the query predicate is `separation(...) <= 0` and the generator's + // per-vertex criterion is `sep <= 0`, both exact, so they cannot disagree. A claim + // of agreement that is never exercised is a plea, so here it is exercised — on + // configurations that BRACKET the contact, which is the only place a disagreement + // could hide. + // + // For each shape the offsets place its lowest surface point above the boundary, + // EXACTLY on it, and one ULP below. `nextAfter` walks the actual representable + // neighbour rather than subtracting a made-up small number, so "one ulp" is one ulp. + // `rest_y` is `f32`, not `Real`: it is a DESCRIPTOR height, and the whole point is to + // step to f32's own representable neighbours — the precision the body's position is + // actually stored in (§1.11.8), whatever the solver scalar. + const Case = struct { name: []const u8, shape: api.ShapeDescriptor, rest_y: f32 }; + const cases = [_]Case{ + // A box of half-extent 1/2: its bottom face is at `centre − 0.5`, so a centre of + // exactly 0.5 puts that face exactly on the boundary. + .{ .name = "box", .shape = .{ .box = .{ .half_extents = av3(0.5, 0.5, 0.5) } }, .rest_y = 0.5 }, + // A sphere of radius 1: its lowest surface point is `centre − 1`. + .{ .name = "sphere", .shape = .{ .sphere = .{ .radius = 1 } }, .rest_y = 1 }, + // A capsule r = 1/4, h = 7/8, standing: lowest point is `centre − 1.125`. + .{ .name = "capsule", .shape = .{ .capsule = .{ .radius = 0.25, .half_height = 0.875 } }, .rest_y = 1.125 }, + }; + + var agreements: u32 = 0; + var touching: u32 = 0; + var separated: u32 = 0; + for (cases) |c| { + const shape_id = try world.store.createShape(gpa, c.shape); + const probe = shape_mod.supportShape(world.store.get(shape_id).?); + // ABOVE, EXACTLY ON, and ONE ULP BELOW the resting height — plus a clearly + // separated and a clearly penetrating control, so the sample is not all boundary. + const heights = [_]f32{ + c.rest_y + 1, + std.math.nextAfter(f32, c.rest_y, 1e30), // one ulp above ⇒ separated + c.rest_y, // exactly on ⇒ touching, the half-space being closed + std.math.nextAfter(f32, c.rest_y, -1e30), // one ulp below ⇒ touching + c.rest_y - 0.25, + }; + for (heights) |h| { + const body = try world.addBody(gpa, .{ + .shape = shape_id, + .body_type = .dynamic, + .position = av3(0, h, 0), + .entity = .{ .index = 1, .generation = 0 }, + }); + // THE TWO ANSWERS, on the same pose: the query predicate at the body grain, + // and the generator's own verdict (a manifold, or null when separated). + const by_overlap = world.bm.overlapShapeBody( + &world.store, + ground, + probe, + vr(0, h, 0), + Quatr.identity, + ).?; + const by_manifold = world.bm.collidePair(&world.store, ground, body) != null; + try testing.expectEqual(by_overlap, by_manifold); + agreements += 1; + if (by_overlap) touching += 1 else separated += 1; + world.removeBody(body); + } + } + // The sample really straddled the boundary — an all-touching or all-separated sweep + // would satisfy the equality above while proving nothing. + try testing.expectEqual(@as(u32, 15), agreements); + try testing.expect(touching > 0 and separated > 0); + // And each shape contributed both verdicts: 2 separated (above, one ulp above) and + // 3 touching (exactly on, one ulp below, well below) per shape. + try testing.expectEqual(@as(u32, 6), separated); + try testing.expectEqual(@as(u32, 9), touching); +} + +// --------------------------------------------------------------------------- +// E7 / J1 — `normal · direction <= 0` on EVERY hit, the four kernels agreeing +// --------------------------------------------------------------------------- + +test "an outward cast from inside a half-space reports minus-direction, not the plane normal" { + // THE TEST THAT DID NOT EXIST. `plane.castShape` returned `plane.normal` at initial + // overlap whatever the sweep direction, so a cast aimed OUT of the solid — along `+n`, + // to leave it — answered `normal · direction = +1`, breaking the invariant + // `shapecast.zig`'s `terminal` states as "the only one keeping + // `normal · direction <= 0` on every hit". The inward cases below always had + // `normal · direction = −1` by luck of aim, which is why the existing suite passed + // through the defect: changing the returned normal moved no test. + const plane = HS{ .normal = Vec3r.unit_y, .distance = 0 }; // solid `y <= 0` + // A sphere of radius 1/2 centred at y = −2: entirely inside, so `sep₀ < 0` on every + // direction and every case below is an INITIAL OVERLAP. + const probe = sphere(0.5); + const inside_plane = HS{ .normal = Vec3r.unit_y, .distance = 2 }; // A's frame: origin is 2 m in + + for ([_]Vec3r{ + Vec3r.unit_y, // straight OUT — the case that was wrong + Vec3r.unit_y.neg(), // straight in + Vec3r.unit_x, // along the boundary + Vec3r.unit_y.add(Vec3r.unit_x).normalize(), // obliquely out + Vec3r.unit_y.neg().add(Vec3r.unit_z).normalize(), // obliquely in + }) |d| { + const hit = narrowphase.plane.castShape(Real, inside_plane, probe, d, 100).?; + try testing.expectEqual(@as(Real, 0), hit.distance); // initial overlap + // THE invariant, on every one of them. + try testing.expect(hit.normal.dot(d) <= 0); + // …and specifically `−direction`, which is what the other three kernels return at + // a zero parameter. + try testing.expect(hit.normal.eql(d.neg())); + // The witness still lies ON the boundary — the geometry is unchanged, only the + // normal's rule is. + try testing.expectApproxEqAbs(@as(Real, 0), inside_plane.signedDistance(hit.point), tol); + } + + // A cast that is NOT an initial overlap still reports the plane's normal, so the rule + // above is confined to the degenerate case and has not swallowed the ordinary one. + _ = plane; + const above = HS{ .normal = Vec3r.unit_y, .distance = -10 }; // solid `y <= −10` + const real_hit = narrowphase.plane.castShape(Real, above, probe, Vec3r.unit_y.neg(), 100).?; + try testing.expect(real_hit.distance > 0); + try testing.expect(real_hit.normal.eql(Vec3r.unit_y)); + try testing.expect(real_hit.normal.dot(Vec3r.unit_y.neg()) <= 0); +} + +test "the four kernels agree on the normal at a zero parameter" { + // `raycast.zig` for an origin inside a convex, `plane.rayShape` for an origin inside + // the half-space, `plane.castShape` at initial overlap, and `shapecast.zig` when its + // own axis has collapsed — all four answer `−direction`, and the invariant + // `normal · direction <= 0` holds on every hit of every one of them. + const d = vr(2.0 / 7.0, -3.0 / 7.0, 6.0 / 7.0); // oblique unit direction + + // (1) convex ray kernel, origin inside a unit sphere. + const ray_convex = narrowphase.rayShape(Real, sphere(1), Vec3r.zero, d).?; + try testing.expectEqual(@as(Real, 0), ray_convex.distance); + try testing.expect(ray_convex.normal.eql(d.neg())); + try testing.expect(ray_convex.normal.dot(d) <= 0); + + // (2) half-space ray kernel, origin inside the solid. + const inside = HS{ .normal = Vec3r.unit_y, .distance = 5 }; + const ray_plane = narrowphase.plane.rayShape(Real, inside, Vec3r.zero, d).?; + try testing.expectEqual(@as(Real, 0), ray_plane.distance); + try testing.expect(ray_plane.normal.eql(d.neg())); + try testing.expect(ray_plane.normal.dot(d) <= 0); + + // (3) half-space cast kernel, initial overlap. + const cast_plane = narrowphase.plane.castShape(Real, inside, sphere(0.5), d, 100).?; + try testing.expectEqual(@as(Real, 0), cast_plane.distance); + try testing.expect(cast_plane.normal.eql(d.neg())); + try testing.expect(cast_plane.normal.dot(d) <= 0); + + // (4) the convex cast kernel, two coincident spheres — the hard-core collapse its + // `terminal` documents. Whatever axis it settles on, the invariant holds. + const cast_convex = narrowphase.castShape( + Real, + sphere(1), + narrowphase.RelativePose(Real).init(Vec3r.zero, Quatr.identity, Vec3r.zero, Quatr.identity), + sphere(1), + d, + 100, + ).?; + try testing.expectEqual(@as(Real, 0), cast_convex.distance); + try testing.expect(cast_convex.normal.dot(d) <= 0); +} + +test "castShapeBody transports the normal without disturbing the invariant" { + const gpa = testing.allocator; + var scene = try PlaneScene.init(gpa); + defer scene.deinit(gpa); + + // The adapter only ROTATES the kernel's normal into world. A rotation preserves the + // dot product with the equally rotated direction, so the invariant is carried + // exactly — which is why J1's fix is local to the kernel and needed no adapter change. + // Asserted rather than argued: the scene's solid is `x >= 10`, so a probe at x = 50 is + // deep inside it and every direction below is an initial overlap. + const probe = narrowphase.SupportShape(Real){ .core = .point, .radius = 0.5 }; + for ([_]Vec3r{ Vec3r.unit_x, Vec3r.unit_x.neg(), Vec3r.unit_y, vr(1, 1, 1).normalize() }) |d| { + const hit = scene.bm.castShapeBody(&scene.store, scene.body, probe, vr(50, 1, 2), Quatr.identity, d, 100).?; + try testing.expectEqual(@as(Real, 0), hit.distance); + try testing.expect(hit.normal.dot(d) <= 0); + try testing.expect(hit.normal.approxEql(d.neg(), 8 * std.math.floatEps(Real))); + } +} + +// --------------------------------------------------------------------------- +// E7 / J2 — the `distance` domain +// --------------------------------------------------------------------------- + +test "a non-finite plane distance is refused at creation, in both senses" { + // THE PREDICATE both halves of the domain are tested through, exercised on accepted + // and rejected inputs — a control never seen to fail is a comment with syntax. + // + // WHY it is a domain error and not an unusual value, MEASURED with `distance = NaN`, + // identically at f32 and f64, and the two consequences CONTRADICT each other: + // + // (a) `signedDistance` is NaN, so `sep > 0` is FALSE, so `collidePlane`'s + // `if (sep > 0) continue;` does not fire and a contact IS emitted. Measured: + // a unit sphere 1000 m OUTSIDE the solid produced a manifold with one point. + // The plane reports contact with everything. + // (b) every comparison in `Aabb.overlapsHalfSpace` is FALSE against a NaN bound. + // Measured: the predicate answered `false` for a box at the origin AND for a + // box 5000 m deep INSIDE the solid. The plane meets nothing and vanishes from + // the broadphase. + // + // One malformed input; the narrowphase says "touching everything" and the broadphase + // says "touching nothing"; and neither is visible to the caller. That is why it is an + // assert on the way in rather than a value the kernels tolerate. + try testing.expect(std.math.isFinite(@as(f32, 0))); + for ([_]f32{ 0, -1, 1e6, -12345.678, std.math.floatMin(f32), -std.math.floatMax(f32) }) |d| { + try testing.expect(std.math.isFinite(d)); + } + for ([_]f32{ std.math.nan(f32), std.math.inf(f32), -std.math.inf(f32) }) |d| { + try testing.expect(!std.math.isFinite(d)); + } + + // The accepted values really are accepted, end to end: a plane at each of them + // builds, and the stored distance round-trips. + const gpa = testing.allocator; + var store = ShapeStore{}; + defer store.deinit(gpa); + for ([_]f32{ 0, -1, 1e6, -12345.678 }) |d| { + const id = try store.createShape(gpa, .{ .plane = .{ .normal = av3(0, 1, 0), .distance = d } }); + try testing.expectEqual(@as(Real, d), store.get(id).?.distance); + } + + // And the KERNEL-side half of the domain, `HalfSpace.assertDomain`, guards the + // TRANSPORTED form too — the descriptor assert cannot, a transport being downstream + // of it. Both halves of that assert are exercised as predicates here for the same + // reason: `assertDomain` itself cannot be caught in a Zig test, and each half was + // observed to fire by hand. + const unit_ok = HS{ .normal = Vec3r.unit_y, .distance = 3 }; + try testing.expect(@abs(unit_ok.normal.lengthSq() - 1) <= 16 * std.math.floatEps(Real)); + try testing.expect(std.math.isFinite(unit_ok.distance)); + const bad_distance = HS{ .normal = Vec3r.unit_y, .distance = std.math.nan(Real) }; + try testing.expect(@abs(bad_distance.normal.lengthSq() - 1) <= 16 * std.math.floatEps(Real)); + try testing.expect(!std.math.isFinite(bad_distance.distance)); // the half that would fire + const bad_normal = HS{ .normal = vr(0, 2, 0), .distance = 3 }; + try testing.expect(!(@abs(bad_normal.normal.lengthSq() - 1) <= 16 * std.math.floatEps(Real))); + try testing.expect(std.math.isFinite(bad_normal.distance)); // the OTHER half is fine +} diff --git a/src/modules/forge/forge_3d/tests/raycast_test.zig b/src/modules/forge/forge_3d/tests/raycast_test.zig index 87b9a02..1052762 100644 --- a/src/modules/forge/forge_3d/tests/raycast_test.zig +++ b/src/modules/forge/forge_3d/tests/raycast_test.zig @@ -50,7 +50,7 @@ test "ray hits a sphere at the closed-form point and normal" { // outward normal is −X. { const d = dir(1, 0, 0); - const hit = (try narrowphase.rayShape(Real, sphere, v(-10, 0, 0), d)).?; + const hit = (narrowphase.rayShape(Real, sphere, v(-10, 0, 0), d)).?; try expectHitInvariants(hit, d); try testing.expectApproxEqAbs(@as(Real, 8), hit.distance, tol); try testing.expect(hit.normal.approxEql(v(-1, 0, 0), tol)); @@ -60,7 +60,7 @@ test "ray hits a sphere at the closed-form point and normal" { // x = −√3 at entry, t = 10 − √3, and the normal is (−√3, 1, 0)/2. { const d = dir(1, 0, 0); - const hit = (try narrowphase.rayShape(Real, sphere, v(-10, 1, 0), d)).?; + const hit = (narrowphase.rayShape(Real, sphere, v(-10, 1, 0), d)).?; try expectHitInvariants(hit, d); const root3: Real = @sqrt(@as(Real, 3)); try testing.expectApproxEqAbs(10 - root3, hit.distance, tol); @@ -74,7 +74,7 @@ test "ray hits a sphere at the closed-form point and normal" { // x = 0, t = 10, normal +Y. { const d = dir(1, 0, 0); - const hit = (try narrowphase.rayShape(Real, sphere, v(-10, 2, 0), d)).?; + const hit = (narrowphase.rayShape(Real, sphere, v(-10, 2, 0), d)).?; try expectHitInvariants(hit, d); try testing.expectApproxEqAbs(@as(Real, 10), hit.distance, tol); try testing.expect(hit.normal.approxEql(v(0, 1, 0), tol)); @@ -88,7 +88,7 @@ test "ray hits a sphere at the closed-form point and normal" { const o = v(-3, -3, 0); const d = dir(1, 1, 0); const inv_root2: Real = 1 / @sqrt(@as(Real, 2)); - const hit = (try narrowphase.rayShape(Real, sphere, o, d)).?; + const hit = (narrowphase.rayShape(Real, sphere, o, d)).?; try expectHitInvariants(hit, d); try testing.expectApproxEqAbs(3 * @sqrt(@as(Real, 2)) - sphere.radius, hit.distance, tol); try testing.expect(hit.normal.approxEql(v(-inv_root2, -inv_root2, 0), tol)); @@ -99,12 +99,12 @@ test "ray hits a sphere at the closed-form point and normal" { // A genuine miss with a non-axis-aligned direction, to show the discriminant // branch is reached and not merely the silhouette test: from (−6, 0, 0) along // (1, 1, 0)/√2 the closest approach is 6/√2 ≈ 4.24, well past r = 2. - try testing.expect((try narrowphase.rayShape(Real, sphere, v(-6, 0, 0), dir(1, 1, 0))) == null); + try testing.expect((narrowphase.rayShape(Real, sphere, v(-6, 0, 0), dir(1, 1, 0))) == null); // Misses: past the silhouette, and pointing away from a sphere in front. { - try testing.expect((try narrowphase.rayShape(Real, sphere, v(-10, 2.001, 0), dir(1, 0, 0))) == null); - try testing.expect((try narrowphase.rayShape(Real, sphere, v(-10, 0, 0), dir(-1, 0, 0))) == null); + try testing.expect((narrowphase.rayShape(Real, sphere, v(-10, 2.001, 0), dir(1, 0, 0))) == null); + try testing.expect((narrowphase.rayShape(Real, sphere, v(-10, 0, 0), dir(-1, 0, 0))) == null); } } @@ -127,7 +127,7 @@ test "ray hits a rotated box on the correct face" { .{ .origin = v(0, 0, 10), .d = dir(0, 0, -1), .distance = 7, .normal = v(0, 0, 1) }, }; for (cases) |case| { - const hit = (try narrowphase.rayShape(Real, box, case.origin, case.d)).?; + const hit = (narrowphase.rayShape(Real, box, case.origin, case.d)).?; try expectHitInvariants(hit, case.d); try testing.expectApproxEqAbs(case.distance, hit.distance, tol); try testing.expect(hit.normal.approxEql(case.normal, tol)); @@ -142,7 +142,7 @@ test "ray hits a rotated box on the correct face" { const raw = v(-1, 0.25, 0); const d = raw.normalize(); const t = 4 / @abs(d.toArray()[0]); - const hit = (try narrowphase.rayShape(Real, box, o, d)).?; + const hit = (narrowphase.rayShape(Real, box, o, d)).?; try expectHitInvariants(hit, d); try testing.expectApproxEqAbs(t, hit.distance, tol); try testing.expect(hit.normal.approxEql(v(1, 0, 0), tol)); @@ -156,15 +156,15 @@ test "ray hits a rotated box on the correct face" { // box in x still enters at x = −1, and the entry axis is X. { const d = dir(1, 0, 0); - const hit = (try narrowphase.rayShape(Real, box, v(-10, 0, -3), d)).?; + const hit = (narrowphase.rayShape(Real, box, v(-10, 0, -3), d)).?; try expectHitInvariants(hit, d); try testing.expectApproxEqAbs(@as(Real, 9), hit.distance, tol); try testing.expect(hit.normal.approxEql(v(-1, 0, 0), tol)); } // Misses: beside the box, and behind the ray. - try testing.expect((try narrowphase.rayShape(Real, box, v(-10, 5, 0), dir(1, 0, 0))) == null); - try testing.expect((try narrowphase.rayShape(Real, box, v(-10, 0, 0), dir(-1, 0, 0))) == null); + try testing.expect((narrowphase.rayShape(Real, box, v(-10, 5, 0), dir(1, 0, 0))) == null); + try testing.expect((narrowphase.rayShape(Real, box, v(-10, 0, 0), dir(-1, 0, 0))) == null); } test "an exact edge entry resolves to the first axis" { @@ -184,7 +184,7 @@ test "an exact edge entry resolves to the first axis" { const t_y = (-1 - o.toArray()[1]) / da[1]; try testing.expectEqual(t_x, t_y); // the tie is EXACT, not approximate - const hit = (try narrowphase.rayShape(Real, box, o, d)).?; + const hit = (narrowphase.rayShape(Real, box, o, d)).?; try expectHitInvariants(hit, d); try testing.expectApproxEqAbs(t_x, hit.distance, tol); // X is axis 0, so the −X face wins; a last-axis-wins tie-break would report @@ -206,7 +206,7 @@ test "the capsule wall and cap agree exactly on the frontier" { // Entry exactly at y == h: the wall gives x = −r, the top cap sphere centred // (0, h, 0) also gives x = −r, and both normals are (−1, 0, 0). - const hit = (try narrowphase.rayShape(Real, capsule, v(-10, h, 0), d)).?; + const hit = (narrowphase.rayShape(Real, capsule, v(-10, h, 0), d)).?; try expectHitInvariants(hit, d); try testing.expectApproxEqAbs(@as(Real, 9), hit.distance, tol); try testing.expect(hit.normal.approxEql(v(-1, 0, 0), tol)); @@ -219,7 +219,7 @@ test "a zero-half-height capsule is a sphere and the cap tie is harmless" { // cannot be observed. Recorded rather than claimed: the branch is defensive. const capsule = SupportShapeR{ .core = .{ .segment = 0 }, .radius = 2 }; const d = dir(0, -1, 0); - const hit = (try narrowphase.rayShape(Real, capsule, v(0, 10, 0), d)).?; + const hit = (narrowphase.rayShape(Real, capsule, v(0, 10, 0), d)).?; try expectHitInvariants(hit, d); try testing.expectApproxEqAbs(@as(Real, 8), hit.distance, tol); try testing.expect(hit.normal.approxEql(v(0, 1, 0), tol)); @@ -231,8 +231,8 @@ test "a zero-half-height capsule is a sphere and the cap tie is harmless" { const from = v(-8, 1, 0.5); const target = v(0.5, 0.5, 0); // inside both shapes const oblique = target.sub(from).normalize(); - const as_capsule = (try narrowphase.rayShape(Real, capsule, from, oblique)).?; - const as_sphere = (try narrowphase.rayShape(Real, sphere, from, oblique)).?; + const as_capsule = (narrowphase.rayShape(Real, capsule, from, oblique)).?; + const as_sphere = (narrowphase.rayShape(Real, sphere, from, oblique)).?; try testing.expectApproxEqAbs(as_sphere.distance, as_capsule.distance, tol); try testing.expect(as_sphere.normal.approxEql(as_capsule.normal, tol)); } @@ -276,7 +276,7 @@ test "the box kernel and Aabb.rayInterval agree on the interval" { const inv = Vec3r{ .data = @as(@Vector(3, Real), @splat(1)) / d.data }; const zero_mask = d.data == @as(@Vector(3, Real), @splat(0)); const interval = aabb.rayInterval(o, inv, zero_mask); - const hit = try narrowphase.rayShape(Real, box, o, d); + const hit = narrowphase.rayShape(Real, box, o, d); if (interval) |iv| { if (iv.exit < 0) { @@ -305,7 +305,7 @@ test "ray hits a capsule on the cylinder and on each cap" { // is at x = −1, so t = 9 and the normal is −X (purely radial, zero in Y). { const d = dir(1, 0, 0); - const hit = (try narrowphase.rayShape(Real, capsule, v(-10, 0, 0), d)).?; + const hit = (narrowphase.rayShape(Real, capsule, v(-10, 0, 0), d)).?; try expectHitInvariants(hit, d); try testing.expectApproxEqAbs(@as(Real, 9), hit.distance, tol); try testing.expect(hit.normal.approxEql(v(-1, 0, 0), tol)); @@ -315,7 +315,7 @@ test "ray hits a capsule on the cylinder and on each cap" { // Cylinder wall just BELOW the top cap plane (y = 2.9 < h): still the wall. { const d = dir(1, 0, 0); - const hit = (try narrowphase.rayShape(Real, capsule, v(-10, 2.9, 0), d)).?; + const hit = (narrowphase.rayShape(Real, capsule, v(-10, 2.9, 0), d)).?; try expectHitInvariants(hit, d); try testing.expectApproxEqAbs(@as(Real, 9), hit.distance, tol); try testing.expect(hit.normal.approxEql(v(-1, 0, 0), tol)); @@ -326,7 +326,7 @@ test "ray hits a capsule on the cylinder and on each cap" { // t = 10 − √0.75. The normal is (−√0.75, 0.5, 0). { const d = dir(1, 0, 0); - const hit = (try narrowphase.rayShape(Real, capsule, v(-10, 3.5, 0), d)).?; + const hit = (narrowphase.rayShape(Real, capsule, v(-10, 3.5, 0), d)).?; try expectHitInvariants(hit, d); const x: Real = @sqrt(@as(Real, 0.75)); try testing.expectApproxEqAbs(10 - x, hit.distance, tol); @@ -339,7 +339,7 @@ test "ray hits a capsule on the cylinder and on each cap" { // Bottom cap, mirrored: from (−10, −3.5, 0) along +X. { const d = dir(1, 0, 0); - const hit = (try narrowphase.rayShape(Real, capsule, v(-10, -3.5, 0), d)).?; + const hit = (narrowphase.rayShape(Real, capsule, v(-10, -3.5, 0), d)).?; try expectHitInvariants(hit, d); const x: Real = @sqrt(@as(Real, 0.75)); try testing.expectApproxEqAbs(10 - x, hit.distance, tol); @@ -350,7 +350,7 @@ test "ray hits a capsule on the cylinder and on each cap" { // top cap at y = h + r = 4, so t = 6 and the normal is +Y. { const d = dir(0, -1, 0); - const hit = (try narrowphase.rayShape(Real, capsule, v(0, 10, 0), d)).?; + const hit = (narrowphase.rayShape(Real, capsule, v(0, 10, 0), d)).?; try expectHitInvariants(hit, d); try testing.expectApproxEqAbs(@as(Real, 6), hit.distance, tol); try testing.expect(hit.normal.approxEql(v(0, 1, 0), tol)); @@ -361,11 +361,11 @@ test "ray hits a capsule on the cylinder and on each cap" { // non-zero. This is the frontier the `|y| <= half_height` slab decides. { const d = dir(1, 0, 0); - const below = (try narrowphase.rayShape(Real, capsule, v(-10, nextBelow(h), 0), d)).?; + const below = (narrowphase.rayShape(Real, capsule, v(-10, nextBelow(h), 0), d)).?; try testing.expectEqual(@as(Real, 0), below.normal.toArray()[1]); try testing.expectApproxEqAbs(@as(Real, 9), below.distance, tol); - const above = (try narrowphase.rayShape(Real, capsule, v(-10, nextAbove(h), 0), d)).?; + const above = (narrowphase.rayShape(Real, capsule, v(-10, nextAbove(h), 0), d)).?; try testing.expect(above.normal.toArray()[1] != 0); // Just past the cap plane the cap is all but tangent to the wall, so the // distance is continuous across the frontier. @@ -377,17 +377,17 @@ test "ray hits a capsule on the cylinder and on each cap" { // however long it runs. { const d = dir(0, -1, 0); - const inside = (try narrowphase.rayShape(Real, capsule, v(0.5, 10, 0), d)).?; + const inside = (narrowphase.rayShape(Real, capsule, v(0.5, 10, 0), d)).?; try expectHitInvariants(inside, d); // Cap sphere centred (0, 3, 0), r = 1, at x = 0.5 → y = 3 + √0.75. try testing.expectApproxEqAbs(10 - (3 + @sqrt(@as(Real, 0.75))), inside.distance, tol); - try testing.expect((try narrowphase.rayShape(Real, capsule, v(1.5, 10, 0), d)) == null); + try testing.expect((narrowphase.rayShape(Real, capsule, v(1.5, 10, 0), d)) == null); } // Misses: past the silhouette, and pointing away. - try testing.expect((try narrowphase.rayShape(Real, capsule, v(-10, 4.001, 0), dir(1, 0, 0))) == null); - try testing.expect((try narrowphase.rayShape(Real, capsule, v(-10, 0, 0), dir(-1, 0, 0))) == null); + try testing.expect((narrowphase.rayShape(Real, capsule, v(-10, 4.001, 0), dir(1, 0, 0))) == null); + try testing.expect((narrowphase.rayShape(Real, capsule, v(-10, 0, 0), dir(-1, 0, 0))) == null); } /// The next representable value below `x` — used to straddle an exact frontier @@ -409,7 +409,7 @@ test "an origin inside a shape hits at distance zero with the negated direction" .{ .core = .{ .segment = 3 }, .radius = 1 }, }; for (shapes) |shape| { - const hit = (try narrowphase.rayShape(Real, shape, Vec3r.zero, d)).?; + const hit = (narrowphase.rayShape(Real, shape, Vec3r.zero, d)).?; try expectHitInvariants(hit, d); try testing.expectEqual(@as(Real, 0), hit.distance); try testing.expect(hit.normal.eql(d.neg())); // exactly, not approximately @@ -424,44 +424,50 @@ test "an origin inside a shape hits at distance zero with the negated direction" .{ .shape = shapes[2], .origin = v(0, 4, 0) }, }; for (on_surface) |case| { - const hit = (try narrowphase.rayShape(Real, case.shape, case.origin, d)).?; + const hit = (narrowphase.rayShape(Real, case.shape, case.origin, d)).?; try testing.expectEqual(@as(Real, 0), hit.distance); try testing.expect(hit.normal.eql(d.neg())); } } -test "a rounded box fails loud instead of missing silently" { - // A box core with a non-zero inflation radius is outside this milestone's - // shape set. It must be an error, never a null (which would read as "no hit") - // and never a plain box (which would under-report the surface). +test "a rounded box is refused by the ray kernel's precondition, whatever the origin" { + // A box core with a non-zero inflation radius is outside the ray kernels' shape + // set: no arm measures its inflated surface, so the box arm would under-report it + // by the radius. + // + // RE-EXPRESSED at M1.1.11/E3, and the claim is unchanged. It was three + // `expectError(error.UnsupportedShape, rayShape(...))` calls at three origins; the + // kernel no longer carries an error, because through `supportShape` — which gives + // every stored box `radius = 0` — that error was reachable by no path at all, and + // a control never seen to fire is a comment with syntax (§1.11.3). The typed + // refusal moved to the two query entries taking a caller-supplied shape handle, + // where a caller can provoke it; here it is `raySupportsShape`, an ASSERTED + // precondition. + // + // What the three origins existed to prove — that the refusal belongs to the SHAPE + // and not to the trajectory, the interior origin having once returned a + // distance-zero hit before ever reaching the check — is now STRUCTURAL: the + // predicate takes no origin at all, so no origin can change its answer. That is a + // stronger statement than three samples, and it is the reason the re-expression is + // not a weakening. The assert's placement before the membership test is what keeps + // the interior case covered, and it was observed to fire at all three origins by + // hand. const rounded = SupportShapeR{ .core = .{ .box = v(1, 1, 1) }, .radius = 0.25 }; + try testing.expect(!narrowphase.raySupportsShape(Real, rounded)); - // The DISCRIMINATING case: an origin INSIDE the core. It is the only one that - // tells a rejection carried by the SHAPE from a rejection carried by the - // trajectory — with the check placed after the solid-membership test, this - // input returned a distance-zero hit and never reached the rejection at all, - // while the exterior case below passed for an apparent reason that did not - // cover it. No mutation of the kernels could have surfaced that: mutation - // tests the code against the tests present, never the tests against the - // inputs absent. - try testing.expectError( - error.UnsupportedShape, - narrowphase.rayShape(Real, rounded, Vec3r.zero, dir(1, 0, 0)), - ); - // On a face of the core, likewise inside for the membership test. - try testing.expectError( - error.UnsupportedShape, - narrowphase.rayShape(Real, rounded, v(1, 0, 0), dir(1, 0, 0)), - ); - // The exterior origin stays — it costs nothing and covers the ordinary path. - try testing.expectError( - error.UnsupportedShape, - narrowphase.rayShape(Real, rounded, v(-10, 0, 0), dir(1, 0, 0)), - ); - // A radius-0 box on the same geometry is fine — so the error is about the - // radius and not about the box. + // A radius-0 box on the same geometry is admissible AND answers — so the refusal + // is about the radius and not about the box, and the predicate is not simply + // false everywhere. const plain = SupportShapeR{ .core = .{ .box = v(1, 1, 1) }, .radius = 0 }; - try testing.expect((try narrowphase.rayShape(Real, plain, v(-10, 0, 0), dir(1, 0, 0))) != null); + try testing.expect(narrowphase.raySupportsShape(Real, plain)); + try testing.expect(narrowphase.rayShape(Real, plain, v(-10, 0, 0), dir(1, 0, 0)) != null); + + // The other two cores are admissible at ANY radius: only a box has a core whose + // inflation the kernel cannot express, which is what makes the predicate's + // condition specific rather than a blanket radius test. + try testing.expect(narrowphase.raySupportsShape(Real, .{ .core = .point, .radius = 0.25 })); + try testing.expect(narrowphase.raySupportsShape(Real, .{ .core = .{ .segment = 1 }, .radius = 0.25 })); + try testing.expect(narrowphase.raySupportsShape(Real, .{ .core = .point, .radius = 0 })); } test "a degenerate zero radius never divides by zero" { @@ -472,12 +478,12 @@ test "a degenerate zero radius never divides by zero" { // the invariants rather than come out NaN. const point_sphere = SupportShapeR{ .core = .point, .radius = 0 }; const d = dir(1, 0, 0); - const through_centre = (try narrowphase.rayShape(Real, point_sphere, v(-10, 0, 0), d)).?; + const through_centre = (narrowphase.rayShape(Real, point_sphere, v(-10, 0, 0), d)).?; try expectHitInvariants(through_centre, d); try testing.expectApproxEqAbs(@as(Real, 10), through_centre.distance, tol); const bare_segment = SupportShapeR{ .core = .{ .segment = 3 }, .radius = 0 }; - const at_axis = (try narrowphase.rayShape(Real, bare_segment, v(-10, 1, 0), d)).?; + const at_axis = (narrowphase.rayShape(Real, bare_segment, v(-10, 1, 0), d)).?; try expectHitInvariants(at_axis, d); try testing.expectApproxEqAbs(@as(Real, 10), at_axis.distance, tol); } @@ -550,18 +556,18 @@ test "the three query entries agree over a real broadphase" { const q = query.RayQuery{ .origin = Vec3r.zero, .direction = v(1, 0, 0), .max_distance = 100 }; // closest → the nearest of the three, at its closed-form surface distance. - const hit = (try query.raycast(&world.bp, &world.bm, &world.store, q)).?; + const hit = (query.raycast(&world.bp, &world.bm, &world.store, q)).?; try testing.expectEqual(near, hit.body); try testing.expectApproxEqAbs(@as(Real, 9), hit.distance, tol); try testing.expect(hit.normal.approxEql(v(-1, 0, 0), tol)); try testing.expect(hit.position.approxEql(v(9, 0, 0), tol)); // any → true, and it agrees with closest on existence. - try testing.expect(try query.raycastAny(&world.bp, &world.bm, &world.store, q)); + try testing.expect(query.raycastAny(&world.bp, &world.bm, &world.store, q)); // all → the three, sorted by distance. var buf: [8]query.RayHit = undefined; - const n = try query.raycastAll(&world.bp, &world.bm, &world.store, q, &buf); + const n = query.raycastAll(&world.bp, &world.bm, &world.store, q, &buf); try testing.expectEqual(@as(u32, 3), n); try testing.expectEqual(near, buf[0].body); try testing.expectEqual(mid, buf[1].body); @@ -570,9 +576,9 @@ test "the three query entries agree over a real broadphase" { // A ray pointing away hits nothing, in all three modes. const away = query.RayQuery{ .origin = Vec3r.zero, .direction = v(-1, 0, 0), .max_distance = 100 }; - try testing.expect((try query.raycast(&world.bp, &world.bm, &world.store, away)) == null); - try testing.expect(!try query.raycastAny(&world.bp, &world.bm, &world.store, away)); - try testing.expectEqual(@as(u32, 0), try query.raycastAll(&world.bp, &world.bm, &world.store, away, &buf)); + try testing.expect((query.raycast(&world.bp, &world.bm, &world.store, away)) == null); + try testing.expect(!query.raycastAny(&world.bp, &world.bm, &world.store, away)); + try testing.expectEqual(@as(u32, 0), query.raycastAll(&world.bp, &world.bm, &world.store, away, &buf)); } test "raycastBody transports the ray by the inverse pose" { @@ -595,7 +601,7 @@ test "raycastBody transports the ray by the inverse pose" { }); const ray = query.Ray.init(Vec3r.zero, v(1, 0, 0)); - const local = (try world.bm.raycastBody(&world.store, id, ray)).?; + const local = (world.bm.raycastBody(&world.store, id, ray)).?; // World entry at x = 10 − 3 = 7 (the local +Y extent faces −X). try testing.expectApproxEqAbs(@as(Real, 7), local.distance, tol); // The LOCAL normal is +Y; rotating it by the body's rotation gives world −X. @@ -604,13 +610,13 @@ test "raycastBody transports the ray by the inverse pose" { // And the full query agrees, position included. const q = query.RayQuery{ .origin = Vec3r.zero, .direction = v(1, 0, 0), .max_distance = 100 }; - const hit = (try query.raycast(&world.bp, &world.bm, &world.store, q)).?; + const hit = (query.raycast(&world.bp, &world.bm, &world.store, q)).?; try testing.expectApproxEqAbs(@as(Real, 7), hit.distance, tol); try testing.expect(hit.normal.approxEql(v(-1, 0, 0), tol)); // A stale handle answers null rather than reading garbage. world.removeBody(id); - try testing.expect((try world.bm.raycastBody(&world.store, id, ray)) == null); + try testing.expect((world.bm.raycastBody(&world.store, id, ray)) == null); } test "only an exactly zero direction is empty; both float extremes work" { @@ -624,9 +630,9 @@ test "only an exactly zero direction is empty; both float extremes work" { // largest absolute component, which is zero exactly when all three are. { const q = query.RayQuery{ .origin = Vec3r.zero, .direction = Vec3r.zero, .max_distance = 100 }; - try testing.expect((try query.raycast(&world.bp, &world.bm, &world.store, q)) == null); - try testing.expect(!try query.raycastAny(&world.bp, &world.bm, &world.store, q)); - try testing.expectEqual(@as(u32, 0), try query.raycastAll(&world.bp, &world.bm, &world.store, q, &buf)); + try testing.expect((query.raycast(&world.bp, &world.bm, &world.store, q)) == null); + try testing.expect(!query.raycastAny(&world.bp, &world.bm, &world.store, q)); + try testing.expectEqual(@as(u32, 0), query.raycastAll(&world.bp, &world.bm, &world.store, q, &buf)); } // A HUGE direction is finite, passes the domain assert, and must give the same @@ -643,11 +649,11 @@ test "only an exactly zero direction is empty; both float extremes work" { try testing.expect(std.math.isFinite(huge_component)); // still a legal direction try testing.expect(std.math.isInf(huge.lengthSq())); const q = query.RayQuery{ .origin = Vec3r.zero, .direction = huge, .max_distance = 100 }; - const hit = (try query.raycast(&world.bp, &world.bm, &world.store, q)).?; + const hit = (query.raycast(&world.bp, &world.bm, &world.store, q)).?; try testing.expectApproxEqAbs(@as(Real, 9), hit.distance, tol); try testing.expect(hit.normal.approxEql(v(-1, 0, 0), tol)); - try testing.expect(try query.raycastAny(&world.bp, &world.bm, &world.store, q)); - try testing.expectEqual(@as(u32, 1), try query.raycastAll(&world.bp, &world.bm, &world.store, q, &buf)); + try testing.expect(query.raycastAny(&world.bp, &world.bm, &world.store, q)); + try testing.expectEqual(@as(u32, 1), query.raycastAll(&world.bp, &world.bm, &world.store, q, &buf)); } // A DENORMAL direction, likewise. Discrimination guard the other way: its @@ -659,7 +665,7 @@ test "only an exactly zero direction is empty; both float extremes work" { const tiny = v(std.math.floatTrueMin(Real), 0, 0); try testing.expectEqual(@as(Real, 0), tiny.lengthSq()); const q = query.RayQuery{ .origin = Vec3r.zero, .direction = tiny, .max_distance = 100 }; - const hit = (try query.raycast(&world.bp, &world.bm, &world.store, q)).?; + const hit = (query.raycast(&world.bp, &world.bm, &world.store, q)).?; try testing.expectApproxEqAbs(@as(Real, 9), hit.distance, tol); try testing.expect(hit.normal.approxEql(v(-1, 0, 0), tol)); } @@ -672,26 +678,29 @@ test "only an exactly zero direction is empty; both float extremes work" { try testing.expectEqual(@as(Real, 0), v(t, t, t).lengthSq()); // Aimed at the sphere's centre line from (−10,−10,−10) the ray misses it, // but what matters is that it is a real ray: `any` and `closest` agree. - const closest = try query.raycast(&world.bp, &world.bm, &world.store, q); - try testing.expectEqual(closest != null, try query.raycastAny(&world.bp, &world.bm, &world.store, q)); + const closest = query.raycast(&world.bp, &world.bm, &world.store, q); + try testing.expectEqual(closest != null, query.raycastAny(&world.bp, &world.bm, &world.store, q)); } } -test "no reachable shape makes a query fail, and the error channel is not dead code" { +test "every shape the store can build answers a ray query, and the ray path is total" { const gpa = std.testing.allocator; var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); defer world.deinit(gpa); - // RECORDED, because it changes what this milestone can claim: through - // `BodyManager.raycastBody`, `error.UnsupportedShape` is currently - // UNREACHABLE. `shape.supportShape` maps a box to `radius = 0` - // unconditionally, and `ShapeStore` holds only sphere / box / capsule, so no - // `SupportShape` reaching the kernel can be a rounded box. The error channel - // is therefore structurally required — the kernel's signature carries it and - // E3 pins it at the kernel level — but it cannot be exercised end-to-end - // until a shape whose `SupportShape` can carry an unsupported combination - // exists (Plane / MeshShape, M1.1.11). This test asserts what IS reachable: - // every shape the store can build answers a query without error. + // RE-EXPRESSED at M1.1.11/E3, and this is the test whose PREMISE the milestone + // closed. It recorded a dated unreachability: through `BodyManager.raycastBody`, + // `error.UnsupportedShape` could not be produced by any shape the store held, + // because `shape.supportShape` gives every box `radius = 0` — so the ray path + // carried an error channel no input could reach, and the record said the date was + // M1.1.11. It is: the channel is GONE from the ray path (the kernel's refusal is an + // asserted precondition) and the typed refusal now lives at the two query entries + // taking a caller-supplied shape handle, where a caller can provoke it and where + // `shapecast_test.zig` / `overlap_test.zig` exercise both of its members. + // + // What SURVIVES here is the positive claim the old test also made, unchanged: every + // shape the store can build answers a ray query. It is now stated without a `try`, + // which is itself the observable — the entries are total. const sphere = try world.store.createShape(gpa, .{ .sphere = .{ .radius = 1 } }); const box = try world.store.createShape(gpa, .{ .box = .{ .half_extents = harness.av3(1, 1, 1) } }); const capsule = try world.store.createShape(gpa, .{ .capsule = .{ .radius = 0.5, .half_height = 1 } }); @@ -706,10 +715,10 @@ test "no reachable shape makes a query fail, and the error channel is not dead c const q = query.RayQuery{ .origin = Vec3r.zero, .direction = v(1, 0, 0), .max_distance = 100 }; var buf: [8]query.RayHit = undefined; - // No error, and all three bodies answer. - try testing.expect((try query.raycast(&world.bp, &world.bm, &world.store, q)) != null); - try testing.expect(try query.raycastAny(&world.bp, &world.bm, &world.store, q)); - try testing.expectEqual(@as(u32, 3), try query.raycastAll(&world.bp, &world.bm, &world.store, q, &buf)); + // All three bodies answer, and no `try` appears on any of the three calls. + try testing.expect((query.raycast(&world.bp, &world.bm, &world.store, q)) != null); + try testing.expect(query.raycastAny(&world.bp, &world.bm, &world.store, q)); + try testing.expectEqual(@as(u32, 3), query.raycastAll(&world.bp, &world.bm, &world.store, q, &buf)); } // --------------------------------------------------------------------------- @@ -745,6 +754,18 @@ test "a body on layer 32 or above is refused at creation" { try testing.expectEqual(count_before, world.bm.count()); } +/// Whether a function type returns an error union. The property a TOTAL query entry +/// has is its negation, and naming it is what lets the pin below assert an ABSENCE of +/// error rather than merely a type equality that happens not to mention one. +fn returnsErrorUnion(comptime F: type) bool { + return @typeInfo(@typeInfo(F).@"fn".return_type.?) == .error_union; +} + +/// The error set of a fallible function type. +fn errorSetOf(comptime F: type) type { + return @typeInfo(@typeInfo(F).@"fn".return_type.?).error_union.error_set; +} + test "the family carries its Real signatures, and the public types stay frozen" { // TWO pins, and they are deliberately not the same claim. // @@ -768,26 +789,32 @@ test "the family carries its Real signatures, and the public types stay frozen" const SS = body_manager_mod.ShapeStore; const BP = broadphase_mod.Broadphase(Real); - // (1) The three ray entries. + // (1) The three ray entries. TOTAL since M1.1.11/E3 — they carried + // `query.Error!` while the kernel's rounded-box latch existed, and that error + // was reachable through no body at all (`supportShape` gives every stored box + // `radius = 0`), so it became an asserted precondition and the latch went with + // it (§1.11.3, §1.11.7). try testing.expectEqual( - fn (*const BP, *const BM, *const SS, query.RayQuery) query.Error!?query.RayHit, + fn (*const BP, *const BM, *const SS, query.RayQuery) ?query.RayHit, @TypeOf(query.raycast), ); try testing.expectEqual( - fn (*const BP, *const BM, *const SS, query.RayQuery) query.Error!bool, + fn (*const BP, *const BM, *const SS, query.RayQuery) bool, @TypeOf(query.raycastAny), ); try testing.expectEqual( - fn (*const BP, *const BM, *const SS, query.RayQuery, []query.RayHit) query.Error!u32, + fn (*const BP, *const BM, *const SS, query.RayQuery, []query.RayHit) u32, @TypeOf(query.raycastAll), ); - // …and the five that were stubs, now at `Real` with `[]BodyId` outputs. + // …and the five that were stubs, now at `Real` with `[]BodyId` outputs. The two + // taking a caller-supplied SHAPE HANDLE carry `query.Error` (§1.11.7); the three + // that take none do not. try testing.expectEqual( - fn (*const BP, *const BM, *const SS, query.CastQuery) ?query.CastHit, + fn (*const BP, *const BM, *const SS, query.CastQuery) query.Error!?query.CastHit, @TypeOf(query.shapeCast), ); try testing.expectEqual( - fn (*const BP, *const BM, *const SS, query.OverlapRequest, []api.BodyId) u32, + fn (*const BP, *const BM, *const SS, query.OverlapRequest, []api.BodyId) query.Error!u32, @TypeOf(query.overlapShape), ); try testing.expectEqual( @@ -842,6 +869,37 @@ test "the family carries its Real signatures, and the public types stay frozen" try testing.expectEqual(Vec3f, @FieldType(api.ClosestPointResult, "position")); try testing.expectEqual(f32, @FieldType(api.ClosestPointResult, "distance")); + // (3) WHICH entries carry an error, stated in the test's own vocabulary rather + // than left implied by the type equalities above. An absence pinned only by a + // type equality reads as an accident; pinned as an absence, a later widening + // is a deliberate act — which is the whole reason §1.11.7 settles the family + // before the freeze. + // + // SIX entries are TOTAL, and they are exactly the six that receive no + // caller-supplied shape handle: there is nothing about them a caller can get + // wrong that a `null`, a `false` or a `0` misreports. + inline for (.{ + @TypeOf(query.raycast), + @TypeOf(query.raycastAny), + @TypeOf(query.raycastAll), + @TypeOf(query.overlapAabb), + @TypeOf(query.pointQuery), + @TypeOf(query.closestPoint), + }) |Total| { + try testing.expect(!returnsErrorUnion(Total)); + } + // TWO carry the channel, and its error SET is pinned member by member: a + // stale handle and an inadmissible probe, distinct from each other and from a + // real miss. One-for-one with the frozen `WeldQueryStatus` of + // `engine-c-api.md`. + inline for (.{ @TypeOf(query.shapeCast), @TypeOf(query.overlapShape) }) |Fallible| { + try testing.expect(returnsErrorUnion(Fallible)); + try testing.expectEqual(query.Error, errorSetOf(Fallible)); + } + try testing.expectEqual(@as(usize, 2), @typeInfo(query.Error).error_set.?.len); + try testing.expectError(error.InvalidShape, @as(query.Error!void, error.InvalidShape)); + try testing.expectError(error.UnsupportedShape, @as(query.Error!void, error.UnsupportedShape)); + // The layer domain is one constant shared by the mask and by `addBody`'s // rejection, not two copies of 32. try testing.expectEqual(@as(u8, 32), api.collision_layer_count); @@ -888,7 +946,7 @@ test "closest hit wins over several candidates" { for (order) |which| { ids[which] = try addSphereAt(gpa, &world, centres[which], 0, @intCast(which)); } - const hit = (try query.raycast(&world.bp, &world.bm, &world.store, axisQuery(100))).?; + const hit = (query.raycast(&world.bp, &world.bm, &world.store, axisQuery(100))).?; try testing.expectEqual(ids[0], hit.body); try testing.expectApproxEqAbs(@as(Real, 9), hit.distance, tol); } @@ -932,19 +990,19 @@ test "equal distance is broken by the owning entity" { // PRECONDITION: the two exact distances are bit-identical. const ray = query.Ray.init(Vec3r.zero, v(1, 0, 0)); - const hit_a = (try world.bm.raycastBody(&world.store, a, ray)).?; - const hit_b = (try world.bm.raycastBody(&world.store, b, ray)).?; + const hit_a = (world.bm.raycastBody(&world.store, a, ray)).?; + const hit_b = (world.bm.raycastBody(&world.store, b, ray)).?; try testing.expectEqual(hit_a.distance, hit_b.distance); // Only then does the winner mean anything: the LOWER ENTITY. - const hit = (try query.raycast(&world.bp, &world.bm, &world.store, axisQuery(100))).?; + const hit = (query.raycast(&world.bp, &world.bm, &world.store, axisQuery(100))).?; try testing.expectEqual(want, hit.body); try testing.expectEqual(@as(u32, low_y_entity), hit.entity.index); try testing.expectEqual(hit_a.distance, hit.distance); // `all` sorts on the same composite key, so the tie orders by entity too. var buf: [4]query.RayHit = undefined; - const n = try query.raycastAll(&world.bp, &world.bm, &world.store, axisQuery(100), &buf); + const n = query.raycastAll(&world.bp, &world.bm, &world.store, axisQuery(100), &buf); try testing.expectEqual(@as(u32, 2), n); try testing.expectEqual(buf[0].distance, buf[1].distance); try testing.expect(buf[0].entity.index < buf[1].entity.index); @@ -992,7 +1050,7 @@ test "a sleeping body is hit and stays asleep" { // A sleeper's proxy stays in the tree: step 10 of the cycle skips its UPDATE, // it does not remove it (§1.11.1). const down = query.RayQuery{ .origin = v(0, 10, 0), .direction = v(0, -1, 0), .max_distance = 100 }; - const hit = (try query.raycast(&world.bp, &world.bm, &world.store, down)).?; + const hit = (query.raycast(&world.bp, &world.bm, &world.store, down)).?; try testing.expectEqual(box, hit.body); try testing.expectApproxEqAbs(@as(Real, 8.5), hit.distance, 1e-2); // The oracle for the normal is the BODY'S OWN transported local +Y, not the @@ -1009,9 +1067,9 @@ test "a sleeping body is hit and stays asleep" { // Both halves are needed — a query that woke the body would still have returned // this hit, so the hit alone proves nothing about the wake contract. try testing.expect(world.bm.isSleeping(box).?); - _ = try query.raycastAny(&world.bp, &world.bm, &world.store, down); + _ = query.raycastAny(&world.bp, &world.bm, &world.store, down); var buf: [4]query.RayHit = undefined; - _ = try query.raycastAll(&world.bp, &world.bm, &world.store, down, &buf); + _ = query.raycastAll(&world.bp, &world.bm, &world.store, down, &buf); try testing.expect(world.bm.isSleeping(box).?); } @@ -1036,15 +1094,15 @@ test "the object mask filters" { for (cases) |case| { var q = axisQuery(100); q.filter.layer_mask = case.mask; - const hit = try query.raycast(&world.bp, &world.bm, &world.store, q); + const hit = query.raycast(&world.bp, &world.bm, &world.store, q); if (case.expect) |want| { try testing.expectEqual(want, hit.?.body); } else { try testing.expect(hit == null); } - try testing.expectEqual(case.expect != null, try query.raycastAny(&world.bp, &world.bm, &world.store, q)); + try testing.expectEqual(case.expect != null, query.raycastAny(&world.bp, &world.bm, &world.store, q)); var buf: [8]query.RayHit = undefined; - try testing.expectEqual(case.count, try query.raycastAll(&world.bp, &world.bm, &world.store, q, &buf)); + try testing.expectEqual(case.count, query.raycastAll(&world.bp, &world.bm, &world.store, q, &buf)); } } @@ -1061,22 +1119,22 @@ test "exclusions are honoured" { { var q = axisQuery(100); q.filter.exclude = &.{near}; - try testing.expectEqual(mid, (try query.raycast(&world.bp, &world.bm, &world.store, q)).?.body); + try testing.expectEqual(mid, (query.raycast(&world.bp, &world.bm, &world.store, q)).?.body); } // Excluding two yields the third. { var q = axisQuery(100); q.filter.exclude = &.{ near, mid }; - try testing.expectEqual(far, (try query.raycast(&world.bp, &world.bm, &world.store, q)).?.body); + try testing.expectEqual(far, (query.raycast(&world.bp, &world.bm, &world.store, q)).?.body); } // Excluding all three yields nothing, in all three modes. { var q = axisQuery(100); q.filter.exclude = &.{ near, mid, far }; - try testing.expect((try query.raycast(&world.bp, &world.bm, &world.store, q)) == null); - try testing.expect(!try query.raycastAny(&world.bp, &world.bm, &world.store, q)); + try testing.expect((query.raycast(&world.bp, &world.bm, &world.store, q)) == null); + try testing.expect(!query.raycastAny(&world.bp, &world.bm, &world.store, q)); var buf: [8]query.RayHit = undefined; - try testing.expectEqual(@as(u32, 0), try query.raycastAll(&world.bp, &world.bm, &world.store, q, &buf)); + try testing.expectEqual(@as(u32, 0), query.raycastAll(&world.bp, &world.bm, &world.store, q, &buf)); } } @@ -1087,17 +1145,17 @@ test "max_distance is a closed interval" { _ = try addSphereAt(gpa, &world, .{ 10, 0, 0 }, 0, 0); // The surface is at exactly 9. A bound of 9 counts; one ulp below does not. - const at = try query.raycast(&world.bp, &world.bm, &world.store, axisQuery(9)); + const at = query.raycast(&world.bp, &world.bm, &world.store, axisQuery(9)); try testing.expect(at != null); try testing.expectApproxEqAbs(@as(Real, 9), at.?.distance, tol); - try testing.expect(try query.raycastAny(&world.bp, &world.bm, &world.store, axisQuery(9))); + try testing.expect(query.raycastAny(&world.bp, &world.bm, &world.store, axisQuery(9))); const just_below = nextBelow(9); try testing.expect(just_below < 9); // the ulp step is real at this scale - try testing.expect((try query.raycast(&world.bp, &world.bm, &world.store, axisQuery(just_below))) == null); - try testing.expect(!try query.raycastAny(&world.bp, &world.bm, &world.store, axisQuery(just_below))); + try testing.expect((query.raycast(&world.bp, &world.bm, &world.store, axisQuery(just_below))) == null); + try testing.expect(!query.raycastAny(&world.bp, &world.bm, &world.store, axisQuery(just_below))); var buf: [4]query.RayHit = undefined; - try testing.expectEqual(@as(u32, 0), try query.raycastAll(&world.bp, &world.bm, &world.store, axisQuery(just_below), &buf)); + try testing.expectEqual(@as(u32, 0), query.raycastAll(&world.bp, &world.bm, &world.store, axisQuery(just_below), &buf)); } test "max_distance zero degenerates to a point test" { @@ -1111,7 +1169,7 @@ test "max_distance zero degenerates to a point test" { // Inside → a hit at distance zero, whatever the direction. for ([_]Vec3r{ v(1, 0, 0), v(0, -1, 0), v(1, 2, -3) }) |d| { const q = query.RayQuery{ .origin = Vec3r.zero, .direction = d, .max_distance = 0 }; - const hit = (try query.raycast(&world.bp, &world.bm, &world.store, q)).?; + const hit = (query.raycast(&world.bp, &world.bm, &world.store, q)).?; try testing.expectEqual(containing, hit.body); try testing.expectEqual(@as(Real, 0), hit.distance); // The distance-zero normal is the negated (normalised) direction. @@ -1120,8 +1178,8 @@ test "max_distance zero degenerates to a point test" { // Outside every shape → nothing, even though a body sits 10 m down the ray. const outside = query.RayQuery{ .origin = v(5, 0, 0), .direction = v(1, 0, 0), .max_distance = 0 }; - try testing.expect((try query.raycast(&world.bp, &world.bm, &world.store, outside)) == null); - try testing.expect(!try query.raycastAny(&world.bp, &world.bm, &world.store, outside)); + try testing.expect((query.raycast(&world.bp, &world.bm, &world.store, outside)) == null); + try testing.expect(!query.raycastAny(&world.bp, &world.bm, &world.store, outside)); } test "any terminates and agrees with closest on existence" { @@ -1171,8 +1229,8 @@ test "any terminates and agrees with closest on existence" { }; q.filter.layer_mask = if (k % 5 == 0) 0b0011 else 0xFFFF_FFFF; - const closest = try query.raycast(&world.bp, &world.bm, &world.store, q); - const any = try query.raycastAny(&world.bp, &world.bm, &world.store, q); + const closest = query.raycast(&world.bp, &world.bm, &world.store, q); + const any = query.raycastAny(&world.bp, &world.bm, &world.store, q); try testing.expectEqual(closest != null, any); if (any) checked_true += 1 else checked_false += 1; } @@ -1191,7 +1249,7 @@ test "all returns every hit sorted by distance then BodyId" { const c = try addSphereAt(gpa, &world, .{ 30, 0, 0 }, 0, 2); var buf: [8]query.RayHit = undefined; - const n = try query.raycastAll(&world.bp, &world.bm, &world.store, axisQuery(100), &buf); + const n = query.raycastAll(&world.bp, &world.bm, &world.store, axisQuery(100), &buf); try testing.expectEqual(@as(u32, 3), n); try testing.expectEqual(a, buf[0].body); try testing.expectEqual(b, buf[1].body); @@ -1207,12 +1265,12 @@ test "all returns every hit sorted by distance then BodyId" { // entry rather than dropping late arrivals, so the answer does not depend on // the order the traversal happened to reach them in. var small: [2]query.RayHit = undefined; - try testing.expectEqual(@as(u32, 2), try query.raycastAll(&world.bp, &world.bm, &world.store, axisQuery(100), &small)); + try testing.expectEqual(@as(u32, 2), query.raycastAll(&world.bp, &world.bm, &world.store, axisQuery(100), &small)); try testing.expectEqual(a, small[0].body); try testing.expectEqual(b, small[1].body); var one: [1]query.RayHit = undefined; - try testing.expectEqual(@as(u32, 1), try query.raycastAll(&world.bp, &world.bm, &world.store, axisQuery(100), &one)); + try testing.expectEqual(@as(u32, 1), query.raycastAll(&world.bp, &world.bm, &world.store, axisQuery(100), &one)); try testing.expectEqual(a, one[0].body); // The overflow assertions above do NOT discriminate on their own, and a @@ -1240,8 +1298,8 @@ test "all returns every hit sorted by distance then BodyId" { // The premise, asserted rather than assumed: `big` really is hit LATER. const ray = query.Ray.init(Vec3r.zero, v(1, 0, 0)); - const big_hit = (try skew.bm.raycastBody(&skew.store, big, ray)).?; - const small_hit = (try skew.bm.raycastBody(&skew.store, small_sphere, ray)).?; + const big_hit = (skew.bm.raycastBody(&skew.store, big, ray)).?; + const small_hit = (skew.bm.raycastBody(&skew.store, small_sphere, ray)).?; try testing.expect(small_hit.distance < big_hit.distance); // ...and its AABB really is entered EARLIER, which is what makes the // traversal offer it first. @@ -1249,14 +1307,14 @@ test "all returns every hit sorted by distance then BodyId" { skew.bm.bodyAabb(&skew.store, small_sphere).?.min.toArray()[0]); var slot: [1]query.RayHit = undefined; - try testing.expectEqual(@as(u32, 1), try query.raycastAll(&skew.bp, &skew.bm, &skew.store, axisQuery(100), &slot)); + try testing.expectEqual(@as(u32, 1), query.raycastAll(&skew.bp, &skew.bm, &skew.store, axisQuery(100), &slot)); try testing.expectEqual(small_sphere, slot[0].body); try testing.expectEqual(small_hit.distance, slot[0].distance); } // A zero-length buffer writes nothing and says so. var none: [0]query.RayHit = undefined; - try testing.expectEqual(@as(u32, 0), try query.raycastAll(&world.bp, &world.bm, &world.store, axisQuery(100), &none)); + try testing.expectEqual(@as(u32, 0), query.raycastAll(&world.bp, &world.bm, &world.store, axisQuery(100), &none)); } /// Compare two hits FIELD BY FIELD with exact equality — the bitwise comparison the @@ -1301,7 +1359,7 @@ test "the result is invariant under creation-order permutation" { defer world.deinit(gpa); for (order) |which| _ = try addSphereAt(gpa, &world, centres[which], 0, @intCast(which)); - const hit = (try query.raycast(&world.bp, &world.bm, &world.store, axisQuery(100))).?; + const hit = (query.raycast(&world.bp, &world.bm, &world.store, axisQuery(100))).?; // The ENTITY is the identity that survives a permutation; the `BodyId` does // not, and asserting it here would be asserting the wrong thing — on this // scene the nearest body happens to be created first in some orders and last @@ -1323,7 +1381,7 @@ test "the result is invariant under creation-order permutation" { // …and the FULL answer, not just the closest: `all` must return the four // entities in the same order under every permutation. var buf: [4]query.RayHit = undefined; - try testing.expectEqual(@as(u32, 4), try query.raycastAll(&world.bp, &world.bm, &world.store, axisQuery(100), &buf)); + try testing.expectEqual(@as(u32, 4), query.raycastAll(&world.bp, &world.bm, &world.store, axisQuery(100), &buf)); for (buf, 0..) |h, i| try testing.expectEqual(@as(u32, @intCast(i)), h.entity.index); } } @@ -1336,8 +1394,8 @@ test "two identical runs are bit-identical" { _ = try addSphereAt(gpa, &world, .{ 20, -0.5, 0 }, 0, 1); const q = axisQuery(100); - const first = (try query.raycast(&world.bp, &world.bm, &world.store, q)).?; - const second = (try query.raycast(&world.bp, &world.bm, &world.store, q)).?; + const first = (query.raycast(&world.bp, &world.bm, &world.store, q)).?; + const second = (query.raycast(&world.bp, &world.bm, &world.store, q)).?; try expectHitBitIdentical(first, second); // And in a second, independently built world with the same construction — the @@ -1346,7 +1404,7 @@ test "two identical runs are bit-identical" { defer twin.deinit(gpa); _ = try addSphereAt(gpa, &twin, .{ 10, 0.25, 0 }, 0, 0); _ = try addSphereAt(gpa, &twin, .{ 20, -0.5, 0 }, 0, 1); - const twin_hit = (try query.raycast(&twin.bp, &twin.bm, &twin.store, q)).?; + const twin_hit = (query.raycast(&twin.bp, &twin.bm, &twin.store, q)).?; try expectHitBitIdentical(first, twin_hit); } @@ -1373,13 +1431,13 @@ test "the sphere kernel is conditioned far from the shape" { // further out. Worth stating rather than implying a guard at both precisions. const sphere = SupportShapeR{ .core = .point, .radius = 1 }; const d = dir(1, 0, 0); - const hit = (try narrowphase.rayShape(Real, sphere, v(-5000, 0, 0), d)).?; + const hit = (narrowphase.rayShape(Real, sphere, v(-5000, 0, 0), d)).?; try expectHitInvariants(hit, d); // includes |normal| == 1, which the old form failed try testing.expectApproxEqAbs(@as(Real, 4999), hit.distance, far_tol); try testing.expect(hit.normal.approxEql(v(-1, 0, 0), 1e-4)); // Off-axis at the same range, so the perpendicular term is not zero either. - const oblique = (try narrowphase.rayShape(Real, sphere, v(-5000, 0.5, 0), d)).?; + const oblique = (narrowphase.rayShape(Real, sphere, v(-5000, 0.5, 0), d)).?; try expectHitInvariants(oblique, d); // x² + 0.25 = 1 ⇒ x = −√0.75, so t = 5000 − √0.75. try testing.expectApproxEqAbs(5000 - @sqrt(@as(Real, 0.75)), oblique.distance, far_tol); @@ -1396,7 +1454,7 @@ test "the capsule kernel is conditioned far from the shape" { const d = dir(1, 0, 0); // Cylinder wall at mid-height. - const wall = (try narrowphase.rayShape(Real, capsule, v(-5000, 0, 0), d)).?; + const wall = (narrowphase.rayShape(Real, capsule, v(-5000, 0, 0), d)).?; try expectHitInvariants(wall, d); try testing.expectApproxEqAbs(@as(Real, 4999), wall.distance, far_tol); try testing.expect(wall.normal.approxEql(v(-1, 0, 0), 1e-4)); @@ -1404,7 +1462,7 @@ test "the capsule kernel is conditioned far from the shape" { // Top cap: the cap sphere is centred (0, 3, 0), so at y = 3.5 the chord gives // x = −√0.75 and t = 5000 − √0.75, with the normal (−√0.75, 0.5, 0). - const cap = (try narrowphase.rayShape(Real, capsule, v(-5000, 3.5, 0), d)).?; + const cap = (narrowphase.rayShape(Real, capsule, v(-5000, 3.5, 0), d)).?; try expectHitInvariants(cap, d); const x: Real = @sqrt(@as(Real, 0.75)); try testing.expectApproxEqAbs(5000 - x, cap.distance, far_tol); @@ -1412,7 +1470,7 @@ test "the capsule kernel is conditioned far from the shape" { try testing.expect(@abs(cap.normal.toArray()[1]) > 0.1); // really the cap, not the wall // And a genuine far-field MISS is still a miss: 1.5 m off a 1 m radius. - try testing.expect((try narrowphase.rayShape(Real, capsule, v(-5000, 0, 1.5), d)) == null); + try testing.expect((narrowphase.rayShape(Real, capsule, v(-5000, 0, 1.5), d)) == null); } test "any terminates the traversal, it does not merely bound it" { @@ -1445,9 +1503,9 @@ test "any terminates the traversal, it does not merely bound it" { } const q = axisQuery(100); - const closest = (try query.raycast(&world.bp, &world.bm, &world.store, q)).?; + const closest = (query.raycast(&world.bp, &world.bm, &world.store, q)).?; try testing.expectApproxEqAbs(@as(Real, 9), closest.distance, tol); - try testing.expect(try query.raycastAny(&world.bp, &world.bm, &world.store, q)); + try testing.expect(query.raycastAny(&world.bp, &world.bm, &world.store, q)); // The terminating property, measured on the traversal's own node counts. This is // the ONE place in the milestone where a visited-node count is a legitimate @@ -1552,7 +1610,7 @@ test "the far-field conditioning holds on an OBLIQUE ray, and the kernel is the // (1) Sphere, oblique in the XY plane, at 5 000 m. { const d = dir(0.6, 0.8, 0); - const hit = (try narrowphase.rayShape(Real, sphere, v(-3000.4, -3999.7, 0), d)).?; + const hit = (narrowphase.rayShape(Real, sphere, v(-3000.4, -3999.7, 0), d)).?; try testing.expect(hit.distance >= 0); try testing.expect(hit.normal.dot(d) <= 0); // The NORM: tight, no envelope. @@ -1571,7 +1629,7 @@ test "the far-field conditioning holds on an OBLIQUE ray, and the kernel is the // like coverage without being any. { const d = dir(0.6, 0, 0.8); - const hit = (try narrowphase.rayShape(Real, capsule, v(-3000.4, 0, -3999.7), d)).?; + const hit = (narrowphase.rayShape(Real, capsule, v(-3000.4, 0, -3999.7), d)).?; try testing.expectApproxEqAbs(@as(Real, 1), hit.normal.length(), unit_tol); try testing.expectEqual(@as(Real, 0), hit.normal.toArray()[1]); // radial: no Y try testing.expectApproxEqAbs(-@sqrt(@as(Real, 0.75)), hit.normal.dot(d), std.math.floatEps(Real) * 5000); @@ -1580,7 +1638,7 @@ test "the far-field conditioning holds on an OBLIQUE ray, and the kernel is the // (3) The aligned contrast: exactly 1, at both precisions. { const d = dir(1, 0, 0); - const aligned = (try narrowphase.rayShape(Real, sphere, v(-5000, 0, 0), d)).?; + const aligned = (narrowphase.rayShape(Real, sphere, v(-5000, 0, 0), d)).?; try testing.expectEqual(@as(Real, 1), aligned.normal.length()); try testing.expectEqual(@as(Real, 4999), aligned.distance); } @@ -1604,7 +1662,7 @@ test "the far-field conditioning holds on an OBLIQUE ray, and the kernel is the var checked_orientation: u32 = 0; for ([_]Real{ 1e2, 1e4, 1e5, 1e6, 2e6, 1e7, 1e8, 1e9 }) |distance| { const origin = d.scale(-distance).add(perp); - const maybe = try narrowphase.rayShape(Real, sphere, origin, d); + const maybe = narrowphase.rayShape(Real, sphere, origin, d); if (maybe == null) { std.debug.print("false negative: constructed hit missed at distance {d}\n", .{distance}); return error.ConstructedRayMissed; @@ -1694,8 +1752,8 @@ test "an exact distance tie is broken by entity, not by creation order" { // PRECONDITION 2 — the two exact distances are bit-identical, and both equal // the closed form. const ray = query.Ray.init(Vec3r.zero, v(1, 0, 0)); - const d_first = (try world.bm.raycastBody(&world.store, first, ray)).?.distance; - const d_second = (try world.bm.raycastBody(&world.store, second, ray)).?.distance; + const d_first = (world.bm.raycastBody(&world.store, first, ray)).?.distance; + const d_second = (world.bm.raycastBody(&world.store, second, ray)).?.distance; try testing.expectEqual(d_first, d_second); try testing.expectEqual(tieDistance(), d_first); @@ -1706,7 +1764,7 @@ test "an exact distance tie is broken by entity, not by creation order" { try testing.expectEqual(high_entity, world.bm.entity(if (high_first) first else second).?.index); // The closest hit is the LOW-entity body under BOTH creation orders. - const hit = (try query.raycast(&world.bp, &world.bm, &world.store, axisQuery(100))).?; + const hit = (query.raycast(&world.bp, &world.bm, &world.store, axisQuery(100))).?; try testing.expectEqual(want, hit.body); try testing.expectEqual(low_entity, hit.entity.index); try testing.expectEqual(tieDistance(), hit.distance); @@ -1714,13 +1772,13 @@ test "an exact distance tie is broken by entity, not by creation order" { // And so is the answer TRUNCATED TO ONE SLOT: `all` retains the best under // the same key, so a one-slot buffer must agree with `closest`. var one: [1]query.RayHit = undefined; - try testing.expectEqual(@as(u32, 1), try query.raycastAll(&world.bp, &world.bm, &world.store, axisQuery(100), &one)); + try testing.expectEqual(@as(u32, 1), query.raycastAll(&world.bp, &world.bm, &world.store, axisQuery(100), &one)); try testing.expectEqual(want, one[0].body); try testing.expectEqual(low_entity, one[0].entity.index); // Full buffer: the tie orders by entity, so the low-entity body comes first. var both: [2]query.RayHit = undefined; - try testing.expectEqual(@as(u32, 2), try query.raycastAll(&world.bp, &world.bm, &world.store, axisQuery(100), &both)); + try testing.expectEqual(@as(u32, 2), query.raycastAll(&world.bp, &world.bm, &world.store, axisQuery(100), &both)); try testing.expectEqual(both[0].distance, both[1].distance); try testing.expectEqual(want, both[0].body); try testing.expectEqual(low_entity, both[0].entity.index); @@ -1736,8 +1794,8 @@ test "an exact distance tie is broken by entity, not by creation order" { const a = try addSphereAt(gpa, &world, .{ 20, tie_offset, 0 }, 0, low_entity); const b = try addSphereAt(gpa, &world, .{ 20, -0.6, 0 }, 0, high_entity); const ray = query.Ray.init(Vec3r.zero, v(1, 0, 0)); - const d_a = (try world.bm.raycastBody(&world.store, a, ray)).?.distance; - const d_b = (try world.bm.raycastBody(&world.store, b, ray)).?.distance; + const d_a = (world.bm.raycastBody(&world.store, a, ray)).?.distance; + const d_b = (world.bm.raycastBody(&world.store, b, ray)).?.distance; try testing.expect(d_a != d_b); } } @@ -1772,16 +1830,16 @@ test "two bodies on the same entity fall back on BodyId" { try testing.expectEqual(world.bm.entity(first).?, world.bm.entity(second).?); const ray = query.Ray.init(Vec3r.zero, v(1, 0, 0)); - const d_first = (try world.bm.raycastBody(&world.store, first, ray)).?.distance; - const d_second = (try world.bm.raycastBody(&world.store, second, ray)).?.distance; + const d_first = (world.bm.raycastBody(&world.store, first, ray)).?.distance; + const d_second = (world.bm.raycastBody(&world.store, second, ray)).?.distance; try testing.expectEqual(d_first, d_second); // Entities equal ⇒ the smaller `BodyId` decides, which here is the // first-created body. - const hit = (try query.raycast(&world.bp, &world.bm, &world.store, axisQuery(100))).?; + const hit = (query.raycast(&world.bp, &world.bm, &world.store, axisQuery(100))).?; try testing.expectEqual(first, hit.body); var one: [1]query.RayHit = undefined; - try testing.expectEqual(@as(u32, 1), try query.raycastAll(&world.bp, &world.bm, &world.store, axisQuery(100), &one)); + try testing.expectEqual(@as(u32, 1), query.raycastAll(&world.bp, &world.bm, &world.store, axisQuery(100), &one)); try testing.expectEqual(first, one[0].body); winner_y[run] = world.bm.position(hit.body).?.toArray()[1]; diff --git a/src/modules/forge/forge_3d/tests/shapecast_test.zig b/src/modules/forge/forge_3d/tests/shapecast_test.zig index 99cd0d1..e192ab2 100644 --- a/src/modules/forge/forge_3d/tests/shapecast_test.zig +++ b/src/modules/forge/forge_3d/tests/shapecast_test.zig @@ -401,18 +401,25 @@ test "the iteration ceiling returns a hit at the current parameter" { // What the cast buys that the ray kernels cannot // --------------------------------------------------------------------------- -test "sphere cast against a box does not error, where the ray kernel refuses" { +test "sphere cast against a box answers where the ray kernel's precondition refuses" { // The discriminating test for the whole design (§1.11.11). Casting a sphere of // radius `r_a` against a box IS, in configuration space, a ray against a box - // inflated by `r_a` — a ROUNDED box, precisely the shape the ray kernels reject. - // Both facts are asserted here, in the same test, so the claim that the cast + // inflated by `r_a` — a ROUNDED box, precisely the shape the ray kernels do not + // cover. Both facts are asserted here, in the same test, so the claim that the cast // covers what the ray kernel refuses is proven rather than stated. + // + // RE-EXPRESSED at M1.1.11/E3: clause (1) was + // `expectError(error.UnsupportedShape, rayShape(...))` and is now the same + // refusal read off the kernel's asserted precondition, the error having left the + // ray path (see `raycast_test.zig`'s rounded-box test for why). The claim — the ray + // kernel refuses this shape, the cast answers it — is identical. const r_a: Real = 0.5; const direction = v(1, 0, 0); - // (1) The ray kernel refuses the rounded box, loudly. + // (1) The ray kernel does not cover the rounded box, and says so before being + // called rather than by failing inside. const rounded: SS = .{ .core = .{ .box = v(1, 1, 1) }, .radius = r_a }; - try testing.expectError(error.UnsupportedShape, narrowphase.rayShape(Real, rounded, v(-10, 0, 0), direction)); + try testing.expect(!narrowphase.raySupportsShape(Real, rounded)); // (2) The cast answers the same geometry with no error channel at all. A sphere // of radius 0.5 sweeping +X against a unit box at (10, 0, 0): its centre must @@ -821,7 +828,7 @@ test "shapeCast returns the nearest body along the sweep" { .direction = v(1, 0, 0), .max_distance = 100, }; - const hit = query_mod.shapeCast(&world.bp, &world.bm, &world.store, q).?; + const hit = (try query_mod.shapeCast(&world.bp, &world.bm, &world.store, q)).?; try testing.expectEqual(near, hit.body); try testing.expectApproxEqAbs(@as(Real, 8), hit.distance, tol); try testing.expect(hit.normal.approxEql(v(-1, 0, 0), tol)); @@ -832,18 +839,18 @@ test "shapeCast returns the nearest body along the sweep" { // it — the bound being CLOSED, exactly 8 still answers. var away = q; away.direction = v(-1, 0, 0); - try testing.expect(query_mod.shapeCast(&world.bp, &world.bm, &world.store, away) == null); + try testing.expect(try query_mod.shapeCast(&world.bp, &world.bm, &world.store, away) == null); var short = q; short.max_distance = 7.9; - try testing.expect(query_mod.shapeCast(&world.bp, &world.bm, &world.store, short) == null); + try testing.expect(try query_mod.shapeCast(&world.bp, &world.bm, &world.store, short) == null); var exact = q; exact.max_distance = 8; - try testing.expect(query_mod.shapeCast(&world.bp, &world.bm, &world.store, exact) != null); + try testing.expect(try query_mod.shapeCast(&world.bp, &world.bm, &world.store, exact) != null); // A zero direction is degenerate and empty, the ray family's guard reused. var still = q; still.direction = Vec3r.zero; - try testing.expect(query_mod.shapeCast(&world.bp, &world.bm, &world.store, still) == null); + try testing.expect(try query_mod.shapeCast(&world.bp, &world.bm, &world.store, still) == null); } // --------------------------------------------------------------------------- @@ -884,12 +891,12 @@ test "the normal at the time of impact is the outward normal of the hit body" { const dirs = [_]Vec3r{ v(1, 0, 0), v(0, -1, 0), v(0, 0, 1), v(3, 4, 0).normalize() }; for (dirs) |unit_d| { const start = unit_d.scale(-10); - const hit = query_mod.shapeCast(&world.bp, &world.bm, &world.store, .{ + const hit = (try query_mod.shapeCast(&world.bp, &world.bm, &world.store, .{ .shape = probe, .origin = start, .direction = unit_d, .max_distance = 100, - }).?; + })).?; try testing.expectEqual(body, hit.body); try testing.expectApproxEqAbs(@as(Real, 7), hit.distance, tol); @@ -939,12 +946,12 @@ test "initial contact returns a witness on the hit body" { Quatr.identity, ).?); - const hit = query_mod.shapeCast(&world.bp, &world.bm, &world.store, .{ + const hit = (try query_mod.shapeCast(&world.bp, &world.bm, &world.store, .{ .shape = bar, .origin = cast_origin, .direction = v(1, 0, 0), .max_distance = 100, - }).?; + })).?; try testing.expectEqual(body, hit.body); try testing.expectEqual(@as(Real, 0), hit.distance); // THE CLAIM: the witness is a point OF THE HIT BODY, boundary included. @@ -972,22 +979,22 @@ test "shapeCast honours the object mask and the exclusion list" { .max_distance = 100, }; // The FULL mask sees the near one. - try testing.expectEqual(near, query_mod.shapeCast(&world.bp, &world.bm, &world.store, base).?.body); + try testing.expectEqual(near, (try query_mod.shapeCast(&world.bp, &world.bm, &world.store, base)).?.body); // A mask naming only layer 7 skips it and answers the far one. var only_far = base; only_far.filter = .{ .layer_mask = @as(u32, 1) << 7 }; - try testing.expectEqual(far, query_mod.shapeCast(&world.bp, &world.bm, &world.store, only_far).?.body); + try testing.expectEqual(far, (try query_mod.shapeCast(&world.bp, &world.bm, &world.store, only_far)).?.body); // The EMPTY mask sees nothing at all. var none = base; none.filter = .{ .layer_mask = 0 }; - try testing.expect(query_mod.shapeCast(&world.bp, &world.bm, &world.store, none) == null); + try testing.expect(try query_mod.shapeCast(&world.bp, &world.bm, &world.store, none) == null); // Exclusions are tested on the body, upstream of the kernel. var without_near = base; without_near.filter = .{ .exclude = &.{near} }; - try testing.expectEqual(far, query_mod.shapeCast(&world.bp, &world.bm, &world.store, without_near).?.body); + try testing.expectEqual(far, (try query_mod.shapeCast(&world.bp, &world.bm, &world.store, without_near)).?.body); var without_both = base; without_both.filter = .{ .exclude = &.{ near, far } }; - try testing.expect(query_mod.shapeCast(&world.bp, &world.bm, &world.store, without_both) == null); + try testing.expect(try query_mod.shapeCast(&world.bp, &world.bm, &world.store, without_both) == null); } test "shapeCast answers a sleeping body and leaves it asleep" { @@ -998,12 +1005,12 @@ test "shapeCast answers a sleeping body and leaves it asleep" { const centre = world.bm.position(sleeper).?; const probe = try world.store.createShape(gpa, .{ .sphere = .{ .radius = 0.25 } }); - const hit = query_mod.shapeCast(&world.bp, &world.bm, &world.store, .{ + const hit = (try query_mod.shapeCast(&world.bp, &world.bm, &world.store, .{ .shape = probe, .origin = v(centre.toArray()[0], 10, centre.toArray()[2]), .direction = v(0, -1, 0), .max_distance = 100, - }).?; + })).?; try testing.expectEqual(sleeper, hit.body); try testing.expect(world.bm.isSleeping(sleeper).?); } @@ -1031,9 +1038,9 @@ test "shapeCast is invariant under creation-order permutation and bit-identical .max_distance = 100, }; - const hit = query_mod.shapeCast(&world.bp, &world.bm, &world.store, q).?; + const hit = (try query_mod.shapeCast(&world.bp, &world.bm, &world.store, q)).?; // Two identical runs in the same world are bit-identical. - const again = query_mod.shapeCast(&world.bp, &world.bm, &world.store, q).?; + const again = (try query_mod.shapeCast(&world.bp, &world.bm, &world.store, q)).?; try expectCastBitIdentical(hit, again); // The ENTITY, not the `BodyId`: the latter follows creation order by @@ -1101,3 +1108,88 @@ pub fn sleepingBox(gpa: std.mem.Allocator, world: *harness.World) !api.BodyId { try testing.expect(world.bm.isSleeping(sleeper).?); return sleeper; } + +// --------------------------------------------------------------------------- +// M1.1.11 / E3 — the three-way outcome of the two entries taking a shape handle +// --------------------------------------------------------------------------- + +test "shapeCast separates a stale handle, an inadmissible probe and a miss" { + const gpa = std.testing.allocator; + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + + // THREE outcomes a single `null` used to conflate (`engine-physics-forge.md` + // §1.11.7). MEASURED on the pre-E3 tree, which is `main` for this entry: a stale + // probe handle returned `null` and a live probe aimed at empty space returned + // `null` — byte-identical, so no caller could tell a malformed query from a + // negative answer. That is the silent-false-negative class §1.11.3 forbids by + // name. + const probe = try world.store.createShape(gpa, .{ .sphere = .{ .radius = 0.5 } }); + const plane = try world.store.createShape(gpa, .{ .plane = .{} }); + const doomed = try world.store.createShape(gpa, .{ .sphere = .{ .radius = 0.5 } }); + world.store.destroyShape(doomed); + _ = try addSphereBody(gpa, &world, .{ 10, 0, 0 }, 1); + + // (1) STALE HANDLE → a typed error. The shape the caller named is gone. + try testing.expectError(error.InvalidShape, query_mod.shapeCast(&world.bp, &world.bm, &world.store, .{ + .shape = doomed, + .origin = Vec3r.zero, + .direction = v(1, 0, 0), + .max_distance = 100, + })); + + // (2) INADMISSIBLE PROBE → a distinct typed error. The cast kernel is a ray march + // on the Minkowski difference of the two CORES, and a half-space has no bounded + // core to difference: it is not a probe the kernel can express at all. Returning + // an empty answer would tell the caller "nothing was hit", which is false. + try testing.expectError(error.UnsupportedShape, query_mod.shapeCast(&world.bp, &world.bm, &world.store, .{ + .shape = plane, + .origin = Vec3r.zero, + .direction = v(1, 0, 0), + .max_distance = 100, + })); + + // (3) REAL MISS → `null`, and only now. A live sphere probe swept +Y, away from + // the only body in the scene at (10, 0, 0). + try testing.expectEqual(@as(?query_mod.CastHit, null), try query_mod.shapeCast(&world.bp, &world.bm, &world.store, .{ + .shape = probe, + .origin = Vec3r.zero, + .direction = v(0, 1, 0), + .max_distance = 100, + })); + + // And the positive control, so the three refusals above are not the entry + // refusing everything: the same probe swept +X reaches the body. A radius-0.5 + // sphere against a unit sphere at x = 10 first touches at 10 − 1 − 0.5 = 8.5. + const hit = (try query_mod.shapeCast(&world.bp, &world.bm, &world.store, .{ + .shape = probe, + .origin = Vec3r.zero, + .direction = v(1, 0, 0), + .max_distance = 100, + })).?; + try testing.expectApproxEqAbs(@as(Real, 8.5), hit.distance, tol); + + // PRECEDENCE, pinned: an inadmissible probe OUTRANKS a degenerate direction. Both + // conditions hold at once here — a plane handle AND a zero direction — and the + // answer is `error.UnsupportedShape`, never `null`. Until this assertion the order + // was correct in the code and argued in a comment, with nothing holding it: swapping + // the two lines would have turned a malformed probe into an ordinary empty answer + // and no test would have noticed. + try testing.expectError(error.UnsupportedShape, query_mod.shapeCast(&world.bp, &world.bm, &world.store, .{ + .shape = plane, + .origin = Vec3r.zero, + .direction = Vec3r.zero, + .max_distance = 100, + })); + + // A ZERO DIRECTION stays a MISS, not an error (§1.11.11 domain table): the query + // is degenerate, its answer is empty, and the probe is perfectly well formed. The + // distinction matters — folding it into the error channel would make a legal + // query look malformed. + try testing.expectEqual(@as(?query_mod.CastHit, null), try query_mod.shapeCast(&world.bp, &world.bm, &world.store, .{ + .shape = probe, + .origin = Vec3r.zero, + .direction = Vec3r.zero, + .max_distance = 100, + })); +} diff --git a/src/modules/forge/forge_3d/tests/solver_test.zig b/src/modules/forge/forge_3d/tests/solver_test.zig index fd9af75..4fb208d 100644 --- a/src/modules/forge/forge_3d/tests/solver_test.zig +++ b/src/modules/forge/forge_3d/tests/solver_test.zig @@ -161,10 +161,29 @@ pub const World = struct { } /// Create a body and insert its broadphase proxy on the matching layer. + /// + /// **Dispatches on the shape CLASS since M1.1.11.** A half-space has no world AABB, + /// so `bodyAabb` asserts on one and there is no box to hand `insert`: an unbounded + /// shape goes into the layer's flat list instead, carrying the half-space transported + /// into WORLD space, which is the frame the broadphase's corner predicate works in + /// (`engine-physics-forge.md` §1.11.15). Exhaustive on the class, no `else`. pub fn addBody(self: *World, gpa: std.mem.Allocator, desc: api.BodyDescriptor) !BodyId { const id = try self.bm.addBody(gpa, &self.store, desc); - const aabb = self.bm.bodyAabb(&self.store, id).?; - const proxy = try self.bp.insert(gpa, broadphaseLayer(desc.body_type), aabb, id); + const layer = broadphaseLayer(desc.body_type); + const shape = self.store.get(desc.shape).?; + const proxy = switch (shape.class()) { + .convex => try self.bp.insert(gpa, layer, self.bm.bodyAabb(&self.store, id).?, id), + .half_space => blk: { + const world = shape_mod.halfSpace(shape).transformed( + self.bm.rotation(id).?, + self.bm.position(id).?, + ); + break :blk try self.bp.insertUnbounded(gpa, layer, .{ + .normal = world.normal, + .distance = world.distance, + }, id); + }, + }; try self.bodies.append(gpa, .{ .id = id, .proxy = proxy }); return id; } @@ -264,6 +283,11 @@ pub const World = struct { for (self.bodies.items) |b| { const sleeping = self.bm.isSleeping(b.id) orelse continue; // stale handle if (sleeping) continue; // a sleeper's AABB is unchanged by construction + // An UNBOUNDED proxy has no box to update and cannot move: a half-space + // forces a STATIC body, so its pairs are established once at insertion and + // then carried by the retention rule of step 2 (§1.11.15). `bp.update` + // asserts this rather than absorbing it, so the skip is explicit here. + if (b.proxy.kind == .unbounded) continue; if (self.bm.bodyAabb(&self.store, b.id)) |aabb| try self.bp.update(gpa, b.proxy, aabb); }