# 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.
    - `aabb3_transform_oriented_inverse(out, world_aabb, pos, rot)` (now in `core/geom/3d/aabb/`) — 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 (`core/collection/union-find/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 — the reconstruction contract (H-series, landed)
Engine outcomes are a pure function of **body components (keyed by entity
id) + the exportable solver-cache blob** — never of allocation history
(body slots, manifold slots, joint ids, awake-list order, BVH shape,
broadphase pad history). A world rebuilt from a snapshot — onto a fresh
engine, or by remove-all/re-add on a live one — continues **bit-identically**
to the engine that kept running. Verified by `determinism.spec.js`
(T1 lockstep repeatability; T2 reconstruction equivalence incl. generation
churn and pad-history wake timing).

- Entity-canonical pair A/B roles assigned in the broadphase
  (`generate_pairs`) — roles feed witness sides, normal orientation and the
  tangent basis, so they must derive from the cross-engine identity.
- Outcome-coupled iteration orders are entity-keyed: island contacts
  (IslandBuilder), joints (`__joints_sorted`), contact events
  (`ContactEventBuffer.sort_canonical` + dispatch normal mirror), CCD pass.
- `sleepState` + `sleep_timer` are persistent body state (adapter v1 +
  0→1 upgrader); `link()` honours them, so restored sleepers stay asleep.
- Stage-0 wake reads persistent manifolds (`prev_touched`), never the
  transient pair list; the precautionary pre-contact wake is an
  edge-triggered swept-tight-AABB proximity predicate (`FLAG_PROX`),
  a pure function of body state.
- `persistence/solver_caches.js`: entity-keyed export/import of manifolds
  (warm-start impulses + lifecycle bits), joint `dofImpulse`, sleep-group
  chains. Identical worlds serialize to identical bytes.
- Foundations that predate the H-series: min-heap free lists for
  deterministic slot reuse, no `Math.random` in the step, direct
  typed-array writes on hot paths (Transform writes still go through
  `set()` — external systems subscribe).
- Same-runtime bit-exact; cross-runtime is a known future seam (see
  Backlog → Reconstruction & determinism).

### 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
- `core/geom/3d/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)

### Reconstruction & determinism (engine side done; consumers pending)

The engine-side determinism contract is landed (see Done → Determinism).
These are the follow-ups that build on it:

- [ ] **Network late-join wiring (end-to-end).** The engine API exists
      (`write_solver_caches` / `read_solver_caches`, sleep-persistent
      components, the entity-id contract) but nothing consumes it yet.
      Build an end-to-end test through the network package's adapters:
      serialize a live world on the "server", reconstruct on a "client"
      (same entity ids, same link order), verify bit-identical continuation
      over N ticks. This validates the entity-replay assumptions
      (replicated entity ids, joint link order, system config transfer)
      before real netcode depends on them, and decides where the blob rides
      (snapshot message vs. component stream). System config (gravity,
      thresholds, iteration counts, `ccdEnabled`) is part of the snapshot
      contract — see `copy_system_config` in `determinism.spec.js` for the
      field list.
- [ ] **Position correction under stacked load.** Settled penetration is
      pose-chaotic with a heavy tail reaching the limb radius (~0.3 m for
      the ragdoll's capsules) when a body settles wedged under the
      assembly's weight — measured across seeds and under either contact-
      role assignment (see the 4-seed table in
      `PhysicsSystem.ragdoll.spec.js`). Pre-existing, not a regression.
      Investigation levers: position iterations (currently 1/substep),
      `MAX_POSITION_BIAS` clamp, depth-proportional stiffening for deep
      static contacts. Success metric: the cross-seed tail of
      `lastSecondMaxPenStatic`, not any single golden seed.
- [ ] **Sleep groups derived from manifolds (redesign).** Wake-the-island
      currently rides on `sleep_group_next/prev` circular chains threaded at
      atomic-sleep time — world-instance body indices that need their own
      section in the solver-cache blob. Deriving the wake set at wake time
      (flood over dormant contacting manifolds + joints among sleepers)
      would delete the fields, the blob section, and the group-restore step
      outright. Needs a per-body → manifold adjacency (or an accepted
      O(live-slots) scan per wake call) and a perf check on wake-heavy
      frames. Behavioural delta to design for: force-slept touching bodies
      currently wake independently (separate singleton groups); a
      manifold-derived flood would wake them together.
- [x] **KILLED — pose-keyed narrowphase memo (measured 2026-06-11).** The
      idea: skip a pair's narrowphase when both collider poses are bit-equal
      to its last run (a pure cache; outcome-neutral by construction, proven
      with a memo-on/off lockstep test). Implemented per-ManifoldStore (a
      process-global cache is unsound — two engines hosting the same pair
      ids at equal poses false-hit each other's slot content), then measured:
      **0.0% hit rate** in every phase of a settling column, including the
      final pre-sleep second. The premise is empirically false: warm-start
      cancellation holds *velocity* near zero, never exactly zero, so awake
      poses drift sub-nanometre every tick and never bit-freeze; sleeping
      pairs never reach narrowphase at all. Reverted as pure overhead.
      Dependent idea for the soft-solver work: a deterministic
      micro-velocity snap-to-zero at the end of the solve would both freeze
      resting poses bit-exact (making this memo viable) AND push
      forever-jiggling islands (deep-column N=20, KEVA) below the sleep
      threshold — likely the cheaper end of the same win.
- [x] **KILLED — solver hot/cold row split (ceiling-measured 2026-06-11).**
      The idea: split the 23-float prepared-contact records by the velocity
      iteration's access pattern (15 hot lanes) for cache density. Measured
      the ceiling first instead of implementing: PADDING the stride 23→31
      (+35% row traffic) moved KEVA-mini by only ~2% — so packing −35% can
      gain at most the same ~2%, far under the ≥10% gate. The velocity
      solve at this scale is dominated by the slot-scattered manifold reads
      (normal + impulses), the body-span gather/scatter and raw FLOPs, not
      by prepared-row density. Not implemented.
- [x] **KILLED — 4×4 manifold block solve (probe-measured 2026-06-11).**
      Pre-integration probe on the deep columns, sweeping iteration budgets:
      depth stability IS iteration-starved (N=30 explodes at velIters 4,
      holds intact at 8 — the divergence is convergence failure), but the
      never-sleeping residual is NOT — at velIters 16 the N=20 column idles
      at residual 0.83, closer to rest than any other config, and still
      never crosses the sleep threshold; sleep behaviour is non-monotonic
      across budgets. That residual is a hard-constraint limit cycle
      hovering at the threshold — a scheme property no amount of
      Gauss-Seidel (sequential or block) removes. A block solve would only
      buy the depth-stability half, which the soft-step scheme change
      covers anyway (Box2D v3 dropped block solving when it adopted soft
      step). Revisit only if post-soft-step iteration budgets remain
      depth-limited.
- [x] **LANDED — Box2D-v3 soft contact step (2026-06-11).** Contact normal
      rows are soft constraints: `b2MakeSoft(hertz, ζ, h)` scales (mass scale
      damps the velocity gain, the impulse-decay term bleeds the accumulator
      — the critically-over-damped spring that killed the hard scheme's
      resting limit cycle), a penetration-bias push for penetrating contacts
      (clamped like the old position bias), and a per-substep RELAX pass
      after position integration that strips bias-injected momentum. The
      split-impulse position pass is skipped for contacts when soft; knobs:
      `contactHertz = 30` (0 = bit-exact legacy hard rows), `ζ = 10`,
      non-dynamic pairs at 2× hertz, hertz clamped to an eighth of the
      substep rate — all pinned from Box2D v3 source.
      A bias-free hybrid (decay without the bias push) was measured FIRST
      and is UNSOUND: the decay bleeds the load-carrying impulse with no
      restoring force, columns sank 3 m. Full v3 it is.
      Measured: deep columns 15/20/30 all sleep (55/57/85 ticks; 20 never
      slept and 30 DIVERGED under hard); KEVA-mini all-ticks median
      90.8 → 14.8 ms (sleep arrives much sooner; awake ticks +23% from the
      relax sweep); 24-seed ragdoll sweep: settle-accel medians ~halved,
      impacts unchanged, settled-pen median 0.049 vs ~0.058 hard.
      Velocity-iteration cut REJECTED by measurement: KEVA-mini's
      convergence cliff sits between velIters 3 and 4 (2 → never sleeps,
      3 → sleeps far later); 4×4 stays.
      Known trades (re-pinned goldens, all documented in-test): settled
      penetration under load is the soft equilibrium droop (~0.8 mm per
      carried cube-weight); aligned-stack lateral creep grew ~5× (softer
      settle-phase normal impulses lower the friction caps — tuning
      candidate: stiffer staticSoftness or friction in the relax pass);
      ragdoll fields idle ~2× livelier (0.74 → 1.66 residual /704 bodies —
      tuning candidate: align joint hertz/ζ policy with contacts). The
      ragdoll canonical seed moved to 0xBEEF42 (0xDEADB0D's drape pose
      under soft ends in an accelerating dome roll — a growing-tail
      outlier; the sweep keeps covering it).
- [ ] **Cross-process repeatability of symmetry-degenerate scenes.** The
      perfectly-aligned 10-cube tower's resting drift came out 42/57/73 mm
      across equivalent builds during the soft-step work — each value
      process-stable and ×3 reproducible, shifting across rebuilds whose
      source diffs were assertion-text only. In-process determinism is
      green (T1 lockstep, 8-trial reproducibility bench), so the seam is
      environmental (suspect: jest transform/module environment shaping
      sub-ulp float paths in symmetry-degenerate tie-breaks). Investigate
      whether genuinely identical code can produce different cross-process
      results (would matter for lockstep across restarts) or whether the
      rebuild artefacts differed; degenerate-scene goldens use CLASS bounds
      in the meantime.
- [ ] **Cross-runtime float determinism audit.** Same-runtime bit-exactness
      is done; running lockstep across *different* JS engines (or versions)
      additionally requires auditing transcendentals — `Math.sin/cos/exp/
      pow` are not spec-pinned (sqrt and arithmetic are IEEE-exact). Only
      worth doing if cross-engine lockstep becomes a real deployment target;
      same-engine replication does not need it.

### 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** — originally landed as `narrowphase/ray_shapes.js`,
   later EXTRACTED to `core/geom/3d/{sphere,box,capsule}/{sphere3,box3,capsule3}_raycast.js`
   (the (`t`, normal, miss = `Infinity`, first-hit-from-outside) convention is a
   fine general raycast contract, and a ray-vs-capsule primitive was otherwise
   missing engine-wide; the dispatch still 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 `quat3_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`
