# Physics engine — state of play

Tracker for what's built, what's pending, and what's deferred.

---

## Context

Deterministic JS rigid-body physics engine for the meep ECS. Target: game
scenarios with up to millions of mostly-sleeping bodies, deterministic replays
for netcode and reproducible debugging, broad shape coverage for common game
collisions. Pure JS — no WASM, no SIMD, no worker threads.

Architectural references for design choices:
- **Jolt** — pre-allocated body pool, active-list iteration, two-tree
  broadphase (static + dynamic).
- **Bullet** — `btPersistentManifold` cache layout with up to 4 points.
- **Box2D / Catto** — sequential impulse with warm-starting, Sutherland-Hodgman
  face clipping for box-box.

---

## Done

### Foundations
- `RigidBody`, `Collider`, `BodyKind`, `RigidBodyFlags`, `ColliderFlags`,
  `SleepState`, `PhysicsEvents`.
- `BodyStorage`: SoA pool, generation-tracked stable IDs, dense awake list,
  min-heap free for deterministic ID reuse.
- `PhysicsSystem`: full public API surface (gravity, force/impulse with and
  without application point, torque, velocity setter, wake/sleep, contact
  filter callback).
- Binary serialization adapters for `RigidBody` and `Collider` (transient
  runtime state deliberately excluded).
- `PairUint32Map`: open-addressed Robin Hood + Fibonacci hash for the
  pair → manifold-slot index (the one new collection added to `core/collection/`).

### Pipeline (`PhysicsSystem.fixedUpdate`)
1. Velocity integration (semi-implicit Euler, linear + angular, gravity,
   damping, world-frame inverse-inertia for torque)
2. Per-collider broadphase refit with fat AABB (Box2D-style velocity-padded
   slack)
3. Pair generation: per-leaf query against both BVHs (static + dynamic),
   canonical `(min, max)` pairs, dedup via manifold touched flag
4. Wake propagation for sleeping bodies in the pair list
5. Narrowphase cross-product over collider lists
6. Sequential-impulse solver (Catto-style, warm-start, friction, Baumgarte)
7. Position integration (linear + quaternion)
8. Sleep test (per-body velocity² below threshold for ≥ 0.5 s)
9. Manifold diff → `ContactBegin` / `Stay` / `End` event dispatch
10. `manifolds.advance_frame()` — roll touched bits, evict grace-expired slots

### Shape coverage
| Pair | Path | Manifold |
|---|---|---|
| sphere-sphere | closed-form | 1 point |
| sphere-box | closed-form (handles centre-inside-box) | 1 point |
| capsule-sphere | point-on-segment closed-form | 1 point |
| capsule-capsule | segment-segment closest pair | 1 point |
| capsule-box | iterative segment-vs-OBB (primary) + cap-centre sphere-vs-OBB at each endpoint | up to 3 |
| box-box face-face | SAT + Sutherland-Hodgman clipping | up to 4 |
| box-box edge-edge | SAT + segment-segment closest-pair | 1 point |
| sphere / box / capsule × concave (heightmap, mesh) | closed-form `*_triangle_contact` per triangle via decomposition dispatcher | up to a few points per triangle (deepest wins) |
| other convex × concave | per-triangle GJK + EPA via decomposition dispatcher | 1 point per triangle |
| anything else | GJK + EPA, MPR fallback on EPA non-convergence | 1 point |

### Non-convex shapes
- **`is_convex` flag** on `AbstractShape3D.prototype` (default `true`).
  Overridden to `false` on `HeightMapShape3D`, `MeshShape3D`, `UnionShape3D`.
  `TransformedShape3D` inherits via getter that reads the wrapped subject.
- **`HeightMapShape3D`** — orientation-vector + `Sampler2D`-backed terrain
  shape. Heights sampled via `sampleChannelCatmullRomUV` (matching the
  terrain system's geometry construction). Compute_bounding_box,
  contains_point, signed_distance, nearest_point_on_surface all
  implemented; `support` throws (non-convex by construction).
- **`Triangle3D`** — buffer-flyweight convex shape. `bind(buffer, offset)`
  repoints at 9 consecutive floats in an external Float64Array. Zero
  allocation per emission; used by the decomposition path.
- **Triangle decomposition machinery** under
  `engine/physics/narrowphase/decomposition/`:
    - `TRIANGLE_FLOAT_STRIDE = 10` per triangle (`vA.xyz`, `vB.xyz`,
      `vC.xyz`, `feature_id`).
    - `heightmap_enumerate_triangles(out, offset, shape, ...aabb)` —
      Arvo-projects the convex's AABB into heightmap-local, intersects
      with the footprint to derive a cell range, emits 2 triangles per
      cell with stable feature_ids.
    - `mesh_enumerate_triangles(out, offset, shape, ...aabb)` — linear
      O(N) scan over `MeshShape3D.indices` with tight per-triangle AABB
      filtering. feature_id = triangle index.
    - `aabb_world_to_local(out, world_aabb, pos, rot)` — 8-corner
      projection of a world AABB into a body's local frame.
    - `decompose_to_triangles(...)` — dispatcher switching on shape
      type marker.
- **Narrowphase concave dispatch** in `narrowphase_step.js`: detects
  `is_convex === false`, computes convex's world AABB, projects to
  concave's local frame, decomposes, per-triangle GJK + EPA with
  one-sided face-normal rejection and contact-normal dedup. Concave-vs-
  concave dynamic pairs are explicitly refused.

### Solver
- Sequential impulse with warm-starting (10 velocity iterations by default).
- Coulomb friction with disk-clamped tangent impulses.
- Baumgarte position correction folded into the velocity solve.
- Full angular Jacobian (`I_w⁻¹ = R · diag · R^T`) and angular impulse
  application.
- Public force/impulse-at-point API (`applyForceAt`, `applyImpulseAt`,
  `applyTorque`).

### Sleep + events
- Per-island **atomic sleep**: an island sleeps when `max(|v|² + |ω|²)`
  across all members stays below the threshold long enough; the whole
  island sleeps in the same frame. Replaces the per-body chatter on
  weakly-connected piles.
- **Atomic wake**: members of a sleeping island are threaded into a
  circular doubly-linked list (`sleep_group_next` / `sleep_group_prev`);
  waking any one member walks the chain and wakes the rest in the same
  call. A 100-block stack hit at the base wakes top-down in one frame
  rather than over 100 frames of broadphase propagation.
- `DisableSleep` on any island member exempts the whole island.
- ContactBegin / Stay / End buffer + dispatch through the per-entity
  `dataset.sendEvent(entity, PhysicsEvents.ContactBegin, ...)` channel — the
  sole contact-event path, delivered to each entity of the pair when a
  dataset is attached.

### Islands
- **Union-find** with path halving + union by min-index over the awake-body
  + touched-contact graph (`engine/physics/island/union_find.js`).
- **`IslandBuilder`** produces deterministic CSR-style output: bodies and
  manifold slots grouped by island, sorted ascending within and across
  islands. Static / kinematic bodies are constraint anchors only — they
  don't merge islands, so disjoint piles on the same floor are separate
  islands.
- **Islands feed the sleep test + grouping, not a per-island solver loop.**
  The TGS contact solver flattens every island's contacts into one
  Gauss-Seidel sweep (`solver/solve_contacts.js`) — islands share no bodies,
  so a single flat sweep is identical to per-island sweeps. The partition is
  still rebuilt every step and consumed by the atomic-island sleep test and the
  joint/contact island grouping; it is also the natural unit for a future
  worker-based parallel solve (see Performance / Scale). (Earlier revisions ran
  the solver per island; the TGS rewrite flattened it — same result, simpler
  loop.)

### Compound bodies
- A body has 0..N attached colliders. Each collider has its own world
  transform and its own BVH leaf.
- Same-entity colliders, child-entity colliders (via `ParentEntity`), or
  hybrids all supported.
- `ColliderObserverSystem` auto-attaches colliders via the dataset when
  paired with `PhysicsSystem` in an EntityManager.
- Narrowphase runs the cross-product over both bodies' collider lists per
  body-pair, accumulates candidates, reduces to ≤4 contacts by
  depth + spread.

### Public queries
- `raycast(origin, dir, max_dist, filter?)` — nearest hit across both trees,
  **refined to the true shape surface** (narrowphase). `result.t` /
  `result.normal` are exact for sphere / box / capsule / mesh / heightmap
  colliders (per-leaf analytic ray tests + triangle Möller–Trumbore for
  concave); composite convex shapes fall back to the broadphase AABB hit. A ray
  crossing a fat leaf AABB but missing the true shape is correctly a miss.
- `shapeCast(ray, shape, rotation, result, filter?)` — broadphase swept
  AABB against both BVHs; per-candidate AABB-slab interval narrowing,
  coarse step over the narrowed window, GJK bisection to time-of-impact.
  Output normal is the true contact-surface normal at the kiss point,
  recovered by re-running GJK + EPA at `best_t` on the winning candidate.
  Falls back to `-ray.direction` only on EPA degeneracies (NaN / zero
  depth). Tests cover axis-aligned, off-axis, and oblique cube-vs-cube;
  sphere-vs-smooth-shape near-tangent has documented angular tolerance
  bands inherited from EPA on smooth supports.
- `overlap(shape, position, rotation, output, output_offset, filter?)`
  — broadphase + per-candidate GJK overlap detection. Writes body_ids
  into a caller-sized buffer; returns count. Convex query shapes only
  (concave throws). Concave candidates routed through the per-triangle
  decomposition path. Designed for speculative kinematic queries on
  kinematic bodies (character controllers, AOE selection).

### Standalone narrowphase utilities
- `deepest_pair_penetration(out_normal, shapeA, posA, rotA, shapeB, posB,
  rotB)` (exported from `narrowphase_step.js`) — runs the **same**
  `dispatch_pair` the contact solver consumes for one posed shape pair and
  returns the DEEPEST contact's depth + world normal (B → A). The single
  source of truth for "minimum-translation between two posed shapes", reused by
  `compute_penetration` (and available to any other query).
- `compute_penetration(out_direction, shape_a, pos_a, rot_a, shape_b,
  pos_b, rot_b)` — non-system geometry primitive: positive penetration
  depth + outward direction (B → A convention) on overlap, 0 otherwise.
  **Hardened** — delegates to `deepest_pair_penetration`, so it is correct
  (not "correct sometimes") for every shape pair the engine can build:
    - sphere / box / capsule pairs → exact closed-form (box-box via SAT, so a
      small body resting on a large floor reports the few-cm near-face overlap,
      NOT the metres-deep "exit through the far side" that MPR's centroid-seeded
      portal used to return);
    - general convex pairs → GJK + EPA (exact for polytopes; curved shapes never
      reach it — they have closed forms);
    - convex × concave → triangle decomposition + the closed-form per-triangle
      solvers, bounded to each triangle's true 2-D extent (the old closed-mesh
      side-face over-report is gone).
  The previous per-triangle half-space test is retained ONLY as a recovery
  fallback for the one case the one-sided closed forms can't resolve: a convex
  shape that has fully tunnelled to the *inner* side of a concave surface (a
  depenetration query must still push it back out — exact for heightmap terrain,
  a valid outward push for closed meshes). Concave × concave throws (M×N
  triangle pairs out of scope). The spec asserts an "applying out_direction ×
  depth separates the shapes" invariant across every convex+convex pair type and
  convex+concave, plus exact per-type depths and the small-box-on-huge-floor
  regression (3 m → 0.05 m).

### Determinism
- Direct typed-array writes on hot paths (bypassing `Vector3#set`'s observer
  dispatch) — Transform writes still go through `set()` because external
  systems subscribe (TransformAttachment, EntityNode, FogOfWarRevealer,
  ViewportPosition).
- Active body iteration sorted by body index.
- Pair canonicalisation `(min, max)`.
- Min-heap free list for slot reuse.
- No `Math.random` anywhere in the simulation step.
- Same-runtime bit-exact determinism by design; cross-runtime is a known
  future seam.

### Migration
- `Motion` / `MotionSystem` / `MotionSerializationAdapter` relocated from
  the meep core (`engine/ecs/`) to the game-domain layer
  (`mir-engine/model/game/ecs/`). meep no longer ships the legacy shim.

### Alternative narrowphase: MPR
- `engine/physics/gjk/mpr.js` — Minkowski Portal Refinement (XenoCollide,
  Snethen GDC 2009). Single-pass overlap test + MTV computation,
  output convention matches EPA so it's drop-in compatible at any
  narrowphase call site. Tends to converge in 5–15 iterations on
  smooth shapes where EPA stalls (the polytope-on-curved-surface
  failure mode the torus-knot reproducer exercised). **Wired as the EPA
  non-convergence fallback** in `narrowphase_step` at both the body-level
  and per-triangle GJK+EPA paths: when EPA returns a non-positive / non-finite
  depth, MPR is tried before giving up. `shape_cast` and `compute_penetration`
  use it for the same reason.

### Bonus utilities
- `core/geom/3d/line/line3_closest_points_segment_segment.js` — generally
  useful 3D segment-segment closest-pair via Ericson §5.1.9.
- `core/collection/PairUint32Map.js` — non-allocating
  `Map<(u32, u32) → u32>` with Robin Hood + Fibonacci hash.

---

## Limitations / Known caveats

- **Multi-collider material precision** — *resolved for contact materials.* The
  narrowphase now combines the specific source-collider pair's friction /
  restitution per contact and stores them in the manifold (CONTACT_STRIDE
  offsets 14/15); the solver reads them per contact, so mixed-material compound
  bodies are accurate (regression test: an asymmetric-friction body yaws when
  shoved). Still primary-collider only: the contact-filter callback's
  `colliderA/B` arguments and the body-level sensor / concave-dispatch flags —
  a smaller follow-up.
- **EPA on smooth shapes**: degenerates (no flat face to converge on).
  Mitigated by closed-form paths for sphere/cube/capsule pairs and by the
  **MPR fallback** on EPA non-convergence; exotic convex shapes vs spheres can
  still occasionally fail if both EPA and MPR degenerate.
- **EPA on `Triangle3D`** — *resolved.* The concave dispatch now uses the
  closed-form `sphere_triangle_contact` / `box_triangle_contact` /
  `capsule_triangle_contact` solvers (P1.1a–c) instead of per-triangle GJK+EPA
  for those primitives, so a sphere/box/capsule on a heightmap or mesh decelerates
  and settles correctly; the `narrowphase_concave.spec.js` "drop and settle"
  cases and the mesh torus-knot settle test are **un-skipped**. Per-triangle
  GJK+EPA remains only as the fallback for *other* convex shapes vs triangles.
  (`compute_penetration` now routes through that same dispatch via
  `deepest_pair_penetration` — see *Standalone narrowphase utilities* — instead
  of its old half-space pre-test; the half-space test survives only as a
  tunnel-recovery fallback.)
- **Box-box edge-edge contact**: a single point at the true closest-pair of the
  two edges (P3.2), not the old body-centre midpoint. This is geometrically
  correct — and an empirical SAT-source sweep confirms the edge-cross branch
  *only* fires for **transverse** edge crossings (inter-edge angle ≈ 83-90°),
  where two skew lines meet at a unique point. Near-parallel edge contacts
  cannot reach this branch (a near-parallel `edgeA × edgeB` never wins the SAT
  minimum) — they resolve through the multi-point face-clipping path. So the
  once-planned "multi-point edge contact for near-parallel edges" refinement is
  **moot**; see the resolved Stability backlog entry.
- **CCD floor only**: speculative margin via the fattened AABB prevents
  most tunnelling. No per-body swept shape-cast for very fast objects.
- **Cross-runtime determinism is not guaranteed**: `Math.sin/cos/exp/log`
  are ULP-correct but not bit-exact across V8 / SpiderMonkey / JSC.
- **Dynamic concave bodies under TGS** — *resolved by per-substep re-detection
  (below); kept here for the rationale.* The substep loop normally re-derives
  contact geometry analytically from the per-triangle contact feature (witness
  anchors + normal) captured once by narrowphase and held fixed for the whole
  outer step. For a convex body the contact feature is stable under the small
  per-step motion, so this is exact; for a *dynamic concave mesh body* (e.g. a
  torus knot rocking on its own lobes) the supporting triangle itself changes
  as the body rocks, so freezing the feature would pump a little energy in and
  the body would rock / slowly sink instead of settling. Note this is NOT a
  contact-precision issue —
  the knot already uses the exact closed-form box-triangle solver (P1.1b);
  the problem is purely that TGS freezes *which* feature is in contact across
  substeps. The common concave case — a convex dynamic body on static concave
  terrain — is unaffected (the convex side's feature is stable), and that is
  the only concave case the engine targets.

  **Interim fix (implemented): per-substep concave re-detection.** For
  contact pairs involving a concave body, the substep loop re-runs the
  concave narrowphase geometry at the current substep pose (instead of the
  analytic refresh that freezes the feature) and re-prepares those contacts
  from the fresh witness/normal/depth — so the contact normal tracks the
  rocking body and no energy is pumped in. Convex pairs keep the cheap
  analytic refresh. This is ~Nx narrowphase cost on concave-involved pairs
  (acceptable — they're rare), gated by collider convexity. Un-skips the
  torus-knot dynamic-settle test.

  **Better long-term fix: convex collision proxies (not raw concave).** Every
  major engine (Box2D, Jolt, PhysX, Rapier) requires dynamic bodies to be
  convex or convex-decomposed; raw concave meshes are static-only. The right
  granularity is a *few* convex pieces — NOT the thousands of tets a
  volumetric mesher produces (tet count ≈ collider/BVH-leaf count, which
  explodes the broadphase for an awake body; tet meshing is for a future
  FEM/soft-body subsystem, not rigid collision). See the "Convex collision
  proxies for dynamic concave bodies" backlog item — a 3D convex hull builder
  (single-hull proxy covers most dynamic objects) plus an optional
  few-hull (V-HACD-style) decomposition. Those supersede the interim
  per-substep re-detection once built.

---

## Backlog (planned, in scope)

### Solver quality (next major work)

These items move the engine from "competent" to "great". TGS is the next
significant solver-architecture change; joints come after, once the TGS
scaffolding is in place.

- **TGS (Temporal Gauss-Seidel) substepping with split-impulse** — Phases
      1–3 **LANDED**. The solver is now a staged TGS pipeline
      (`solver/solve_contacts.js`: `prepare_contacts` → per substep
      [`refresh_contacts` → `warm_start_contacts` → `solve_velocity` →
      `solve_position`] → `apply_restitution`), driven by the substep loop in
      `PhysicsSystem.fixedUpdate`. Defaults: `substeps = 4`,
      `velocityIterations = 4`, `positionIterations = 1` (all fields on
      `PhysicsSystem`).
        - **Phase 1 — split impulse.** Position correction runs on a per-body
          pseudo-velocity (`__pseudo_velocity`) folded into the pose by
          `integrate_position` and discarded; depth correction never
          contaminates persistent velocity.
        - **Phase 2 — one-shot restitution.** Velocity pass is pure
          non-penetration; restitution is a single post-loop pass driving
          `vn → -e·vn_approach`, gated on a running max normal impulse
          (`maxNormalImpulse`) so transient collisions still bounce under
          per-substep warm-start.
        - **Phase 3 — substep loop.** `substeps` sub-iterations at `h = dt/N`.
          Forces consumed once at full `dt` before the loop; gravity applied
          per substep; **warm-start replayed per substep** (the crux — a
          per-substep impulse balances one substep of gravity, so resting
          stacks hold at zero velocity). Contact geometry is re-derived
          **analytically** each substep from frozen local witness anchors +
          the trusted prepare-time depth (a sign-robust delta), so narrowphase
          runs **once** per outer step — cheaper than the originally-planned
          per-substep match-and-merge refresh, and exact for convex
          primitives whose contact feature is stable under small motion.

      Results vs the single-step solver: a 100:1 mass ratio now stacks
      instead of crushing through (regression test added); 8-cube stacks
      settle to zero velocity and sleep (were impossible long-term under SI);
      falling-tower bench cost unchanged (~48 ms/1000 active bodies);
      `substeps = 1` reproduces the single-step result bit-for-bit-ish
      (one-frame restitution delay aside).

      **Hard-won lessons (for REVIEW_002):**
        - Warm-start MUST be per-substep, not once. Replaying a full-frame
          impulse once while gravity arrives per substep over-pushes resting
          contacts and *explodes* deep stacks. Per-substep warm-start +
          per-substep gravity cancel exactly at rest.
        - Restitution must gate on the *running max* normal impulse, not the
          end-of-loop value — per-substep warm-start relaxes a transient
          contact's `j_n` back to ~0 by the end, which would suppress the
          bounce.
        - Analytic separation re-derivation beats per-substep narrowphase
          for convex shapes (cheaper, no manifold-lifecycle churn) but is
          only as good as the frozen normal — see the concave caveat below.

      Follow-ups since the core landed:
        - [x] **Box-box SAT reference tie-break deadband** — aligned cube
          stacks (4–10 high) now settle to zero velocity and sleep; the
          reference-face flip-flop that creeped/toppled them is gone.
        - [x] **Per-substep contact re-detection for concave pairs** — lifts
          the dynamic-concave-body limitation; the torus-knot dynamic-settle
          test is un-skipped. Concave pairs re-run narrowphase geometry each
          substep (`redetect_concave_contacts`); convex pairs keep the cheap
          analytic refresh.

      Remaining (Phases 4–6) — now complete:
        - [x] Regression coverage: heavy-on-light pyramid (10× capstone on two
          light cubes settles + sleeps) and a ragdoll-stub (shoulder
          ball-socket + elbow hinge arm hangs, stays articulated, settles).
        - [x] REVIEW_002 retrospective — `engine/physics/REVIEW_002.md`.

      References: Catto 2018 ("Soft Constraints" GDC talk + the TGS
      follow-up); Box2D v3 source (`b2ApplyRestitution`, the substep solver
      stages); Rapier as the closest architectural sibling.

- [x] **Constraints / joints — DONE (phases 1–7 below).** One configurable
      6-DOF joint (lock/free/limit/motor/spring + swing-twist cone-twist) plus
      the raycast vehicle. Covers chains/ropes, ragdolls, vehicles (incl.
      suspension), and the mechanical set (doors, pistons, welds, sliders,
      powered hinges/wheels). The design rationale below is kept as history; the
      phasing checklist records what landed. Solver/joint retrospective in
      `REVIEW_003.md`.

      Original framing (now satisfied): TGS unblocked it (joint-chain
      convergence is a TGS sweet spot), warm-start + per-substep + island
      machinery was reusable, and the SPOOK compliance dial gave spring
      constraints essentially for free.

      **Foundational work (do first): generalise the solver to constraint
      rows.** Today `solver/solve_contacts.js` is hard-coded to the
      contact-shape constraint (normal + 2 friction tangents, ≥0 clamp,
      restitution, penetration bias). Joints are equality / inequality
      constraints on relative velocity at anchors, generally bilateral
      (impulse may be ±) with optional limits and motors. The clean shape —
      and what Jolt / Box2D-v3 do — is a **generic constraint row**: a
      Jacobian (linear + angular parts per body), an effective mass, a bias
      (position error × SPOOK gain, or motor target), and impulse bounds
      `[lo, hi]` (`[0,∞)` for a contact/limit, `(−∞,∞)` for an equality,
      `[−maxForce·h, +maxForce·h]` for a motor). Each joint type just fills
      in its rows; the existing per-body impulse-apply primitive
      (`apply_impulse_to_body` + `world_inverse_inertia_apply`), the
      per-substep warm-start, the islands, and the split-impulse / SPOOK
      position handling are all reused. Contacts become *one* constraint
      type among several rather than the hard-coded path.

      The specific constraint set, its use-case mapping, and per-type
      architecture-fit assessment are under review (see the constraints
      sketch). High level: ball-socket / distance / spring / weld and the
      grab constraint are near drop-ins on the row machinery; hinge /
      prismatic / cone-twist / motors / limits add angular-row + bounded-row
      mechanics (still within the impulse framework); raycast vehicles,
      conveyor surface-velocity, and gear/pulley coupling are higher-level
      systems or contact modifiers that sit *on top of* the primitives
      rather than being generic rows.

      **Decision: build ONE configurable 6-DOF constraint** (PhysX D6 / Jolt
      SixDOF), implemented mode-by-mode. The `Joint` ECS component carries
      `dofMode[6]` (3 linear, 3 angular) each `{locked|free|limited|spring|
      motor}` + per-DOF limit/spring/motor config + warm-start accumulators.
      Concrete joints are configs, not new code (ball-socket = lock 3 linear;
      hinge = lock 3 linear + 2 angular; weld = lock 6; cone-twist = lock 3
      linear + limit 3 angular; suspension = spring 1 linear + lock rest).

      Phasing:
        1. [x] Constraint-row solver as a **parallel row set** in the TGS
           substep loop (contacts left untouched, not ported — lower risk).
           `constraint/solve_constraints.js` reuses `world_inverse_inertia`,
           per-substep warm-start, and the SPOOK position bias; `Joint`
           component + `link_joint`/`unlink_joint` in PhysicsSystem;
           `jointIterations` knob. Bodies need no collider.
        2. [x] **LOCKED linear DOFs → ball-socket.** Pendulum (anchor pinned
           to a world pivot, body swings) and a 2-link chain (body↔body,
           joints stay connected, chain hangs) pass. → **chains, ropes,
           pendulums working.**
        3. [x] LOCKED angular + linear DOFs in the frame basis — **weld,
           hinge, prismatic done**. Joint frame bases
           (`localBasisA`/`localBasisB`); BOTH linear and angular rows now
           resolve in frame A's axes (cleared the world-axis linear debt — the
           solver is fully frame-relative). Angular: relative rotation
           `qD = conj(qA)·qB` → small-angle error, ωB−ωA rows + SPOOK bias.
           Linear: `C·axis` error, vA−vB rows. `asWeld()` / `asHinge(axis)` /
           `asPrismatic(axis)` presets. Verified: weld holds pose + orientation
           against an off-centre torque; hinge swings about its free axis only
           (locked axes < 0.02); prismatic slides along its one free axis,
           locked on the others; all LOCKED-mode tests still green after the
           frame-basis rewrite.
        4. [x] LIMITED + MOTOR (bounded rows) → doors, pistons, wheel
           spin/drive, joint ROM. **LIMITED done** (linear + angular):
           `setLinearLimit(axis,lo,hi)` / `setAngularLimit(axis,lo,hi)` set a
           per-DOF travel/ROM range. The whole row set is now **one mode-
           agnostic solve** parameterised by `(bias, clamp range)`: LOCKED is
           the bilateral case (Baumgarte bias, unclamped); LIMITED is a
           **speculative (β=1) one-sided velocity constraint** that removes
           exactly the approach velocity so the DOF *lands on* its stop (no
           penetration, no rebound — an inelastic end-stop) and self-gates when
           far from the bound; only the push-out side of the bias is clamped so
           a teleport is eased out, not yanked. Verified: a vertical slider
           falls freely then stops dead on its lower stop (lands at the bound,
           no overshoot/rebound, locked axes held); a spun hinge stops dead on
           each ±end-stop with no rebound and holds. Angular position is the
           small-angle measure (`2·sin(θ/2)`) — accurate for modest ROM, see
           phase 6 for wide cones. **MOTOR next** (target-velocity row, impulse
           clamped to `±maxForce·h`).
        5. [x] SPRING (SPOOK soft) → suspension, bungees, soft ragdolls.
           `setLinearSpring(axis,k,c)` / `setAngularSpring(axis,k,c)`. A
           compliant (regularised) row in the same unified solve: per substep
           `denom = c + h·k`, compliance `γ = 1/(h·denom)`, restoring bias
           `(k/denom)·C`, softened mass `1/(K+γ)`; the iteration carries one
           extra `+ γ·λ_accum` term (γ = 0 ⇒ the LOCKED/LIMITED/MOTOR rows are
           bit-for-bit unchanged). Verified: a vertical strut settles at exactly
           the m·g/k deflection and a stiffer spring sags less and stays stable;
           an undamped spring oscillates about equilibrium (stores energy) while
           a damped one comes to rest; a torsional spring holds a gravity-loaded
           hinge at its balance angle. Suspension element ready (the simulated-
           wheel option for phase 7); also the soft basis for cone-twist.
        6. [x] Cone-twist / swing-twist angular limits → ragdolls. Opt-in
           `Joint.swingTwist` (or the `asConeTwist(twistLo,twistHi,swingY[,swingZ])`
           preset) switches the angular position measure from the per-axis
           small-angle vector to a swing-twist decomposition: angular X = twist
           about the bone, Y/Z = swing off it, each an **exact** angle. The
           existing LIMITED/SPRING/LOCKED rows are reused unchanged on those
           positions, so a twist/swing limit holds at the true angle at wide
           ROM (a 1.2 rad swing stops at 1.2, where the small-angle proxy
           drifts to ~1.287). Verified: exact swing/twist stops, free-within-
           cone, twist/swing independence; default (small-angle) path untouched.
           **Decision — inlined, not the Quaternion method.** Benchmarked the
           allocation-free inlined `swing_twist_error` against
           `Quaternion.computeSwingAndTwist` (`swing_twist.bench.spec.js`): the
           inline is **~5x** faster than the method with reused out-params and
           **~10x** vs the naive fresh-allocation form (object property access +
           normalize + a quaternion multiply + GC). In the per-substep
           per-joint hot loop that margin is worth the duplicated math, so the
           solver inlines it (the Quaternion method stays for general callers).
        7. [x] Vehicle layer — **raycast-vehicle controller**
           (`vehicle/RaycastVehicle.js`): single chassis body + raycast wheels.
           Per frame (before `fixedUpdate`) each wheel casts its suspension ray,
           applies a spring+damper suspension force along the contact normal
           (`applyForceAt`), and a tyre-friction impulse (`applyImpulseAt`) —
           lateral grip that cancels side-slip plus longitudinal drive/brake,
           clamped together to a friction circle μ·N. `addWheel`, `setSteering`,
           `setDriveForce`, `setBrake`; per-wheel runtime (contact, compression,
           normal, spin) for rendering. A controller on top of the public
           `raycast` + force API, not a new constraint; the 6-DOF spring+motor
           is the simulated-wheel alternative. Verified: hovers on its springs
           (4 contacts, settled), drives/coasts/brakes along its axis, tyre grip
           arrests a sideways shove, steering turns it upright, and it free-falls
           cleanly when airborne. Note: suspension is one dt-force per frame (not
           per-substep), so a resting chassis carries a ~g·h velocity-sample
           artifact (it hovers stably; position is steady to sub-cm). Ray
           accuracy follows `PhysicsSystem.raycast` — now narrowphase-exact for
           sphere / box / capsule / mesh / heightmap ground.
        8. [ ] Extras: pulley, gear, conveyor (contact surface-velocity),
           breakable-joint flag.

      Foundation gaps — both now closed:
        - [x] **Island integration.** Jointed dynamic-dynamic bodies are
          unioned into one island (`IslandBuilder` Pass 1b), so a chain /
          ragdoll sleeps and wakes as a unit; `__wake_joints` propagates wake
          across a joint when one side is awake and the other asleep
          (e.g. a kinematic/motor driver pulling a sleeping chain). Verified:
          a damped chain settles and both links sleep in one sleep group.
        - [x] **Generation-checked body references.** `solve_joints`,
          `IslandBuilder` Pass 1b and `__wake_joints` all gate on
          `storage.is_valid(packedId)`, so a joint to an unlinked / slot-reused
          body goes inert instead of attaching to the wrong body or crashing.
          Verified: unlinking a jointed body leaves the joint inert and the
          survivor free.

      References: Catto / Box2D-v3 joint solvers; Jolt's `Constraint` base
      (`SetupVelocityConstraint` / `WarmStartVelocityConstraint` /
      `SolveVelocityConstraint` / `SolvePositionConstraint`); PhysX D6 /
      ODE joint taxonomy.

### Stability
- [x] **Closed-form triangle-vs-primitive solvers** — `sphere_triangle_contact`
      / `box_triangle_contact` / `capsule_triangle_contact` (P1.1a–c), wired into
      the concave decomposition dispatch in place of per-triangle GJK+EPA for
      those primitives. Un-skipped the `narrowphase_concave.spec.js` ball-on-
      heightmap / mesh-cube settle tests and the `PhysicsSystem.spec.js`
      torus-knot test. Per-triangle GJK+EPA remains only as the fallback for
      *other* convex shapes vs triangles. `compute_penetration` now routes
      through the shared narrowphase dispatch (`deepest_pair_penetration`), so it
      uses the closed-form per-triangle solvers too — the old closed-mesh
      over-report is gone; the half-space test is retained only as a
      tunnel-recovery fallback.
- [x] **Edge-edge multi-point manifold** — *resolved by design (no code
      change needed).* An empirical SAT-source sweep over a wide range of
      box-box orientations shows the single-point edge-cross branch only ever
      wins for **transverse** edge crossings (inter-edge angle ≈ 83-90°), where
      a single closest-pair point is geometrically exact. A near-parallel edge
      pair gives a near-degenerate `edgeA × edgeB` that never becomes the SAT
      minimum, so near-parallel ("line") edge contacts resolve through the
      multi-point **face-clipping** path instead — confirmed by regression
      tests in `box_box_manifold.spec.js` (near-parallel tilted boxes → ≥ 2
      points; transverse crossing → exactly 1 exact point). The originally
      planned refinement targeted a case the geometry can't produce, so it is
      closed rather than implemented.
- [x] **Per-contact source-collider tracking (materials)** — multi-material
      compound bodies now get accurate per-contact friction / restitution. The
      narrowphase combines the specific (colliderA, colliderB) pair's
      coefficients at dispatch time (the only place that knows the source
      collider on each side — `contact/combine_material.js`) and stamps them
      into the manifold (CONTACT_STRIDE grown 14 → 16, offsets 14/15); the
      solver reads them per contact instead of from the body's primary collider.
      Regression test: an asymmetric-friction compound body yaws when shoved
      (the grippy collider drags), and a symmetric control does not. Still
      primary-collider-only: the contact-filter callback's collider args and the
      body-level sensor / concave flags (smaller follow-up).
- [ ] **Joint-aware island sleep (ragdoll settle quality).** A draped,
      self-colliding 10-joint ragdoll does not fully sleep in 10 s — surfaced by
      a 1000-seed Monte-Carlo sweep (`PhysicsSystem.ragdoll.spec.js`, `.skip`):
      for unlucky seeds a distal limb sustains a settled limit cycle (settled
      finite-difference accel up to ~1094 m/s² / ~1479 rad/s² at a limb end vs a
      ~55 m/s² median — bounded, non-growing, penetration-free, so a quality gap
      not a divergence). The sleep test today is per-body `|v|²+|ω|²`; an island
      over-constrained by cone-twist limits + self-contacts keeps small residual
      jiggle above the per-body threshold so it never crosses into sleep.
      Candidate fixes: sleep a jointed/contacting island on its AGGREGATE motion
      rather than the per-body minimum, and/or a settled-regime relaxation (zero
      restitution + extra position iterations) once an island's energy is low.
      The sweep flags the worst seeds for replay. (Test infra also adds
      per-point kinematics tracking — joint anchors + limb ends, with
      displacement→velocity→acceleration and the angular equivalents.)
- [x] **Box-on-heightmap settling — RESOLVED.** A dynamic box dropped onto a
      static HeightMapShape — flat seam straddle AND the sloped dip — settles to
      full rest; the `PhysicsSystem.heightmap.spec.js` dip-drop reproducer is
      un-skipped and passing. Fixed by the combination of the same-feature-id
      contact de-dup in `redetect_pair_geometry` (1:1 claimed matching, so the
      several contacts one triangle emits — all sharing its feature_id — no longer
      collapse onto a single candidate) and the HeightMapShape3D
      collision-tessellation work; together they give the box a stable per-substep
      contact set instead of a churning / collapsing one.

      Root cause, for the record: the historical "never settles" rattle was a
      depth divergence between the once-per-step `narrowphase_step` and the
      per-substep `redetect_pair_geometry`. At the SAME pose, re-detection
      over-reported a box-vs-triangle penetration as ~1 m (the "exit through the
      far side" class) where narrowphase reported ~1 cm, launching the box into an
      eternal vertical bounce (touch → ~1 m over-correction → separate → fall back
      → re-contact). Both paths call the identical `dispatch_pair`, so the
      divergence was the same-fid collapse corrupting the matched geometry; with
      the de-dup in place the two paths now agree (verified: both report
      [0.0100 ×4] at a 0.01-deep flat contact, in-cell and 4-triangle straddle).

      Guards: `narrowphase/redetect_pair_geometry.spec.js` now pins BOTH
      invariants — contacts sharing a feature_id keep distinct witnesses, and
      re-detect depth == narrowphase depth at a fixed pose (in-cell and
      4-triangle seam-straddle cases); `ecs/PhysicsSystem.heightmap.spec.js` pins
      the observable settle. The depth-equality regression test the earlier
      diagnostic trail asked for is in place, closing this out.

### Performance / Scale
- [x] **Per-body linear CCD shape-cast** — opt-in continuous collision for
      fast movers where the speculative margin isn't enough.
      `RigidBodyFlags.CCD` (off by default) + `ccd/linear_sweep.js`.
        - **Approach (Box2D `b2_continuousPhysics`-style conservative
          advancement).** After the substep solver produces each body's final
          pose (between `apply_restitution` and the sleep test), a flagged fast
          mover's primary collider is swept along its NET step translation
          (start-of-step → final pose) through the existing `shape_cast` TOI
          engine. On the first blocker the body is clamped to the contact pose
          and its inbound normal velocity removed (an inelastic stop); the next
          discrete step resolves the now-touching contact with the real
          material / restitution. Reuses `shape_cast` wholesale — no new
          geometry. Start positions captured in Stage 1 over the post-wake awake
          set; the pass iterates the awake list in storage order (deterministic).
        - **Motion gate — absolute slop, NOT body extent.** A body is swept when
          it moved more than `CCD_MIN_SWEEP_DISTANCE` (1 mm) this step. The gate
          exists only to skip near-stationary bodies (degenerate sweep + cost).
          It is deliberately *not* a fraction of the body's own size: tunnelling
          risk is set by the **obstacle's** thickness, not the mover's — a 2 m
          sphere drifting 0.5 m/step still passes clean through a 1 cm floor, so
          an extent-based gate would (wrongly) wait until the body moved more
          than its own radius and miss every thin-obstacle tunnel below that
          speed.
        - **No self-clamp on resting/sliding contacts.** The sweep ignores an
          impact at `t ≈ 0` (`CCD_INITIAL_OVERLAP_EPS`): an initial overlap is a
          contact the body already sits/slides on, owned by the discrete solver,
          not a tunnel — clamping there would freeze the body to the surface.
        - **Measured (falling-tower bench, 1000 random shapes onto a 1 cm floor,
          600 ticks; clean A/B on identical code via `ccdEnabled`).** This bench
          is the WRONG validation vehicle for CCD, and the numbers prove it: CCD
          off → **10/1000 tunnel**, median **42.7 ms**/step; all 1000 flagged →
          **50/1000 tunnel**, median **61.6 ms**. CCD makes it *worse*. The bench
          is a dense-pile **squeeze-through** scenario — 1000 bodies stacked on a
          1 cm floor, forced through by the column's weight over many steps — which
          is a *solver* limitation, not a missed single-step fly-through. CCD's
          post-solve clamp + velocity-kill fights the solver in a dense settling
          pile (it teleports mutually-stacking dynamics and breaks warm-start), so
          flagging a whole pile is an anti-pattern. CCD's real job — stopping a
          sparse fast mover against thin geometry — is validated by
          `ccd/linear_sweep.spec.js` (9 tests: a fast cube tunnels a thin floor
          without the flag and is stopped with it; deterministic; resting bodies
          undisturbed). **Correction:** an earlier version of this entry and the
          commit message (`42163b0d4`) claimed a "0/1000 tunnel, 58.2 ms" on-leg
          — that was never measured (a session tooling glitch); the real on-leg
          is 50/1000 / 61.6 ms.
        - **Scope (v1, documented):** linear sweep only (orientation fixed
          through the sweep); primary same-entity collider (child-entity
          colliders, synced outside the step, are not swept); EXACT against
          static geometry, APPROXIMATE against other dynamics (their
          start-of-step broadphase AABBs); the CCD stop is inelastic (the impact
          itself doesn't bounce — restitution applies on the next discrete
          contact); Dynamic bodies only. A body both resting on one surface and
          tunnelling another in the same step resolves only the resting contact
          (the `t ≈ 0` skip) — rare; the next discrete step catches the rest.
        - **Follow-ups (the dense-pile finding points the way):** sweep against
          STATIC geometry only by default — the dynamic-vs-dynamic clamp is the
          source of the dense-pile interference above and is only ever
          approximate anyway, so dropping it should make CCD purely additive
          (stops you at static walls/floors, never fights the dynamic stack);
          a proper TOI sub-solver for bullet-vs-dynamic; rotational / angular
          CCD; multi-collider sweep for compound bodies.
- [x] **Broadphase BVH balance — SAH rotation.** The dynamic AABB tree
      (`core/bvh2/bvh3/BVH.js`, a Box2D port) used SAH-cost insertion but a
      *height-only* AVL rotation (`balance_height`): height-balanced yet not
      SAH-balanced, so queries walked more nodes than needed. Replaced the
      rotation in `bubble_up_update` with `balance_rotate` — the Box2D-v3 /
      Kensler SAH-reducing rotation (for node A with children B, C, evaluate the
      four child↔grandchild swaps and apply the one that most reduces the
      surface-area cost). Deterministic; identical pair set.
        - Measured (same-session A/B, heavy benches): raycast **−9%**
          (28.2→25.6 µs/ray), falling-tower median **−10%**, settling-grid
          median **−12%**, and the **990/1000-churn stress −27%**
          (63.95→46.68 ms mean over 10k ticks) — biggest where the tree churns
          hardest. Determinism (8-trial bit-identical) holds.
        - **Insertion cost (measured):** `balance_rotate` does 4 surface-area
          evaluations per bubble-up level vs `balance_height`'s single height
          compare, so *pure bulk insertion* is **~1.4–1.5× slower** — the 100k
          synthetic insert bench (`BVH.spec.js`, drift-controlled interleaved
          A/B) drops from **~37k → ~25k inserts/sec** (~27→~40 µs/insert). This
          is the balancer's worst case (insert-only, zero queries/refits to
          amortise against). It does not show up end-to-end: static trees are
          built once then queried forever, dynamic bodies insert once then
          refit/query every frame, and even the 990/1000-swap stress test — the
          maximal insert-churn workload — is net **−27%**. Accepted.
        - **Tradeoff (documented):** the contact solver's Gauss-Seidel order
          follows broadphase traversal order (see `generate_pairs`), so the
          different tree shape shifts convergence on near-aligned stacks — the
          synthetic 128-cube wall now sleeps at ~10 s (was ~6.9 s). It still
          settles, doesn't creep / topple (all bug-guard assertions hold); only
          the sleep *time* moved (that test's budget was bumped 9→11 s with a
          note). Random-shape scenes (falling tower) were faster *and* settled
          fine.
        - **Follow-up:** decouple the solve order from tree shape — sort the
          broadphase pair list by `(idA, idB)` before narrowphase so contact
          order is body-id-deterministic regardless of tree shape. Then no tree
          change can affect convergence (and the stack settles identically under
          either balancer). Has a per-step sort cost + wide test re-baseline, so
          it's its own task. `balance_height` is retained for comparison /
          fallback.
- [ ] **Per-island parallel solve**: today's island data layout would
      allow worker-based solving once `SharedArrayBuffer` is available.
      Out-of-scope unless / until SAB is universally usable.

### Features
- [ ] **Convex collision proxies for dynamic concave bodies.** The long-term
      replacement for the interim per-substep concave re-detection (see
      Limitations) — and how every major engine handles dynamic non-convex
      shapes: collide a *few* convex pieces, never the raw concave mesh.
        1. **3D convex hull builder** (meep has only 2D hulls today —
           `core/geom/2d/convex-hull/`). A single hull of a mesh is one
           collider / one broadphase leaf and covers the overwhelming majority
           of dynamic objects (thrown props, debris). Pairs with the existing
           "Convex hull shape + eigen-inertia" item below.
        2. **Few-hull (V-HACD-style) approximate convex decomposition** for
           shapes whose concavity matters (a cup, a chair): ~8–64 fat convex
           hulls = 8–64 colliders, two orders of magnitude below a tet mesh.
      Each hull is convex → stable contact feature → the TGS analytic refresh
      is exact → no per-substep re-detection, no rocking. Granularity is the
      whole point: collider/BVH-leaf count must stay small for an *awake*
      dynamic body (the volumetric tet-mesher under `core/geom/3d/tetrahedra/`
      is the wrong tool here — thousands of pieces — and belongs to a future
      FEM/soft-body subsystem, not rigid collision).
- [ ] **Convex hull shape** with eigen-based principal-axes inertia
      derivation. Hooks `matrix_eigenvalues_in_place` from the existing
      linalg layer.
- [~] **Cylinder / cone shapes.**
    - [x] **`CylinderShape3D`** — Y-aligned solid cylinder (radius + full
          height, flat caps; the capsule's flat-cap sibling). Exact `support`,
          capped-cylinder SDF, bounds, `contains` / `nearest_point` /
          volume-sampling, equals/hash, `'cylinder'` JSON tag, `isCylinderShape3D`
          marker. Convex → routes through the narrowphase **GJK + EPA** fallback
          (no marker dispatch needed); spec asserts overlap-detected +
          MTV-separates vs sphere/box. Closed-form cylinder-vs-X contact pairs
          are a future refinement (the curved side is the usual smooth-support
          EPA case — same status as pre-closed-form sphere/capsule).
    - [ ] Closed-form cylinder contact pairs (cylinder × box / sphere / capsule
          / plane) for multi-point cap manifolds + stable resting.
    - [ ] **Cone shape** (+ closed-form / GJK fallback).

### Rendering integration
- [ ] **Fixed-step → render interpolation.** Not implemented yet. Physics writes
      each body's pose straight into the ECS `Transform` once per fixed step
      (`EntityManager.fixedUpdateStepSize`); with a render rate that doesn't match
      the fixed rate, the rendered motion aliases (stutter / temporal aliasing,
      worst at low fixed rates). Designed as a cross-cutting system (render +
      physics-as-producer + network-as-producer), reusing the network package's
      agnostic interpolation primitives rather than a physics-local mechanism.
      Full design + phasing: **see
      [`INTEPOLATION_SYSTEM_PLAN.md`](./INTEPOLATION_SYSTEM_PLAN.md)**. Locked
      decisions: a neutral shared interpolation log/adapters (lifted out of
      `network/`); physics restores authoritative pose from the log at step start
      (no solver rewiring) and records moved bodies' snapshots at step end;
      blend → `Transform` at `preRender`; O(moving) not O(N); rewind-safe via the
      tick-keyed log.

### API polish
- [x] **`overlap(shape, position, rotation, output, output_offset,
      filter?)`** — broadphase + narrowphase overlap query for kinematic
      / AOE / selection use cases. Body_ids written into a caller-sized
      Uint32Array buffer. Convex query shape only; concave candidates
      are routed through the per-triangle decomposition path.
- [x] **`shapeCast(ray, shape, rotation, result, filter?)`** for
      character controllers and kinematic shape sweeps. Broadphase
      swept-AABB against both BVHs; per-candidate AABB-slab interval
      narrowing + coarse step + GJK bisection for time-of-impact. The
      output `result.normal` is the true contact-surface normal at the
      kiss point, computed by re-running GJK + EPA at `best_t` on the
      winning candidate (falls back to `-ray.direction` only on EPA
      degeneracies).
- [x] **`compute_penetration(out_direction, shape_a, pos_a, rot_a,
      shape_b, pos_b, rot_b)`** — standalone geometry primitive (no
      PhysicsSystem) for resolving overlap between two shapes at given
      poses. Returns depth + outward direction. **Hardened** to route through
      the shared narrowphase dispatch (`deepest_pair_penetration`): exact
      closed-form for sphere/box/capsule pairs (box-box via SAT), GJK+EPA for
      general convex, closed-form per-triangle for convex × concave; the
      half-space test is retained only for tunnel recovery.

### Raycast narrowphase (done)

**Problem.** `raycast` (and the suspension ray inside `RaycastVehicle`) resolves
only to the nearest BVH leaf's *inflated* AABB: `result.t` is the distance to
that fattened box and `result.normal` is its face normal. Exact for an
axis-aligned box (modulo the broadphase margin), coarse for spheres / capsules /
rotated boxes / meshes / heightmaps. Refine each candidate against the true
shape to return the exact surface distance + normal. `shapeCast` already does
this for swept convex shapes via GJK+EPA; `raycast` should get the same
treatment with cheap analytic primitives on the hot path.

**Design.** Mirror `narrowphase_step`'s dispatch: closed-form ray tests for the
common primitives, a generic GJK fallback for the rest. The structural change is
in the BVH walk — the nearest *leaf AABB* is **not** the nearest *shape hit* (a
ray can clip a near fat-AABB but miss its shape while hitting a farther one), so
every crossing leaf must be refined, with subtrees pruned by inflated-AABB
`t_near` vs the best *refined* `t` (conservative-correct: a shape hit is always
≥ its tight AABB entry ≥ its inflated AABB entry). A leaf whose ray crosses the
fat AABB but misses the true shape now contributes **no hit** — the key
correctness gain.

Phasing (each phase: implement → spec → run from `H:/git/moh` → commit):

1. [x] **Ray-primitive helpers** — landed as `narrowphase/ray_shapes.js`
   (local-frame `ray_sphere_local` / `ray_box_local` / `ray_capsule_local`,
   not `core/geom`: the (`t`, normal, miss = `Infinity`, first-hit-from-outside)
   convention is raycast-specific, and the dispatch shares one ray→local
   transform across them). Built local-frame (unit direction ⇒ `t` preserved;
   rotate the local normal back). Triangle MT is inlined in the concave path
   (the existing `computeTriangleRayIntersection` writes a `SurfacePoint3` and
   returns no `t` — unsuited to the buffer-flyweight loop). Colocated specs.
2. [x] **Ray-narrowphase dispatch** `narrowphase/refine_ray_hit.js`:
   `(shape, position, rotation, ox,oy,oz, dx,dy,dz, tMax, outNormal) → t`.
   Type-marker dispatch (`isUnitSphereShape3D` / `isBoxShape3D` /
   `isCapsuleShape3D`) to the analytic primitives; a generic convex fallback
   for `TransformedShape3D` / `UnionShape3D` / other (GJK ray-cast, or reuse
   `shape_cast` with a zero-radius `PointShape3D`).
3. [x] **Concave path** in the dispatch: for `is_convex === false` (mesh /
   heightmap), enumerate the triangles overlapping the ray's swept AABB
   (`mesh_enumerate_triangles` / `heightmap_enumerate_triangles`), Möller–
   Trumbore each, take the nearest; normal from the triangle winding.
4. [x] **Rewire `queries/raycast.js`**: at each leaf, call `refine_ray_hit` on
   the true shape + pose instead of accepting the AABB `t_near`; track the best
   refined `(t, body, normal)`; keep subtree pruning on inflated-AABB `t_near`.
   Same signature / `PhysicsSurfacePoint` result; drop the AABB-face-normal
   block. Multi-collider bodies still resolve the primary collider only
   (inherited BVH-leaf limitation; note it).
5. [x] **Tests**: per-shape exactness (sphere / OBB / capsule / mesh /
   heightmap) — exact `t` and true normal; the **fat-AABB-cross-but-shape-miss
   ⇒ no hit** case (the correctness win); nearest-of-several across a near miss;
   `filter` and `tMax` honoured. Re-verify `RaycastVehicle` (ride height now
   exact — tighten the test bands if they shift by the old broadphase margin).
6. [x] **Bench + docs**: a raycast micro-bench (analytic fast-path cost; confirm
   the fat-AABB-miss rejection doesn't regress throughput); update the "Public
   queries" entry, `raycast.js` header, and the `RaycastVehicle` "AABB-level"
   caveat once exact.

Note: this sharpens `RaycastVehicle` suspension on non-box ground and every
shape query; it does not change the broadphase or any API surface.

---

## Future / out-of-scope

These are explicit architectural exclusions or post-v1 explorations.

### Architecture
- **Cross-runtime bit-exact determinism**: a soft-float library would
  replace `Math.sin/cos/exp/log/pow` in the hot path. The codebase is
  already structured to make this a swap-in at `quat_integrate.js` and
  tangent-basis construction in `build_manifold.js`. Not pursued because
  the same-runtime determinism we have covers the common cases (single-
  device replay, networked lockstep where all clients run the same JS
  engine).
- **WASM / SIMD**: the engine targets pure-JS portability. SIMD would
  invalidate the determinism story (V8 doesn't expose deterministic
  Float64x2 ops).
- **Multi-threaded solver**: workers don't share memory cheaply without
  `SharedArrayBuffer` plus the COOP/COEP HTTP headers, which are not
  always available. Single-threaded is good-enough for the awake-body
  budget that matters.
- **Packed-SoA body dynamics state — DECIDED AGAINST; do not re-open.** A
  reviewer will note that `BodyStorage` is SoA for *identity* (entity /
  generation / kind / flags / awake-set) but the per-body *dynamics* state —
  velocity, inertia, mass, force/torque accumulators — lives on the `RigidBody`
  **component object** (each carrying several `Vector3` = `Float64Array` + an
  `onChanged` Signal), in a sparse `__bodies[]` array reached by pointer-chase,
  not packed by slot. The same is true of `Collider` and `Joint`. This is a
  deliberate, settled choice, not an oversight: **meep is an ECS engine, and
  `RigidBody` / `Collider` / `Joint` are public-API components.** Them being
  first-class objects is a UX choice and uniformity with the rest of the engine —
  it is what makes them authorable, serializable (`toJSON`/`fromJSON` + binary
  adapters), value-diffable (`equals`/`hash` for netcode replication), and
  observable (`Vector3.onChanged`), exactly like every other component. A
  packed-SoA body pool would buy locality on the *awake* integration sweeps at
  the cost of breaking that uniformity and the public component contract. The
  design instead leans on "mostly-sleeping" (per-body iteration is over the
  *awake* set only), keeps the genuinely O(contacts) inner loop in flat SoA
  (manifolds + solver scratch), and has the hot paths index the component
  vectors' `Float64Array` backing directly to skip the observer (see
  Determinism). This is final — do not resurface it as a "fix".
- **Single in-flight solve (module-scoped solver scratch).**
  `solver/solve_contacts.js` keeps its cross-stage state (the `g_*` counters and
  the `scratch_*` arrays) at module scope — one copy shared by all worlds, not
  per `PhysicsSystem`. Deliberate: every world reuses one set of scratch, and
  it's safe because the engine is single-threaded and steps one `fixedUpdate` at
  a time. The ceiling it sets: two `PhysicsSystem` instances cannot be stepped
  concurrently or re-entrantly (the second clobbers the first's solver scratch).
  Accepted for a single-world, single-threaded engine; lifting it would need
  per-system scratch (or a solver-context object threaded through the stages).

### Simulation extensions
- **Soft body / cloth / fluids**: the SoA layout in `BodyStorage` and the
  manifold cache are rigid-body shaped. A soft-body system would be a
  parallel subsystem, not an extension.
- **Reduced-coordinate articulations** (MuJoCo / Featherstone-style):
  game-physics audience runs in maximal coordinates by convention. Not
  on the roadmap.

### Game-side
- **Vehicle physics** (suspensions, drivetrains): a domain layer that
  sits on top of the rigid-body primitives, not in `meep/`.
- **Character controllers**: same — `engine/control/first-person/` is the
  natural home.

---

## Notable design files

- Original design plan: `C:\Users\Alex\.claude\plans\let-s-plan-to-implement-transient-harp.md`
- This file (state of play): `engine/physics/PLAN.md`
