@woosh/meep-engine 2.145.0 → 2.146.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/package.json +1 -1
  2. package/src/core/geom/3d/shape/HeightMapShape3D.d.ts +33 -3
  3. package/src/core/geom/3d/shape/HeightMapShape3D.d.ts.map +1 -1
  4. package/src/core/geom/3d/shape/HeightMapShape3D.js +486 -451
  5. package/src/engine/control/first-person/DESIGN_COLLISION.md +365 -352
  6. package/src/engine/control/first-person/FirstPersonPlayerController.d.ts +1 -3
  7. package/src/engine/control/first-person/FirstPersonPlayerController.d.ts.map +1 -1
  8. package/src/engine/control/first-person/FirstPersonPlayerControllerConfig.d.ts +12 -2
  9. package/src/engine/control/first-person/FirstPersonPlayerControllerConfig.d.ts.map +1 -1
  10. package/src/engine/control/first-person/FirstPersonPlayerControllerConfig.js +7 -2
  11. package/src/engine/control/first-person/TODO.md +13 -11
  12. package/src/engine/control/first-person/abilities/WallJump.d.ts.map +1 -1
  13. package/src/engine/control/first-person/abilities/WallJump.js +11 -3
  14. package/src/engine/control/first-person/abilities/WallRun.d.ts.map +1 -1
  15. package/src/engine/control/first-person/abilities/WallRun.js +12 -0
  16. package/src/engine/control/first-person/collision/KinematicMover.d.ts.map +1 -1
  17. package/src/engine/control/first-person/collision/KinematicMover.js +634 -592
  18. package/src/engine/control/first-person/prototype_first_person_controller.js +1003 -901
  19. package/src/engine/physics/PLAN.md +943 -809
  20. package/src/engine/physics/body/BodyStorage.d.ts +9 -0
  21. package/src/engine/physics/body/BodyStorage.d.ts.map +1 -1
  22. package/src/engine/physics/body/BodyStorage.js +23 -0
  23. package/src/engine/physics/broadphase/generate_pairs.d.ts.map +1 -1
  24. package/src/engine/physics/broadphase/generate_pairs.js +7 -0
  25. package/src/engine/physics/ccd/linear_sweep.d.ts +97 -0
  26. package/src/engine/physics/ccd/linear_sweep.d.ts.map +1 -0
  27. package/src/engine/physics/ccd/linear_sweep.js +238 -0
  28. package/src/engine/physics/ecs/PhysicsSystem.d.ts +18 -3
  29. package/src/engine/physics/ecs/PhysicsSystem.d.ts.map +1 -1
  30. package/src/engine/physics/ecs/PhysicsSystem.js +59 -8
  31. package/src/engine/physics/ecs/RigidBodyFlags.d.ts +6 -0
  32. package/src/engine/physics/ecs/RigidBodyFlags.d.ts.map +1 -1
  33. package/src/engine/physics/ecs/RigidBodyFlags.js +6 -0
  34. package/src/engine/physics/narrowphase/box_triangle_contact.js +811 -811
  35. package/src/engine/physics/narrowphase/compute_penetration.d.ts.map +1 -1
  36. package/src/engine/physics/narrowphase/compute_penetration.js +325 -323
  37. package/src/engine/physics/narrowphase/decomposition/heightmap_enumerate_triangles.d.ts +27 -8
  38. package/src/engine/physics/narrowphase/decomposition/heightmap_enumerate_triangles.d.ts.map +1 -1
  39. package/src/engine/physics/narrowphase/decomposition/heightmap_enumerate_triangles.js +235 -204
  40. package/src/engine/physics/narrowphase/narrowphase_step.d.ts.map +1 -1
  41. package/src/engine/physics/narrowphase/narrowphase_step.js +70 -13
  42. package/src/engine/physics/queries/overlap_shape.d.ts.map +1 -1
  43. package/src/engine/physics/queries/overlap_shape.js +185 -183
  44. package/src/engine/simulation/Ticker.d.ts +14 -0
  45. package/src/engine/simulation/Ticker.d.ts.map +1 -1
  46. package/src/engine/simulation/Ticker.js +136 -1
@@ -1,809 +1,943 @@
1
- # Physics engine — state of play
2
-
3
- Tracker for what's built, what's pending, and what's deferred.
4
-
5
- ---
6
-
7
- ## Context
8
-
9
- Deterministic JS rigid-body physics engine for the meep ECS. Target: game
10
- scenarios with up to millions of mostly-sleeping bodies, deterministic replays
11
- for netcode and reproducible debugging, broad shape coverage for common game
12
- collisions. Pure JS — no WASM, no SIMD, no worker threads.
13
-
14
- Architectural references for design choices:
15
- - **Jolt** — pre-allocated body pool, active-list iteration, two-tree
16
- broadphase (static + dynamic).
17
- - **Bullet** — `btPersistentManifold` cache layout with up to 4 points.
18
- - **Box2D / Catto** — sequential impulse with warm-starting, Sutherland-Hodgman
19
- face clipping for box-box.
20
-
21
- ---
22
-
23
- ## Done
24
-
25
- ### Foundations
26
- - `RigidBody`, `Collider`, `BodyKind`, `RigidBodyFlags`, `ColliderFlags`,
27
- `SleepState`, `PhysicsEvents`.
28
- - `BodyStorage`: SoA pool, generation-tracked stable IDs, dense awake list,
29
- min-heap free for deterministic ID reuse.
30
- - `PhysicsSystem`: full public API surface (gravity, force/impulse with and
31
- without application point, torque, velocity setter, wake/sleep, contact
32
- filter callback).
33
- - Binary serialization adapters for `RigidBody` and `Collider` (transient
34
- runtime state deliberately excluded).
35
- - `PairUint32Map`: open-addressed Robin Hood + Fibonacci hash for the
36
- pair → manifold-slot index (the one new collection added to `core/collection/`).
37
-
38
- ### Pipeline (`PhysicsSystem.fixedUpdate`)
39
- 1. Velocity integration (semi-implicit Euler, linear + angular, gravity,
40
- damping, world-frame inverse-inertia for torque)
41
- 2. Per-collider broadphase refit with fat AABB (Box2D-style velocity-padded
42
- slack)
43
- 3. Pair generation: per-leaf query against both BVHs (static + dynamic),
44
- canonical `(min, max)` pairs, dedup via manifold touched flag
45
- 4. Wake propagation for sleeping bodies in the pair list
46
- 5. Narrowphase cross-product over collider lists
47
- 6. Sequential-impulse solver (Catto-style, warm-start, friction, Baumgarte)
48
- 7. Position integration (linear + quaternion)
49
- 8. Sleep test (per-body velocity² below threshold for ≥ 0.5 s)
50
- 9. Manifold diff → `ContactBegin` / `Stay` / `End` event dispatch
51
- 10. `manifolds.advance_frame()` — roll touched bits, evict grace-expired slots
52
-
53
- ### Shape coverage
54
- | Pair | Path | Manifold |
55
- |---|---|---|
56
- | sphere-sphere | closed-form | 1 point |
57
- | sphere-box | closed-form (handles centre-inside-box) | 1 point |
58
- | capsule-sphere | point-on-segment closed-form | 1 point |
59
- | capsule-capsule | segment-segment closest pair | 1 point |
60
- | capsule-box | iterative segment-vs-OBB (primary) + cap-centre sphere-vs-OBB at each endpoint | up to 3 |
61
- | box-box face-face | SAT + Sutherland-Hodgman clipping | up to 4 |
62
- | box-box edge-edge | SAT + segment-segment closest-pair | 1 point |
63
- | sphere / box / capsule × concave (heightmap, mesh) | closed-form `*_triangle_contact` per triangle via decomposition dispatcher | up to a few points per triangle (deepest wins) |
64
- | other convex × concave | per-triangle GJK + EPA via decomposition dispatcher | 1 point per triangle |
65
- | anything else | GJK + EPA, MPR fallback on EPA non-convergence | 1 point |
66
-
67
- ### Non-convex shapes
68
- - **`is_convex` flag** on `AbstractShape3D.prototype` (default `true`).
69
- Overridden to `false` on `HeightMapShape3D`, `MeshShape3D`, `UnionShape3D`.
70
- `TransformedShape3D` inherits via getter that reads the wrapped subject.
71
- - **`HeightMapShape3D`** — orientation-vector + `Sampler2D`-backed terrain
72
- shape. Heights sampled via `sampleChannelCatmullRomUV` (matching the
73
- terrain system's geometry construction). Compute_bounding_box,
74
- contains_point, signed_distance, nearest_point_on_surface all
75
- implemented; `support` throws (non-convex by construction).
76
- - **`Triangle3D`** — buffer-flyweight convex shape. `bind(buffer, offset)`
77
- repoints at 9 consecutive floats in an external Float64Array. Zero
78
- allocation per emission; used by the decomposition path.
79
- - **Triangle decomposition machinery** under
80
- `engine/physics/narrowphase/decomposition/`:
81
- - `TRIANGLE_FLOAT_STRIDE = 10` per triangle (`vA.xyz`, `vB.xyz`,
82
- `vC.xyz`, `feature_id`).
83
- - `heightmap_enumerate_triangles(out, offset, shape, ...aabb)` —
84
- Arvo-projects the convex's AABB into heightmap-local, intersects
85
- with the footprint to derive a cell range, emits 2 triangles per
86
- cell with stable feature_ids.
87
- - `mesh_enumerate_triangles(out, offset, shape, ...aabb)` — linear
88
- O(N) scan over `MeshShape3D.indices` with tight per-triangle AABB
89
- filtering. feature_id = triangle index.
90
- - `aabb_world_to_local(out, world_aabb, pos, rot)` — 8-corner
91
- projection of a world AABB into a body's local frame.
92
- - `decompose_to_triangles(...)` — dispatcher switching on shape
93
- type marker.
94
- - **Narrowphase concave dispatch** in `narrowphase_step.js`: detects
95
- `is_convex === false`, computes convex's world AABB, projects to
96
- concave's local frame, decomposes, per-triangle GJK + EPA with
97
- one-sided face-normal rejection and contact-normal dedup. Concave-vs-
98
- concave dynamic pairs are explicitly refused.
99
-
100
- ### Solver
101
- - Sequential impulse with warm-starting (10 velocity iterations by default).
102
- - Coulomb friction with disk-clamped tangent impulses.
103
- - Baumgarte position correction folded into the velocity solve.
104
- - Full angular Jacobian (`I_w⁻¹ = R · diag · R^T`) and angular impulse
105
- application.
106
- - Public force/impulse-at-point API (`applyForceAt`, `applyImpulseAt`,
107
- `applyTorque`).
108
-
109
- ### Sleep + events
110
- - Per-island **atomic sleep**: an island sleeps when `max(|v|² + |ω|²)`
111
- across all members stays below the threshold long enough; the whole
112
- island sleeps in the same frame. Replaces the per-body chatter on
113
- weakly-connected piles.
114
- - **Atomic wake**: members of a sleeping island are threaded into a
115
- circular doubly-linked list (`sleep_group_next` / `sleep_group_prev`);
116
- waking any one member walks the chain and wakes the rest in the same
117
- call. A 100-block stack hit at the base wakes top-down in one frame
118
- rather than over 100 frames of broadphase propagation.
119
- - `DisableSleep` on any island member exempts the whole island.
120
- - ContactBegin / Stay / End buffer + dispatch through both
121
- `PhysicsSystem.onContactBegin/Stay/End` Signals and the per-entity
122
- `entity.sendEvent(PhysicsEvents.ContactBegin, ...)` channel (when a
123
- dataset is attached).
124
-
125
- ### Islands
126
- - **Union-find** with path halving + union by min-index over the awake-body
127
- + touched-contact graph (`engine/physics/island/union_find.js`).
128
- - **`IslandBuilder`** produces deterministic CSR-style output: bodies and
129
- manifold slots grouped by island, sorted ascending within and across
130
- islands. Static / kinematic bodies are constraint anchors only — they
131
- don't merge islands, so disjoint piles on the same floor are separate
132
- islands.
133
- - **Solver iterates per island**: impulse convergence happens inside an
134
- island without waiting on unrelated bodies' Gauss-Seidel updates, and
135
- disconnected awake bodies don't pay each other's solver cost.
136
-
137
- ### Compound bodies
138
- - A body has 0..N attached colliders. Each collider has its own world
139
- transform and its own BVH leaf.
140
- - Same-entity colliders, child-entity colliders (via `ParentEntity`), or
141
- hybrids all supported.
142
- - `ColliderObserverSystem` auto-attaches colliders via the dataset when
143
- paired with `PhysicsSystem` in an EntityManager.
144
- - Narrowphase runs the cross-product over both bodies' collider lists per
145
- body-pair, accumulates candidates, reduces to ≤4 contacts by
146
- depth + spread.
147
-
148
- ### Public queries
149
- - `raycast(origin, dir, max_dist, filter?)` nearest hit across both trees,
150
- **refined to the true shape surface** (narrowphase). `result.t` /
151
- `result.normal` are exact for sphere / box / capsule / mesh / heightmap
152
- colliders (per-leaf analytic ray tests + triangle Möller–Trumbore for
153
- concave); composite convex shapes fall back to the broadphase AABB hit. A ray
154
- crossing a fat leaf AABB but missing the true shape is correctly a miss.
155
- - `shapeCast(ray, shape, rotation, result, filter?)` — broadphase swept
156
- AABB against both BVHs; per-candidate AABB-slab interval narrowing,
157
- coarse step over the narrowed window, GJK bisection to time-of-impact.
158
- Output normal is the true contact-surface normal at the kiss point,
159
- recovered by re-running GJK + EPA at `best_t` on the winning candidate.
160
- Falls back to `-ray.direction` only on EPA degeneracies (NaN / zero
161
- depth). Tests cover axis-aligned, off-axis, and oblique cube-vs-cube;
162
- sphere-vs-smooth-shape near-tangent has documented angular tolerance
163
- bands inherited from EPA on smooth supports.
164
- - `overlap(shape, position, rotation, output, output_offset, filter?)`
165
- broadphase + per-candidate GJK overlap detection. Writes body_ids
166
- into a caller-sized buffer; returns count. Convex query shapes only
167
- (concave throws). Concave candidates routed through the per-triangle
168
- decomposition path. Designed for speculative kinematic queries on
169
- kinematic bodies (character controllers, AOE selection).
170
-
171
- ### Standalone narrowphase utilities
172
- - `deepest_pair_penetration(out_normal, shapeA, posA, rotA, shapeB, posB,
173
- rotB)` (exported from `narrowphase_step.js`) runs the **same**
174
- `dispatch_pair` the contact solver consumes for one posed shape pair and
175
- returns the DEEPEST contact's depth + world normal (B → A). The single
176
- source of truth for "minimum-translation between two posed shapes", reused by
177
- `compute_penetration` (and available to any other query).
178
- - `compute_penetration(out_direction, shape_a, pos_a, rot_a, shape_b,
179
- pos_b, rot_b)` non-system geometry primitive: positive penetration
180
- depth + outward direction (B A convention) on overlap, 0 otherwise.
181
- **Hardened** delegates to `deepest_pair_penetration`, so it is correct
182
- (not "correct sometimes") for every shape pair the engine can build:
183
- - sphere / box / capsule pairs → exact closed-form (box-box via SAT, so a
184
- small body resting on a large floor reports the few-cm near-face overlap,
185
- NOT the metres-deep "exit through the far side" that MPR's centroid-seeded
186
- portal used to return);
187
- - general convex pairs GJK + EPA (exact for polytopes; curved shapes never
188
- reach it they have closed forms);
189
- - convex × concave triangle decomposition + the closed-form per-triangle
190
- solvers, bounded to each triangle's true 2-D extent (the old closed-mesh
191
- side-face over-report is gone).
192
- The previous per-triangle half-space test is retained ONLY as a recovery
193
- fallback for the one case the one-sided closed forms can't resolve: a convex
194
- shape that has fully tunnelled to the *inner* side of a concave surface (a
195
- depenetration query must still push it back out exact for heightmap terrain,
196
- a valid outward push for closed meshes). Concave × concave throws (M×N
197
- triangle pairs out of scope). The spec asserts an "applying out_direction ×
198
- depth separates the shapes" invariant across every convex+convex pair type and
199
- convex+concave, plus exact per-type depths and the small-box-on-huge-floor
200
- regression (3 m 0.05 m).
201
-
202
- ### Determinism
203
- - Direct typed-array writes on hot paths (bypassing `Vector3#set`'s observer
204
- dispatch) Transform writes still go through `set()` because external
205
- systems subscribe (TransformAttachment, EntityNode, FogOfWarRevealer,
206
- ViewportPosition).
207
- - Active body iteration sorted by body index.
208
- - Pair canonicalisation `(min, max)`.
209
- - Min-heap free list for slot reuse.
210
- - No `Math.random` anywhere in the simulation step.
211
- - Same-runtime bit-exact determinism by design; cross-runtime is a known
212
- future seam.
213
-
214
- ### Migration
215
- - `Motion` / `MotionSystem` / `MotionSerializationAdapter` relocated from
216
- the meep core (`engine/ecs/`) to the game-domain layer
217
- (`mir-engine/model/game/ecs/`). meep no longer ships the legacy shim.
218
-
219
- ### Alternative narrowphase: MPR
220
- - `engine/physics/gjk/mpr.js` — Minkowski Portal Refinement (XenoCollide,
221
- Snethen GDC 2009). Single-pass overlap test + MTV computation,
222
- output convention matches EPA so it's drop-in compatible at any
223
- narrowphase call site. Tends to converge in 5–15 iterations on
224
- smooth shapes where EPA stalls (the polytope-on-curved-surface
225
- failure mode the torus-knot reproducer exercised). **Wired as the EPA
226
- non-convergence fallback** in `narrowphase_step` at both the body-level
227
- and per-triangle GJK+EPA paths: when EPA returns a non-positive / non-finite
228
- depth, MPR is tried before giving up. `shape_cast` and `compute_penetration`
229
- use it for the same reason.
230
-
231
- ### Bonus utilities
232
- - `core/geom/3d/line/line3_closest_points_segment_segment.js` generally
233
- useful 3D segment-segment closest-pair via Ericson §5.1.9.
234
- - `core/collection/PairUint32Map.js` non-allocating
235
- `Map<(u32, u32) u32>` with Robin Hood + Fibonacci hash.
236
-
237
- ---
238
-
239
- ## Limitations / Known caveats
240
-
241
- - **Multi-collider material precision** *resolved for contact materials.* The
242
- narrowphase now combines the specific source-collider pair's friction /
243
- restitution per contact and stores them in the manifold (CONTACT_STRIDE
244
- offsets 14/15); the solver reads them per contact, so mixed-material compound
245
- bodies are accurate (regression test: an asymmetric-friction body yaws when
246
- shoved). Still primary-collider only: the contact-filter callback's
247
- `colliderA/B` arguments and the body-level sensor / concave-dispatch flags
248
- a smaller follow-up.
249
- - **EPA on smooth shapes**: degenerates (no flat face to converge on).
250
- Mitigated by closed-form paths for sphere/cube/capsule pairs and by the
251
- **MPR fallback** on EPA non-convergence; exotic convex shapes vs spheres can
252
- still occasionally fail if both EPA and MPR degenerate.
253
- - **EPA on `Triangle3D`** *resolved.* The concave dispatch now uses the
254
- closed-form `sphere_triangle_contact` / `box_triangle_contact` /
255
- `capsule_triangle_contact` solvers (P1.1a–c) instead of per-triangle GJK+EPA
256
- for those primitives, so a sphere/box/capsule on a heightmap or mesh decelerates
257
- and settles correctly; the `narrowphase_concave.spec.js` "drop and settle"
258
- cases and the mesh torus-knot settle test are **un-skipped**. Per-triangle
259
- GJK+EPA remains only as the fallback for *other* convex shapes vs triangles.
260
- (`compute_penetration` now routes through that same dispatch via
261
- `deepest_pair_penetration` see *Standalone narrowphase utilities* — instead
262
- of its old half-space pre-test; the half-space test survives only as a
263
- tunnel-recovery fallback.)
264
- - **Box-box edge-edge contact**: a single point at the true closest-pair of the
265
- two edges (P3.2), not the old body-centre midpoint. This is geometrically
266
- correct and an empirical SAT-source sweep confirms the edge-cross branch
267
- *only* fires for **transverse** edge crossings (inter-edge angle ≈ 83-90°),
268
- where two skew lines meet at a unique point. Near-parallel edge contacts
269
- cannot reach this branch (a near-parallel `edgeA × edgeB` never wins the SAT
270
- minimum) they resolve through the multi-point face-clipping path. So the
271
- once-planned "multi-point edge contact for near-parallel edges" refinement is
272
- **moot**; see the resolved Stability backlog entry.
273
- - **CCD floor only**: speculative margin via the fattened AABB prevents
274
- most tunnelling. No per-body swept shape-cast for very fast objects.
275
- - **Cross-runtime determinism is not guaranteed**: `Math.sin/cos/exp/log`
276
- are ULP-correct but not bit-exact across V8 / SpiderMonkey / JSC.
277
- - **Dynamic concave bodies under TGS** *resolved by per-substep re-detection
278
- (below); kept here for the rationale.* The substep loop normally re-derives
279
- contact geometry analytically from the per-triangle contact feature (witness
280
- anchors + normal) captured once by narrowphase and held fixed for the whole
281
- outer step. For a convex body the contact feature is stable under the small
282
- per-step motion, so this is exact; for a *dynamic concave mesh body* (e.g. a
283
- torus knot rocking on its own lobes) the supporting triangle itself changes
284
- as the body rocks, so freezing the feature would pump a little energy in and
285
- the body would rock / slowly sink instead of settling. Note this is NOT a
286
- contact-precision issue
287
- the knot already uses the exact closed-form box-triangle solver (P1.1b);
288
- the problem is purely that TGS freezes *which* feature is in contact across
289
- substeps. The common concave case a convex dynamic body on static concave
290
- terrain is unaffected (the convex side's feature is stable), and that is
291
- the only concave case the engine targets.
292
-
293
- **Interim fix (implemented): per-substep concave re-detection.** For
294
- contact pairs involving a concave body, the substep loop re-runs the
295
- concave narrowphase geometry at the current substep pose (instead of the
296
- analytic refresh that freezes the feature) and re-prepares those contacts
297
- from the fresh witness/normal/depth so the contact normal tracks the
298
- rocking body and no energy is pumped in. Convex pairs keep the cheap
299
- analytic refresh. This is ~Nx narrowphase cost on concave-involved pairs
300
- (acceptable they're rare), gated by collider convexity. Un-skips the
301
- torus-knot dynamic-settle test.
302
-
303
- **Better long-term fix: convex collision proxies (not raw concave).** Every
304
- major engine (Box2D, Jolt, PhysX, Rapier) requires dynamic bodies to be
305
- convex or convex-decomposed; raw concave meshes are static-only. The right
306
- granularity is a *few* convex pieces NOT the thousands of tets a
307
- volumetric mesher produces (tet count ≈ collider/BVH-leaf count, which
308
- explodes the broadphase for an awake body; tet meshing is for a future
309
- FEM/soft-body subsystem, not rigid collision). See the "Convex collision
310
- proxies for dynamic concave bodies" backlog item a 3D convex hull builder
311
- (single-hull proxy covers most dynamic objects) plus an optional
312
- few-hull (V-HACD-style) decomposition. Those supersede the interim
313
- per-substep re-detection once built.
314
-
315
- ---
316
-
317
- ## Backlog (planned, in scope)
318
-
319
- ### Solver quality (next major work)
320
-
321
- These items move the engine from "competent" to "great". TGS is the next
322
- significant solver-architecture change; joints come after, once the TGS
323
- scaffolding is in place.
324
-
325
- - **TGS (Temporal Gauss-Seidel) substepping with split-impulse** — Phases
326
- 1–3 **LANDED**. The solver is now a staged TGS pipeline
327
- (`solver/solve_contacts.js`: `prepare_contacts` per substep
328
- [`refresh_contacts` `warm_start_contacts` `solve_velocity`
329
- `solve_position`] `apply_restitution`), driven by the substep loop in
330
- `PhysicsSystem.fixedUpdate`. Defaults: `substeps = 4`,
331
- `velocityIterations = 4`, `positionIterations = 1` (all fields on
332
- `PhysicsSystem`).
333
- - **Phase 1 split impulse.** Position correction runs on a per-body
334
- pseudo-velocity (`__pseudo_velocity`) folded into the pose by
335
- `integrate_position` and discarded; depth correction never
336
- contaminates persistent velocity.
337
- - **Phase 2 one-shot restitution.** Velocity pass is pure
338
- non-penetration; restitution is a single post-loop pass driving
339
- `vn -e·vn_approach`, gated on a running max normal impulse
340
- (`maxNormalImpulse`) so transient collisions still bounce under
341
- per-substep warm-start.
342
- - **Phase 3 — substep loop.** `substeps` sub-iterations at `h = dt/N`.
343
- Forces consumed once at full `dt` before the loop; gravity applied
344
- per substep; **warm-start replayed per substep** (the crux — a
345
- per-substep impulse balances one substep of gravity, so resting
346
- stacks hold at zero velocity). Contact geometry is re-derived
347
- **analytically** each substep from frozen local witness anchors +
348
- the trusted prepare-time depth (a sign-robust delta), so narrowphase
349
- runs **once** per outer step cheaper than the originally-planned
350
- per-substep match-and-merge refresh, and exact for convex
351
- primitives whose contact feature is stable under small motion.
352
-
353
- Results vs the single-step solver: a 100:1 mass ratio now stacks
354
- instead of crushing through (regression test added); 8-cube stacks
355
- settle to zero velocity and sleep (were impossible long-term under SI);
356
- falling-tower bench cost unchanged (~48 ms/1000 active bodies);
357
- `substeps = 1` reproduces the single-step result bit-for-bit-ish
358
- (one-frame restitution delay aside).
359
-
360
- **Hard-won lessons (for REVIEW_002):**
361
- - Warm-start MUST be per-substep, not once. Replaying a full-frame
362
- impulse once while gravity arrives per substep over-pushes resting
363
- contacts and *explodes* deep stacks. Per-substep warm-start +
364
- per-substep gravity cancel exactly at rest.
365
- - Restitution must gate on the *running max* normal impulse, not the
366
- end-of-loop value per-substep warm-start relaxes a transient
367
- contact's `j_n` back to ~0 by the end, which would suppress the
368
- bounce.
369
- - Analytic separation re-derivation beats per-substep narrowphase
370
- for convex shapes (cheaper, no manifold-lifecycle churn) but is
371
- only as good as the frozen normal see the concave caveat below.
372
-
373
- Follow-ups since the core landed:
374
- - [x] **Box-box SAT reference tie-break deadband** — aligned cube
375
- stacks (4–10 high) now settle to zero velocity and sleep; the
376
- reference-face flip-flop that creeped/toppled them is gone.
377
- - [x] **Per-substep contact re-detection for concave pairs** — lifts
378
- the dynamic-concave-body limitation; the torus-knot dynamic-settle
379
- test is un-skipped. Concave pairs re-run narrowphase geometry each
380
- substep (`redetect_concave_contacts`); convex pairs keep the cheap
381
- analytic refresh.
382
-
383
- Remaining (Phases 4–6)now complete:
384
- - [x] Regression coverage: heavy-on-light pyramid (10× capstone on two
385
- light cubes settles + sleeps) and a ragdoll-stub (shoulder
386
- ball-socket + elbow hinge arm hangs, stays articulated, settles).
387
- - [x] REVIEW_002 retrospective — `engine/physics/REVIEW_002.md`.
388
-
389
- References: Catto 2018 ("Soft Constraints" GDC talk + the TGS
390
- follow-up); Box2D v3 source (`b2ApplyRestitution`, the substep solver
391
- stages); Rapier as the closest architectural sibling.
392
-
393
- - [x] **Constraints / joints DONE (phases 1–7 below).** One configurable
394
- 6-DOF joint (lock/free/limit/motor/spring + swing-twist cone-twist) plus
395
- the raycast vehicle. Covers chains/ropes, ragdolls, vehicles (incl.
396
- suspension), and the mechanical set (doors, pistons, welds, sliders,
397
- powered hinges/wheels). The design rationale below is kept as history; the
398
- phasing checklist records what landed. Solver/joint retrospective in
399
- `REVIEW_003.md`.
400
-
401
- Original framing (now satisfied): TGS unblocked it (joint-chain
402
- convergence is a TGS sweet spot), warm-start + per-substep + island
403
- machinery was reusable, and the SPOOK compliance dial gave spring
404
- constraints essentially for free.
405
-
406
- **Foundational work (do first): generalise the solver to constraint
407
- rows.** Today `solver/solve_contacts.js` is hard-coded to the
408
- contact-shape constraint (normal + 2 friction tangents, ≥0 clamp,
409
- restitution, penetration bias). Joints are equality / inequality
410
- constraints on relative velocity at anchors, generally bilateral
411
- (impulse may be ±) with optional limits and motors. The clean shape —
412
- and what Jolt / Box2D-v3 do is a **generic constraint row**: a
413
- Jacobian (linear + angular parts per body), an effective mass, a bias
414
- (position error × SPOOK gain, or motor target), and impulse bounds
415
- `[lo, hi]` (`[0,∞)` for a contact/limit, `(−∞,∞)` for an equality,
416
- `[−maxForce·h, +maxForce·h]` for a motor). Each joint type just fills
417
- in its rows; the existing per-body impulse-apply primitive
418
- (`apply_impulse_to_body` + `world_inverse_inertia_apply`), the
419
- per-substep warm-start, the islands, and the split-impulse / SPOOK
420
- position handling are all reused. Contacts become *one* constraint
421
- type among several rather than the hard-coded path.
422
-
423
- The specific constraint set, its use-case mapping, and per-type
424
- architecture-fit assessment are under review (see the constraints
425
- sketch). High level: ball-socket / distance / spring / weld and the
426
- grab constraint are near drop-ins on the row machinery; hinge /
427
- prismatic / cone-twist / motors / limits add angular-row + bounded-row
428
- mechanics (still within the impulse framework); raycast vehicles,
429
- conveyor surface-velocity, and gear/pulley coupling are higher-level
430
- systems or contact modifiers that sit *on top of* the primitives
431
- rather than being generic rows.
432
-
433
- **Decision: build ONE configurable 6-DOF constraint** (PhysX D6 / Jolt
434
- SixDOF), implemented mode-by-mode. The `Joint` ECS component carries
435
- `dofMode[6]` (3 linear, 3 angular) each `{locked|free|limited|spring|
436
- motor}` + per-DOF limit/spring/motor config + warm-start accumulators.
437
- Concrete joints are configs, not new code (ball-socket = lock 3 linear;
438
- hinge = lock 3 linear + 2 angular; weld = lock 6; cone-twist = lock 3
439
- linear + limit 3 angular; suspension = spring 1 linear + lock rest).
440
-
441
- Phasing:
442
- 1. [x] Constraint-row solver as a **parallel row set** in the TGS
443
- substep loop (contacts left untouched, not ported lower risk).
444
- `constraint/solve_constraints.js` reuses `world_inverse_inertia`,
445
- per-substep warm-start, and the SPOOK position bias; `Joint`
446
- component + `link_joint`/`unlink_joint` in PhysicsSystem;
447
- `jointIterations` knob. Bodies need no collider.
448
- 2. [x] **LOCKED linear DOFs ball-socket.** Pendulum (anchor pinned
449
- to a world pivot, body swings) and a 2-link chain (body↔body,
450
- joints stay connected, chain hangs) pass. **chains, ropes,
451
- pendulums working.**
452
- 3. [x] LOCKED angular + linear DOFs in the frame basis — **weld,
453
- hinge, prismatic done**. Joint frame bases
454
- (`localBasisA`/`localBasisB`); BOTH linear and angular rows now
455
- resolve in frame A's axes (cleared the world-axis linear debt — the
456
- solver is fully frame-relative). Angular: relative rotation
457
- `qD = conj(qA)·qB` → small-angle error, ωB−ωA rows + SPOOK bias.
458
- Linear: `C·axis` error, vA−vB rows. `asWeld()` / `asHinge(axis)` /
459
- `asPrismatic(axis)` presets. Verified: weld holds pose + orientation
460
- against an off-centre torque; hinge swings about its free axis only
461
- (locked axes < 0.02); prismatic slides along its one free axis,
462
- locked on the others; all LOCKED-mode tests still green after the
463
- frame-basis rewrite.
464
- 4. [x] LIMITED + MOTOR (bounded rows) doors, pistons, wheel
465
- spin/drive, joint ROM. **LIMITED done** (linear + angular):
466
- `setLinearLimit(axis,lo,hi)` / `setAngularLimit(axis,lo,hi)` set a
467
- per-DOF travel/ROM range. The whole row set is now **one mode-
468
- agnostic solve** parameterised by `(bias, clamp range)`: LOCKED is
469
- the bilateral case (Baumgarte bias, unclamped); LIMITED is a
470
- **speculative (β=1) one-sided velocity constraint** that removes
471
- exactly the approach velocity so the DOF *lands on* its stop (no
472
- penetration, no rebound — an inelastic end-stop) and self-gates when
473
- far from the bound; only the push-out side of the bias is clamped so
474
- a teleport is eased out, not yanked. Verified: a vertical slider
475
- falls freely then stops dead on its lower stop (lands at the bound,
476
- no overshoot/rebound, locked axes held); a spun hinge stops dead on
477
- each ±end-stop with no rebound and holds. Angular position is the
478
- small-angle measure (`2·sin(θ/2)`)accurate for modest ROM, see
479
- phase 6 for wide cones. **MOTOR next** (target-velocity row, impulse
480
- clamped to `±maxForce·h`).
481
- 5. [x] SPRING (SPOOK soft) suspension, bungees, soft ragdolls.
482
- `setLinearSpring(axis,k,c)` / `setAngularSpring(axis,k,c)`. A
483
- compliant (regularised) row in the same unified solve: per substep
484
- `denom = c + h·k`, compliance = 1/(h·denom)`, restoring bias
485
- `(k/denom)·C`, softened mass `1/(K+γ)`; the iteration carries one
486
- extra `+ γ·λ_accum` term (γ = 0 ⇒ the LOCKED/LIMITED/MOTOR rows are
487
- bit-for-bit unchanged). Verified: a vertical strut settles at exactly
488
- the m·g/k deflection and a stiffer spring sags less and stays stable;
489
- an undamped spring oscillates about equilibrium (stores energy) while
490
- a damped one comes to rest; a torsional spring holds a gravity-loaded
491
- hinge at its balance angle. Suspension element ready (the simulated-
492
- wheel option for phase 7); also the soft basis for cone-twist.
493
- 6. [x] Cone-twist / swing-twist angular limits → ragdolls. Opt-in
494
- `Joint.swingTwist` (or the `asConeTwist(twistLo,twistHi,swingY[,swingZ])`
495
- preset) switches the angular position measure from the per-axis
496
- small-angle vector to a swing-twist decomposition: angular X = twist
497
- about the bone, Y/Z = swing off it, each an **exact** angle. The
498
- existing LIMITED/SPRING/LOCKED rows are reused unchanged on those
499
- positions, so a twist/swing limit holds at the true angle at wide
500
- ROM (a 1.2 rad swing stops at 1.2, where the small-angle proxy
501
- drifts to ~1.287). Verified: exact swing/twist stops, free-within-
502
- cone, twist/swing independence; default (small-angle) path untouched.
503
- **Decision inlined, not the Quaternion method.** Benchmarked the
504
- allocation-free inlined `swing_twist_error` against
505
- `Quaternion.computeSwingAndTwist` (`swing_twist.bench.spec.js`): the
506
- inline is **~5x** faster than the method with reused out-params and
507
- **~10x** vs the naive fresh-allocation form (object property access +
508
- normalize + a quaternion multiply + GC). In the per-substep
509
- per-joint hot loop that margin is worth the duplicated math, so the
510
- solver inlines it (the Quaternion method stays for general callers).
511
- 7. [x] Vehicle layer — **raycast-vehicle controller**
512
- (`vehicle/RaycastVehicle.js`): single chassis body + raycast wheels.
513
- Per frame (before `fixedUpdate`) each wheel casts its suspension ray,
514
- applies a spring+damper suspension force along the contact normal
515
- (`applyForceAt`), and a tyre-friction impulse (`applyImpulseAt`)
516
- lateral grip that cancels side-slip plus longitudinal drive/brake,
517
- clamped together to a friction circle μ·N. `addWheel`, `setSteering`,
518
- `setDriveForce`, `setBrake`; per-wheel runtime (contact, compression,
519
- normal, spin) for rendering. A controller on top of the public
520
- `raycast` + force API, not a new constraint; the 6-DOF spring+motor
521
- is the simulated-wheel alternative. Verified: hovers on its springs
522
- (4 contacts, settled), drives/coasts/brakes along its axis, tyre grip
523
- arrests a sideways shove, steering turns it upright, and it free-falls
524
- cleanly when airborne. Note: suspension is one dt-force per frame (not
525
- per-substep), so a resting chassis carries a ~g·h velocity-sample
526
- artifact (it hovers stably; position is steady to sub-cm). Ray
527
- accuracy follows `PhysicsSystem.raycast` now narrowphase-exact for
528
- sphere / box / capsule / mesh / heightmap ground.
529
- 8. [ ] Extras: pulley, gear, conveyor (contact surface-velocity),
530
- breakable-joint flag.
531
-
532
- Foundation gaps both now closed:
533
- - [x] **Island integration.** Jointed dynamic-dynamic bodies are
534
- unioned into one island (`IslandBuilder` Pass 1b), so a chain /
535
- ragdoll sleeps and wakes as a unit; `__wake_joints` propagates wake
536
- across a joint when one side is awake and the other asleep
537
- (e.g. a kinematic/motor driver pulling a sleeping chain). Verified:
538
- a damped chain settles and both links sleep in one sleep group.
539
- - [x] **Generation-checked body references.** `solve_joints`,
540
- `IslandBuilder` Pass 1b and `__wake_joints` all gate on
541
- `storage.is_valid(packedId)`, so a joint to an unlinked / slot-reused
542
- body goes inert instead of attaching to the wrong body or crashing.
543
- Verified: unlinking a jointed body leaves the joint inert and the
544
- survivor free.
545
-
546
- References: Catto / Box2D-v3 joint solvers; Jolt's `Constraint` base
547
- (`SetupVelocityConstraint` / `WarmStartVelocityConstraint` /
548
- `SolveVelocityConstraint` / `SolvePositionConstraint`); PhysX D6 /
549
- ODE joint taxonomy.
550
-
551
- ### Stability
552
- - [x] **Closed-form triangle-vs-primitive solvers** `sphere_triangle_contact`
553
- / `box_triangle_contact` / `capsule_triangle_contact` (P1.1a–c), wired into
554
- the concave decomposition dispatch in place of per-triangle GJK+EPA for
555
- those primitives. Un-skipped the `narrowphase_concave.spec.js` ball-on-
556
- heightmap / mesh-cube settle tests and the `PhysicsSystem.spec.js`
557
- torus-knot test. Per-triangle GJK+EPA remains only as the fallback for
558
- *other* convex shapes vs triangles. `compute_penetration` now routes
559
- through the shared narrowphase dispatch (`deepest_pair_penetration`), so it
560
- uses the closed-form per-triangle solvers too the old closed-mesh
561
- over-report is gone; the half-space test is retained only as a
562
- tunnel-recovery fallback.
563
- - [x] **Edge-edge multi-point manifold** *resolved by design (no code
564
- change needed).* An empirical SAT-source sweep over a wide range of
565
- box-box orientations shows the single-point edge-cross branch only ever
566
- wins for **transverse** edge crossings (inter-edge angle 83-90°), where
567
- a single closest-pair point is geometrically exact. A near-parallel edge
568
- pair gives a near-degenerate `edgeA × edgeB` that never becomes the SAT
569
- minimum, so near-parallel ("line") edge contacts resolve through the
570
- multi-point **face-clipping** path instead confirmed by regression
571
- tests in `box_box_manifold.spec.js` (near-parallel tilted boxes ≥ 2
572
- points; transverse crossing exactly 1 exact point). The originally
573
- planned refinement targeted a case the geometry can't produce, so it is
574
- closed rather than implemented.
575
- - [x] **Per-contact source-collider tracking (materials)** multi-material
576
- compound bodies now get accurate per-contact friction / restitution. The
577
- narrowphase combines the specific (colliderA, colliderB) pair's
578
- coefficients at dispatch time (the only place that knows the source
579
- collider on each side `contact/combine_material.js`) and stamps them
580
- into the manifold (CONTACT_STRIDE grown 14 → 16, offsets 14/15); the
581
- solver reads them per contact instead of from the body's primary collider.
582
- Regression test: an asymmetric-friction compound body yaws when shoved
583
- (the grippy collider drags), and a symmetric control does not. Still
584
- primary-collider-only: the contact-filter callback's collider args and the
585
- body-level sensor / concave flags (smaller follow-up).
586
- - [ ] **Joint-aware island sleep (ragdoll settle quality).** A draped,
587
- self-colliding 10-joint ragdoll does not fully sleep in 10 s surfaced by
588
- a 1000-seed Monte-Carlo sweep (`PhysicsSystem.ragdoll.spec.js`, `.skip`):
589
- for unlucky seeds a distal limb sustains a settled limit cycle (settled
590
- finite-difference accel up to ~1094 m/s² / ~1479 rad/s² at a limb end vs a
591
- ~55 m/ median bounded, non-growing, penetration-free, so a quality gap
592
- not a divergence). The sleep test today is per-body `|v|²+|ω|²`; an island
593
- over-constrained by cone-twist limits + self-contacts keeps small residual
594
- jiggle above the per-body threshold so it never crosses into sleep.
595
- Candidate fixes: sleep a jointed/contacting island on its AGGREGATE motion
596
- rather than the per-body minimum, and/or a settled-regime relaxation (zero
597
- restitution + extra position iterations) once an island's energy is low.
598
- The sweep flags the worst seeds for replay. (Test infra also adds
599
- per-point kinematics tracking joint anchors + limb ends, with
600
- displacement→velocity→acceleration and the angular equivalents.)
601
-
602
- ### Performance / Scale
603
- - [ ] **Per-body linear CCD shape-cast**: optional opt-in for fast-moving
604
- bodies where speculative margin isn't enough. The bench's falling
605
- tower (1km drop onto a 1cm floor) is the concrete reproducer —
606
- 180 / 1000 bodies tunnel.
607
- - [x] **Broadphase BVH balance SAH rotation.** The dynamic AABB tree
608
- (`core/bvh2/bvh3/BVH.js`, a Box2D port) used SAH-cost insertion but a
609
- *height-only* AVL rotation (`balance_height`): height-balanced yet not
610
- SAH-balanced, so queries walked more nodes than needed. Replaced the
611
- rotation in `bubble_up_update` with `balance_rotate` the Box2D-v3 /
612
- Kensler SAH-reducing rotation (for node A with children B, C, evaluate the
613
- four child↔grandchild swaps and apply the one that most reduces the
614
- surface-area cost). Deterministic; identical pair set.
615
- - Measured (same-session A/B, heavy benches): raycast **−9%**
616
- (28.2→25.6 µs/ray), falling-tower median **−10%**, settling-grid
617
- median **−12%**, and the **990/1000-churn stress −27%**
618
- (63.95→46.68 ms mean over 10k ticks) biggest where the tree churns
619
- hardest. Determinism (8-trial bit-identical) holds.
620
- - **Insertion cost (measured):** `balance_rotate` does 4 surface-area
621
- evaluations per bubble-up level vs `balance_height`'s single height
622
- compare, so *pure bulk insertion* is **~1.4–1.5× slower** the 100k
623
- synthetic insert bench (`BVH.spec.js`, drift-controlled interleaved
624
- A/B) drops from **~37k ~25k inserts/sec** (~27→~40 µs/insert). This
625
- is the balancer's worst case (insert-only, zero queries/refits to
626
- amortise against). It does not show up end-to-end: static trees are
627
- built once then queried forever, dynamic bodies insert once then
628
- refit/query every frame, and even the 990/1000-swap stress test — the
629
- maximal insert-churn workload is net **−27%**. Accepted.
630
- - **Tradeoff (documented):** the contact solver's Gauss-Seidel order
631
- follows broadphase traversal order (see `generate_pairs`), so the
632
- different tree shape shifts convergence on near-aligned stacks the
633
- synthetic 128-cube wall now sleeps at ~10 s (was ~6.9 s). It still
634
- settles, doesn't creep / topple (all bug-guard assertions hold); only
635
- the sleep *time* moved (that test's budget was bumped 9→11 s with a
636
- note). Random-shape scenes (falling tower) were faster *and* settled
637
- fine.
638
- - **Follow-up:** decouple the solve order from tree shape — sort the
639
- broadphase pair list by `(idA, idB)` before narrowphase so contact
640
- order is body-id-deterministic regardless of tree shape. Then no tree
641
- change can affect convergence (and the stack settles identically under
642
- either balancer). Has a per-step sort cost + wide test re-baseline, so
643
- it's its own task. `balance_height` is retained for comparison /
644
- fallback.
645
- - [ ] **Per-island parallel solve**: today's island data layout would
646
- allow worker-based solving once `SharedArrayBuffer` is available.
647
- Out-of-scope unless / until SAB is universally usable.
648
-
649
- ### Features
650
- - [ ] **Convex collision proxies for dynamic concave bodies.** The long-term
651
- replacement for the interim per-substep concave re-detection (see
652
- Limitations) and how every major engine handles dynamic non-convex
653
- shapes: collide a *few* convex pieces, never the raw concave mesh.
654
- 1. **3D convex hull builder** (meep has only 2D hulls today
655
- `core/geom/2d/convex-hull/`). A single hull of a mesh is one
656
- collider / one broadphase leaf and covers the overwhelming majority
657
- of dynamic objects (thrown props, debris). Pairs with the existing
658
- "Convex hull shape + eigen-inertia" item below.
659
- 2. **Few-hull (V-HACD-style) approximate convex decomposition** for
660
- shapes whose concavity matters (a cup, a chair): ~8–64 fat convex
661
- hulls = 8–64 colliders, two orders of magnitude below a tet mesh.
662
- Each hull is convex stable contact feature the TGS analytic refresh
663
- is exact → no per-substep re-detection, no rocking. Granularity is the
664
- whole point: collider/BVH-leaf count must stay small for an *awake*
665
- dynamic body (the volumetric tet-mesher under `core/geom/3d/tetrahedra/`
666
- is the wrong tool here thousands of pieces and belongs to a future
667
- FEM/soft-body subsystem, not rigid collision).
668
- - [ ] **Convex hull shape** with eigen-based principal-axes inertia
669
- derivation. Hooks `matrix_eigenvalues_in_place` from the existing
670
- linalg layer.
671
- - [~] **Cylinder / cone shapes.**
672
- - [x] **`CylinderShape3D`** — Y-aligned solid cylinder (radius + full
673
- height, flat caps; the capsule's flat-cap sibling). Exact `support`,
674
- capped-cylinder SDF, bounds, `contains` / `nearest_point` /
675
- volume-sampling, equals/hash, `'cylinder'` JSON tag, `isCylinderShape3D`
676
- marker. Convex routes through the narrowphase **GJK + EPA** fallback
677
- (no marker dispatch needed); spec asserts overlap-detected +
678
- MTV-separates vs sphere/box. Closed-form cylinder-vs-X contact pairs
679
- are a future refinement (the curved side is the usual smooth-support
680
- EPA case same status as pre-closed-form sphere/capsule).
681
- - [ ] Closed-form cylinder contact pairs (cylinder × box / sphere / capsule
682
- / plane) for multi-point cap manifolds + stable resting.
683
- - [ ] **Cone shape** (+ closed-form / GJK fallback).
684
-
685
- ### API polish
686
- - [x] **`overlap(shape, position, rotation, output, output_offset,
687
- filter?)`** broadphase + narrowphase overlap query for kinematic
688
- / AOE / selection use cases. Body_ids written into a caller-sized
689
- Uint32Array buffer. Convex query shape only; concave candidates
690
- are routed through the per-triangle decomposition path.
691
- - [x] **`shapeCast(ray, shape, rotation, result, filter?)`** for
692
- character controllers and kinematic shape sweeps. Broadphase
693
- swept-AABB against both BVHs; per-candidate AABB-slab interval
694
- narrowing + coarse step + GJK bisection for time-of-impact. The
695
- output `result.normal` is the true contact-surface normal at the
696
- kiss point, computed by re-running GJK + EPA at `best_t` on the
697
- winning candidate (falls back to `-ray.direction` only on EPA
698
- degeneracies).
699
- - [x] **`compute_penetration(out_direction, shape_a, pos_a, rot_a,
700
- shape_b, pos_b, rot_b)`** standalone geometry primitive (no
701
- PhysicsSystem) for resolving overlap between two shapes at given
702
- poses. Returns depth + outward direction. **Hardened** to route through
703
- the shared narrowphase dispatch (`deepest_pair_penetration`): exact
704
- closed-form for sphere/box/capsule pairs (box-box via SAT), GJK+EPA for
705
- general convex, closed-form per-triangle for convex × concave; the
706
- half-space test is retained only for tunnel recovery.
707
-
708
- ### Raycast narrowphase (done)
709
-
710
- **Problem.** `raycast` (and the suspension ray inside `RaycastVehicle`) resolves
711
- only to the nearest BVH leaf's *inflated* AABB: `result.t` is the distance to
712
- that fattened box and `result.normal` is its face normal. Exact for an
713
- axis-aligned box (modulo the broadphase margin), coarse for spheres / capsules /
714
- rotated boxes / meshes / heightmaps. Refine each candidate against the true
715
- shape to return the exact surface distance + normal. `shapeCast` already does
716
- this for swept convex shapes via GJK+EPA; `raycast` should get the same
717
- treatment with cheap analytic primitives on the hot path.
718
-
719
- **Design.** Mirror `narrowphase_step`'s dispatch: closed-form ray tests for the
720
- common primitives, a generic GJK fallback for the rest. The structural change is
721
- in the BVH walk the nearest *leaf AABB* is **not** the nearest *shape hit* (a
722
- ray can clip a near fat-AABB but miss its shape while hitting a farther one), so
723
- every crossing leaf must be refined, with subtrees pruned by inflated-AABB
724
- `t_near` vs the best *refined* `t` (conservative-correct: a shape hit is always
725
- its tight AABB entry its inflated AABB entry). A leaf whose ray crosses the
726
- fat AABB but misses the true shape now contributes **no hit** the key
727
- correctness gain.
728
-
729
- Phasing (each phase: implement spec run from `H:/git/moh` → commit):
730
-
731
- 1. [x] **Ray-primitive helpers** landed as `narrowphase/ray_shapes.js`
732
- (local-frame `ray_sphere_local` / `ray_box_local` / `ray_capsule_local`,
733
- not `core/geom`: the (`t`, normal, miss = `Infinity`, first-hit-from-outside)
734
- convention is raycast-specific, and the dispatch shares one ray→local
735
- transform across them). Built local-frame (unit direction `t` preserved;
736
- rotate the local normal back). Triangle MT is inlined in the concave path
737
- (the existing `computeTriangleRayIntersection` writes a `SurfacePoint3` and
738
- returns no `t` — unsuited to the buffer-flyweight loop). Colocated specs.
739
- 2. [x] **Ray-narrowphase dispatch** `narrowphase/refine_ray_hit.js`:
740
- `(shape, position, rotation, ox,oy,oz, dx,dy,dz, tMax, outNormal) t`.
741
- Type-marker dispatch (`isUnitSphereShape3D` / `isBoxShape3D` /
742
- `isCapsuleShape3D`) to the analytic primitives; a generic convex fallback
743
- for `TransformedShape3D` / `UnionShape3D` / other (GJK ray-cast, or reuse
744
- `shape_cast` with a zero-radius `PointShape3D`).
745
- 3. [x] **Concave path** in the dispatch: for `is_convex === false` (mesh /
746
- heightmap), enumerate the triangles overlapping the ray's swept AABB
747
- (`mesh_enumerate_triangles` / `heightmap_enumerate_triangles`), Möller–
748
- Trumbore each, take the nearest; normal from the triangle winding.
749
- 4. [x] **Rewire `queries/raycast.js`**: at each leaf, call `refine_ray_hit` on
750
- the true shape + pose instead of accepting the AABB `t_near`; track the best
751
- refined `(t, body, normal)`; keep subtree pruning on inflated-AABB `t_near`.
752
- Same signature / `PhysicsSurfacePoint` result; drop the AABB-face-normal
753
- block. Multi-collider bodies still resolve the primary collider only
754
- (inherited BVH-leaf limitation; note it).
755
- 5. [x] **Tests**: per-shape exactness (sphere / OBB / capsule / mesh /
756
- heightmap)exact `t` and true normal; the **fat-AABB-cross-but-shape-miss
757
- no hit** case (the correctness win); nearest-of-several across a near miss;
758
- `filter` and `tMax` honoured. Re-verify `RaycastVehicle` (ride height now
759
- exact tighten the test bands if they shift by the old broadphase margin).
760
- 6. [x] **Bench + docs**: a raycast micro-bench (analytic fast-path cost; confirm
761
- the fat-AABB-miss rejection doesn't regress throughput); update the "Public
762
- queries" entry, `raycast.js` header, and the `RaycastVehicle` "AABB-level"
763
- caveat once exact.
764
-
765
- Note: this sharpens `RaycastVehicle` suspension on non-box ground and every
766
- shape query; it does not change the broadphase or any API surface.
767
-
768
- ---
769
-
770
- ## Future / out-of-scope
771
-
772
- These are explicit architectural exclusions or post-v1 explorations.
773
-
774
- ### Architecture
775
- - **Cross-runtime bit-exact determinism**: a soft-float library would
776
- replace `Math.sin/cos/exp/log/pow` in the hot path. The codebase is
777
- already structured to make this a swap-in at `quat_integrate.js` and
778
- tangent-basis construction in `build_manifold.js`. Not pursued because
779
- the same-runtime determinism we have covers the common cases (single-
780
- device replay, networked lockstep where all clients run the same JS
781
- engine).
782
- - **WASM / SIMD**: the engine targets pure-JS portability. SIMD would
783
- invalidate the determinism story (V8 doesn't expose deterministic
784
- Float64x2 ops).
785
- - **Multi-threaded solver**: workers don't share memory cheaply without
786
- `SharedArrayBuffer` plus the COOP/COEP HTTP headers, which are not
787
- always available. Single-threaded is good-enough for the awake-body
788
- budget that matters.
789
-
790
- ### Simulation extensions
791
- - **Soft body / cloth / fluids**: the SoA layout in `BodyStorage` and the
792
- manifold cache are rigid-body shaped. A soft-body system would be a
793
- parallel subsystem, not an extension.
794
- - **Reduced-coordinate articulations** (MuJoCo / Featherstone-style):
795
- game-physics audience runs in maximal coordinates by convention. Not
796
- on the roadmap.
797
-
798
- ### Game-side
799
- - **Vehicle physics** (suspensions, drivetrains): a domain layer that
800
- sits on top of the rigid-body primitives, not in `meep/`.
801
- - **Character controllers**: same `engine/control/first-person/` is the
802
- natural home.
803
-
804
- ---
805
-
806
- ## Notable design files
807
-
808
- - Original design plan: `C:\Users\Alex\.claude\plans\let-s-plan-to-implement-transient-harp.md`
809
- - This file (state of play): `engine/physics/PLAN.md`
1
+ # Physics engine — state of play
2
+
3
+ Tracker for what's built, what's pending, and what's deferred.
4
+
5
+ ---
6
+
7
+ ## Context
8
+
9
+ Deterministic JS rigid-body physics engine for the meep ECS. Target: game
10
+ scenarios with up to millions of mostly-sleeping bodies, deterministic replays
11
+ for netcode and reproducible debugging, broad shape coverage for common game
12
+ collisions. Pure JS — no WASM, no SIMD, no worker threads.
13
+
14
+ Architectural references for design choices:
15
+ - **Jolt** — pre-allocated body pool, active-list iteration, two-tree
16
+ broadphase (static + dynamic).
17
+ - **Bullet** — `btPersistentManifold` cache layout with up to 4 points.
18
+ - **Box2D / Catto** — sequential impulse with warm-starting, Sutherland-Hodgman
19
+ face clipping for box-box.
20
+
21
+ ---
22
+
23
+ ## Done
24
+
25
+ ### Foundations
26
+ - `RigidBody`, `Collider`, `BodyKind`, `RigidBodyFlags`, `ColliderFlags`,
27
+ `SleepState`, `PhysicsEvents`.
28
+ - `BodyStorage`: SoA pool, generation-tracked stable IDs, dense awake list,
29
+ min-heap free for deterministic ID reuse.
30
+ - `PhysicsSystem`: full public API surface (gravity, force/impulse with and
31
+ without application point, torque, velocity setter, wake/sleep, contact
32
+ filter callback).
33
+ - Binary serialization adapters for `RigidBody` and `Collider` (transient
34
+ runtime state deliberately excluded).
35
+ - `PairUint32Map`: open-addressed Robin Hood + Fibonacci hash for the
36
+ pair → manifold-slot index (the one new collection added to `core/collection/`).
37
+
38
+ ### Pipeline (`PhysicsSystem.fixedUpdate`)
39
+ 1. Velocity integration (semi-implicit Euler, linear + angular, gravity,
40
+ damping, world-frame inverse-inertia for torque)
41
+ 2. Per-collider broadphase refit with fat AABB (Box2D-style velocity-padded
42
+ slack)
43
+ 3. Pair generation: per-leaf query against both BVHs (static + dynamic),
44
+ canonical `(min, max)` pairs, dedup via manifold touched flag
45
+ 4. Wake propagation for sleeping bodies in the pair list
46
+ 5. Narrowphase cross-product over collider lists
47
+ 6. Sequential-impulse solver (Catto-style, warm-start, friction, Baumgarte)
48
+ 7. Position integration (linear + quaternion)
49
+ 8. Sleep test (per-body velocity² below threshold for ≥ 0.5 s)
50
+ 9. Manifold diff → `ContactBegin` / `Stay` / `End` event dispatch
51
+ 10. `manifolds.advance_frame()` — roll touched bits, evict grace-expired slots
52
+
53
+ ### Shape coverage
54
+ | Pair | Path | Manifold |
55
+ |---|---|---|
56
+ | sphere-sphere | closed-form | 1 point |
57
+ | sphere-box | closed-form (handles centre-inside-box) | 1 point |
58
+ | capsule-sphere | point-on-segment closed-form | 1 point |
59
+ | capsule-capsule | segment-segment closest pair | 1 point |
60
+ | capsule-box | iterative segment-vs-OBB (primary) + cap-centre sphere-vs-OBB at each endpoint | up to 3 |
61
+ | box-box face-face | SAT + Sutherland-Hodgman clipping | up to 4 |
62
+ | box-box edge-edge | SAT + segment-segment closest-pair | 1 point |
63
+ | sphere / box / capsule × concave (heightmap, mesh) | closed-form `*_triangle_contact` per triangle via decomposition dispatcher | up to a few points per triangle (deepest wins) |
64
+ | other convex × concave | per-triangle GJK + EPA via decomposition dispatcher | 1 point per triangle |
65
+ | anything else | GJK + EPA, MPR fallback on EPA non-convergence | 1 point |
66
+
67
+ ### Non-convex shapes
68
+ - **`is_convex` flag** on `AbstractShape3D.prototype` (default `true`).
69
+ Overridden to `false` on `HeightMapShape3D`, `MeshShape3D`, `UnionShape3D`.
70
+ `TransformedShape3D` inherits via getter that reads the wrapped subject.
71
+ - **`HeightMapShape3D`** — orientation-vector + `Sampler2D`-backed terrain
72
+ shape. Heights sampled via `sampleChannelCatmullRomUV` (matching the
73
+ terrain system's geometry construction). Compute_bounding_box,
74
+ contains_point, signed_distance, nearest_point_on_surface all
75
+ implemented; `support` throws (non-convex by construction).
76
+ - **`Triangle3D`** — buffer-flyweight convex shape. `bind(buffer, offset)`
77
+ repoints at 9 consecutive floats in an external Float64Array. Zero
78
+ allocation per emission; used by the decomposition path.
79
+ - **Triangle decomposition machinery** under
80
+ `engine/physics/narrowphase/decomposition/`:
81
+ - `TRIANGLE_FLOAT_STRIDE = 10` per triangle (`vA.xyz`, `vB.xyz`,
82
+ `vC.xyz`, `feature_id`).
83
+ - `heightmap_enumerate_triangles(out, offset, shape, ...aabb)` —
84
+ Arvo-projects the convex's AABB into heightmap-local, intersects
85
+ with the footprint to derive a cell range, emits 2 triangles per
86
+ cell with stable feature_ids.
87
+ - `mesh_enumerate_triangles(out, offset, shape, ...aabb)` — linear
88
+ O(N) scan over `MeshShape3D.indices` with tight per-triangle AABB
89
+ filtering. feature_id = triangle index.
90
+ - `aabb_world_to_local(out, world_aabb, pos, rot)` — 8-corner
91
+ projection of a world AABB into a body's local frame.
92
+ - `decompose_to_triangles(...)` — dispatcher switching on shape
93
+ type marker.
94
+ - **Narrowphase concave dispatch** in `narrowphase_step.js`: detects
95
+ `is_convex === false`, computes convex's world AABB, projects to
96
+ concave's local frame, decomposes, per-triangle GJK + EPA with
97
+ one-sided face-normal rejection and contact-normal dedup. Concave-vs-
98
+ concave dynamic pairs are explicitly refused.
99
+
100
+ ### Solver
101
+ - Sequential impulse with warm-starting (10 velocity iterations by default).
102
+ - Coulomb friction with disk-clamped tangent impulses.
103
+ - Baumgarte position correction folded into the velocity solve.
104
+ - Full angular Jacobian (`I_w⁻¹ = R · diag · R^T`) and angular impulse
105
+ application.
106
+ - Public force/impulse-at-point API (`applyForceAt`, `applyImpulseAt`,
107
+ `applyTorque`).
108
+
109
+ ### Sleep + events
110
+ - Per-island **atomic sleep**: an island sleeps when `max(|v|² + |ω|²)`
111
+ across all members stays below the threshold long enough; the whole
112
+ island sleeps in the same frame. Replaces the per-body chatter on
113
+ weakly-connected piles.
114
+ - **Atomic wake**: members of a sleeping island are threaded into a
115
+ circular doubly-linked list (`sleep_group_next` / `sleep_group_prev`);
116
+ waking any one member walks the chain and wakes the rest in the same
117
+ call. A 100-block stack hit at the base wakes top-down in one frame
118
+ rather than over 100 frames of broadphase propagation.
119
+ - `DisableSleep` on any island member exempts the whole island.
120
+ - ContactBegin / Stay / End buffer + dispatch through both
121
+ `PhysicsSystem.onContactBegin/Stay/End` Signals and the per-entity
122
+ `entity.sendEvent(PhysicsEvents.ContactBegin, ...)` channel (when a
123
+ dataset is attached).
124
+
125
+ ### Islands
126
+ - **Union-find** with path halving + union by min-index over the awake-body
127
+ + touched-contact graph (`engine/physics/island/union_find.js`).
128
+ - **`IslandBuilder`** produces deterministic CSR-style output: bodies and
129
+ manifold slots grouped by island, sorted ascending within and across
130
+ islands. Static / kinematic bodies are constraint anchors only — they
131
+ don't merge islands, so disjoint piles on the same floor are separate
132
+ islands.
133
+ - **Islands feed the sleep test + grouping, not a per-island solver loop.**
134
+ The TGS contact solver flattens every island's contacts into one
135
+ Gauss-Seidel sweep (`solver/solve_contacts.js`) islands share no bodies,
136
+ so a single flat sweep is identical to per-island sweeps. The partition is
137
+ still rebuilt every step and consumed by the atomic-island sleep test and the
138
+ joint/contact island grouping; it is also the natural unit for a future
139
+ worker-based parallel solve (see Performance / Scale). (Earlier revisions ran
140
+ the solver per island; the TGS rewrite flattened it — same result, simpler
141
+ loop.)
142
+
143
+ ### Compound bodies
144
+ - A body has 0..N attached colliders. Each collider has its own world
145
+ transform and its own BVH leaf.
146
+ - Same-entity colliders, child-entity colliders (via `ParentEntity`), or
147
+ hybrids all supported.
148
+ - `ColliderObserverSystem` auto-attaches colliders via the dataset when
149
+ paired with `PhysicsSystem` in an EntityManager.
150
+ - Narrowphase runs the cross-product over both bodies' collider lists per
151
+ body-pair, accumulates candidates, reduces to ≤4 contacts by
152
+ depth + spread.
153
+
154
+ ### Public queries
155
+ - `raycast(origin, dir, max_dist, filter?)` — nearest hit across both trees,
156
+ **refined to the true shape surface** (narrowphase). `result.t` /
157
+ `result.normal` are exact for sphere / box / capsule / mesh / heightmap
158
+ colliders (per-leaf analytic ray tests + triangle Möller–Trumbore for
159
+ concave); composite convex shapes fall back to the broadphase AABB hit. A ray
160
+ crossing a fat leaf AABB but missing the true shape is correctly a miss.
161
+ - `shapeCast(ray, shape, rotation, result, filter?)` broadphase swept
162
+ AABB against both BVHs; per-candidate AABB-slab interval narrowing,
163
+ coarse step over the narrowed window, GJK bisection to time-of-impact.
164
+ Output normal is the true contact-surface normal at the kiss point,
165
+ recovered by re-running GJK + EPA at `best_t` on the winning candidate.
166
+ Falls back to `-ray.direction` only on EPA degeneracies (NaN / zero
167
+ depth). Tests cover axis-aligned, off-axis, and oblique cube-vs-cube;
168
+ sphere-vs-smooth-shape near-tangent has documented angular tolerance
169
+ bands inherited from EPA on smooth supports.
170
+ - `overlap(shape, position, rotation, output, output_offset, filter?)`
171
+ broadphase + per-candidate GJK overlap detection. Writes body_ids
172
+ into a caller-sized buffer; returns count. Convex query shapes only
173
+ (concave throws). Concave candidates routed through the per-triangle
174
+ decomposition path. Designed for speculative kinematic queries on
175
+ kinematic bodies (character controllers, AOE selection).
176
+
177
+ ### Standalone narrowphase utilities
178
+ - `deepest_pair_penetration(out_normal, shapeA, posA, rotA, shapeB, posB,
179
+ rotB)` (exported from `narrowphase_step.js`) runs the **same**
180
+ `dispatch_pair` the contact solver consumes for one posed shape pair and
181
+ returns the DEEPEST contact's depth + world normal (B → A). The single
182
+ source of truth for "minimum-translation between two posed shapes", reused by
183
+ `compute_penetration` (and available to any other query).
184
+ - `compute_penetration(out_direction, shape_a, pos_a, rot_a, shape_b,
185
+ pos_b, rot_b)` — non-system geometry primitive: positive penetration
186
+ depth + outward direction (B → A convention) on overlap, 0 otherwise.
187
+ **Hardened** delegates to `deepest_pair_penetration`, so it is correct
188
+ (not "correct sometimes") for every shape pair the engine can build:
189
+ - sphere / box / capsule pairs exact closed-form (box-box via SAT, so a
190
+ small body resting on a large floor reports the few-cm near-face overlap,
191
+ NOT the metres-deep "exit through the far side" that MPR's centroid-seeded
192
+ portal used to return);
193
+ - general convex pairs GJK + EPA (exact for polytopes; curved shapes never
194
+ reach it they have closed forms);
195
+ - convex × concave triangle decomposition + the closed-form per-triangle
196
+ solvers, bounded to each triangle's true 2-D extent (the old closed-mesh
197
+ side-face over-report is gone).
198
+ The previous per-triangle half-space test is retained ONLY as a recovery
199
+ fallback for the one case the one-sided closed forms can't resolve: a convex
200
+ shape that has fully tunnelled to the *inner* side of a concave surface (a
201
+ depenetration query must still push it back out — exact for heightmap terrain,
202
+ a valid outward push for closed meshes). Concave × concave throws (M×N
203
+ triangle pairs out of scope). The spec asserts an "applying out_direction ×
204
+ depth separates the shapes" invariant across every convex+convex pair type and
205
+ convex+concave, plus exact per-type depths and the small-box-on-huge-floor
206
+ regression (3 m → 0.05 m).
207
+
208
+ ### Determinism
209
+ - Direct typed-array writes on hot paths (bypassing `Vector3#set`'s observer
210
+ dispatch) Transform writes still go through `set()` because external
211
+ systems subscribe (TransformAttachment, EntityNode, FogOfWarRevealer,
212
+ ViewportPosition).
213
+ - Active body iteration sorted by body index.
214
+ - Pair canonicalisation `(min, max)`.
215
+ - Min-heap free list for slot reuse.
216
+ - No `Math.random` anywhere in the simulation step.
217
+ - Same-runtime bit-exact determinism by design; cross-runtime is a known
218
+ future seam.
219
+
220
+ ### Migration
221
+ - `Motion` / `MotionSystem` / `MotionSerializationAdapter` relocated from
222
+ the meep core (`engine/ecs/`) to the game-domain layer
223
+ (`mir-engine/model/game/ecs/`). meep no longer ships the legacy shim.
224
+
225
+ ### Alternative narrowphase: MPR
226
+ - `engine/physics/gjk/mpr.js` Minkowski Portal Refinement (XenoCollide,
227
+ Snethen GDC 2009). Single-pass overlap test + MTV computation,
228
+ output convention matches EPA so it's drop-in compatible at any
229
+ narrowphase call site. Tends to converge in 5–15 iterations on
230
+ smooth shapes where EPA stalls (the polytope-on-curved-surface
231
+ failure mode the torus-knot reproducer exercised). **Wired as the EPA
232
+ non-convergence fallback** in `narrowphase_step` at both the body-level
233
+ and per-triangle GJK+EPA paths: when EPA returns a non-positive / non-finite
234
+ depth, MPR is tried before giving up. `shape_cast` and `compute_penetration`
235
+ use it for the same reason.
236
+
237
+ ### Bonus utilities
238
+ - `core/geom/3d/line/line3_closest_points_segment_segment.js` — generally
239
+ useful 3D segment-segment closest-pair via Ericson §5.1.9.
240
+ - `core/collection/PairUint32Map.js` — non-allocating
241
+ `Map<(u32, u32) u32>` with Robin Hood + Fibonacci hash.
242
+
243
+ ---
244
+
245
+ ## Limitations / Known caveats
246
+
247
+ - **Multi-collider material precision** *resolved for contact materials.* The
248
+ narrowphase now combines the specific source-collider pair's friction /
249
+ restitution per contact and stores them in the manifold (CONTACT_STRIDE
250
+ offsets 14/15); the solver reads them per contact, so mixed-material compound
251
+ bodies are accurate (regression test: an asymmetric-friction body yaws when
252
+ shoved). Still primary-collider only: the contact-filter callback's
253
+ `colliderA/B` arguments and the body-level sensor / concave-dispatch flags
254
+ a smaller follow-up.
255
+ - **EPA on smooth shapes**: degenerates (no flat face to converge on).
256
+ Mitigated by closed-form paths for sphere/cube/capsule pairs and by the
257
+ **MPR fallback** on EPA non-convergence; exotic convex shapes vs spheres can
258
+ still occasionally fail if both EPA and MPR degenerate.
259
+ - **EPA on `Triangle3D`** *resolved.* The concave dispatch now uses the
260
+ closed-form `sphere_triangle_contact` / `box_triangle_contact` /
261
+ `capsule_triangle_contact` solvers (P1.1a–c) instead of per-triangle GJK+EPA
262
+ for those primitives, so a sphere/box/capsule on a heightmap or mesh decelerates
263
+ and settles correctly; the `narrowphase_concave.spec.js` "drop and settle"
264
+ cases and the mesh torus-knot settle test are **un-skipped**. Per-triangle
265
+ GJK+EPA remains only as the fallback for *other* convex shapes vs triangles.
266
+ (`compute_penetration` now routes through that same dispatch via
267
+ `deepest_pair_penetration` see *Standalone narrowphase utilities* instead
268
+ of its old half-space pre-test; the half-space test survives only as a
269
+ tunnel-recovery fallback.)
270
+ - **Box-box edge-edge contact**: a single point at the true closest-pair of the
271
+ two edges (P3.2), not the old body-centre midpoint. This is geometrically
272
+ correct and an empirical SAT-source sweep confirms the edge-cross branch
273
+ *only* fires for **transverse** edge crossings (inter-edge angle 83-90°),
274
+ where two skew lines meet at a unique point. Near-parallel edge contacts
275
+ cannot reach this branch (a near-parallel `edgeA × edgeB` never wins the SAT
276
+ minimum) they resolve through the multi-point face-clipping path. So the
277
+ once-planned "multi-point edge contact for near-parallel edges" refinement is
278
+ **moot**; see the resolved Stability backlog entry.
279
+ - **CCD floor only**: speculative margin via the fattened AABB prevents
280
+ most tunnelling. No per-body swept shape-cast for very fast objects.
281
+ - **Cross-runtime determinism is not guaranteed**: `Math.sin/cos/exp/log`
282
+ are ULP-correct but not bit-exact across V8 / SpiderMonkey / JSC.
283
+ - **Dynamic concave bodies under TGS** *resolved by per-substep re-detection
284
+ (below); kept here for the rationale.* The substep loop normally re-derives
285
+ contact geometry analytically from the per-triangle contact feature (witness
286
+ anchors + normal) captured once by narrowphase and held fixed for the whole
287
+ outer step. For a convex body the contact feature is stable under the small
288
+ per-step motion, so this is exact; for a *dynamic concave mesh body* (e.g. a
289
+ torus knot rocking on its own lobes) the supporting triangle itself changes
290
+ as the body rocks, so freezing the feature would pump a little energy in and
291
+ the body would rock / slowly sink instead of settling. Note this is NOT a
292
+ contact-precision issue —
293
+ the knot already uses the exact closed-form box-triangle solver (P1.1b);
294
+ the problem is purely that TGS freezes *which* feature is in contact across
295
+ substeps. The common concave case a convex dynamic body on static concave
296
+ terrain is unaffected (the convex side's feature is stable), and that is
297
+ the only concave case the engine targets.
298
+
299
+ **Interim fix (implemented): per-substep concave re-detection.** For
300
+ contact pairs involving a concave body, the substep loop re-runs the
301
+ concave narrowphase geometry at the current substep pose (instead of the
302
+ analytic refresh that freezes the feature) and re-prepares those contacts
303
+ from the fresh witness/normal/depth so the contact normal tracks the
304
+ rocking body and no energy is pumped in. Convex pairs keep the cheap
305
+ analytic refresh. This is ~Nx narrowphase cost on concave-involved pairs
306
+ (acceptable they're rare), gated by collider convexity. Un-skips the
307
+ torus-knot dynamic-settle test.
308
+
309
+ **Better long-term fix: convex collision proxies (not raw concave).** Every
310
+ major engine (Box2D, Jolt, PhysX, Rapier) requires dynamic bodies to be
311
+ convex or convex-decomposed; raw concave meshes are static-only. The right
312
+ granularity is a *few* convex pieces NOT the thousands of tets a
313
+ volumetric mesher produces (tet count ≈ collider/BVH-leaf count, which
314
+ explodes the broadphase for an awake body; tet meshing is for a future
315
+ FEM/soft-body subsystem, not rigid collision). See the "Convex collision
316
+ proxies for dynamic concave bodies" backlog item — a 3D convex hull builder
317
+ (single-hull proxy covers most dynamic objects) plus an optional
318
+ few-hull (V-HACD-style) decomposition. Those supersede the interim
319
+ per-substep re-detection once built.
320
+
321
+ ---
322
+
323
+ ## Backlog (planned, in scope)
324
+
325
+ ### Solver quality (next major work)
326
+
327
+ These items move the engine from "competent" to "great". TGS is the next
328
+ significant solver-architecture change; joints come after, once the TGS
329
+ scaffolding is in place.
330
+
331
+ - **TGS (Temporal Gauss-Seidel) substepping with split-impulse** Phases
332
+ 1–3 **LANDED**. The solver is now a staged TGS pipeline
333
+ (`solver/solve_contacts.js`: `prepare_contacts` per substep
334
+ [`refresh_contacts` `warm_start_contacts` `solve_velocity`
335
+ `solve_position`] `apply_restitution`), driven by the substep loop in
336
+ `PhysicsSystem.fixedUpdate`. Defaults: `substeps = 4`,
337
+ `velocityIterations = 4`, `positionIterations = 1` (all fields on
338
+ `PhysicsSystem`).
339
+ - **Phase 1 split impulse.** Position correction runs on a per-body
340
+ pseudo-velocity (`__pseudo_velocity`) folded into the pose by
341
+ `integrate_position` and discarded; depth correction never
342
+ contaminates persistent velocity.
343
+ - **Phase 2 one-shot restitution.** Velocity pass is pure
344
+ non-penetration; restitution is a single post-loop pass driving
345
+ `vn → -e·vn_approach`, gated on a running max normal impulse
346
+ (`maxNormalImpulse`) so transient collisions still bounce under
347
+ per-substep warm-start.
348
+ - **Phase 3 substep loop.** `substeps` sub-iterations at `h = dt/N`.
349
+ Forces consumed once at full `dt` before the loop; gravity applied
350
+ per substep; **warm-start replayed per substep** (the crux — a
351
+ per-substep impulse balances one substep of gravity, so resting
352
+ stacks hold at zero velocity). Contact geometry is re-derived
353
+ **analytically** each substep from frozen local witness anchors +
354
+ the trusted prepare-time depth (a sign-robust delta), so narrowphase
355
+ runs **once** per outer step cheaper than the originally-planned
356
+ per-substep match-and-merge refresh, and exact for convex
357
+ primitives whose contact feature is stable under small motion.
358
+
359
+ Results vs the single-step solver: a 100:1 mass ratio now stacks
360
+ instead of crushing through (regression test added); 8-cube stacks
361
+ settle to zero velocity and sleep (were impossible long-term under SI);
362
+ falling-tower bench cost unchanged (~48 ms/1000 active bodies);
363
+ `substeps = 1` reproduces the single-step result bit-for-bit-ish
364
+ (one-frame restitution delay aside).
365
+
366
+ **Hard-won lessons (for REVIEW_002):**
367
+ - Warm-start MUST be per-substep, not once. Replaying a full-frame
368
+ impulse once while gravity arrives per substep over-pushes resting
369
+ contacts and *explodes* deep stacks. Per-substep warm-start +
370
+ per-substep gravity cancel exactly at rest.
371
+ - Restitution must gate on the *running max* normal impulse, not the
372
+ end-of-loop value — per-substep warm-start relaxes a transient
373
+ contact's `j_n` back to ~0 by the end, which would suppress the
374
+ bounce.
375
+ - Analytic separation re-derivation beats per-substep narrowphase
376
+ for convex shapes (cheaper, no manifold-lifecycle churn) but is
377
+ only as good as the frozen normal — see the concave caveat below.
378
+
379
+ Follow-ups since the core landed:
380
+ - [x] **Box-box SAT reference tie-break deadband** — aligned cube
381
+ stacks (4–10 high) now settle to zero velocity and sleep; the
382
+ reference-face flip-flop that creeped/toppled them is gone.
383
+ - [x] **Per-substep contact re-detection for concave pairs** lifts
384
+ the dynamic-concave-body limitation; the torus-knot dynamic-settle
385
+ test is un-skipped. Concave pairs re-run narrowphase geometry each
386
+ substep (`redetect_concave_contacts`); convex pairs keep the cheap
387
+ analytic refresh.
388
+
389
+ Remaining (Phases 4–6) now complete:
390
+ - [x] Regression coverage: heavy-on-light pyramid (10× capstone on two
391
+ light cubes settles + sleeps) and a ragdoll-stub (shoulder
392
+ ball-socket + elbow hinge arm hangs, stays articulated, settles).
393
+ - [x] REVIEW_002 retrospective`engine/physics/REVIEW_002.md`.
394
+
395
+ References: Catto 2018 ("Soft Constraints" GDC talk + the TGS
396
+ follow-up); Box2D v3 source (`b2ApplyRestitution`, the substep solver
397
+ stages); Rapier as the closest architectural sibling.
398
+
399
+ - [x] **Constraints / joints — DONE (phases 1–7 below).** One configurable
400
+ 6-DOF joint (lock/free/limit/motor/spring + swing-twist cone-twist) plus
401
+ the raycast vehicle. Covers chains/ropes, ragdolls, vehicles (incl.
402
+ suspension), and the mechanical set (doors, pistons, welds, sliders,
403
+ powered hinges/wheels). The design rationale below is kept as history; the
404
+ phasing checklist records what landed. Solver/joint retrospective in
405
+ `REVIEW_003.md`.
406
+
407
+ Original framing (now satisfied): TGS unblocked it (joint-chain
408
+ convergence is a TGS sweet spot), warm-start + per-substep + island
409
+ machinery was reusable, and the SPOOK compliance dial gave spring
410
+ constraints essentially for free.
411
+
412
+ **Foundational work (do first): generalise the solver to constraint
413
+ rows.** Today `solver/solve_contacts.js` is hard-coded to the
414
+ contact-shape constraint (normal + 2 friction tangents, ≥0 clamp,
415
+ restitution, penetration bias). Joints are equality / inequality
416
+ constraints on relative velocity at anchors, generally bilateral
417
+ (impulse may be ±) with optional limits and motors. The clean shape —
418
+ and what Jolt / Box2D-v3 do — is a **generic constraint row**: a
419
+ Jacobian (linear + angular parts per body), an effective mass, a bias
420
+ (position error × SPOOK gain, or motor target), and impulse bounds
421
+ `[lo, hi]` (`[0,∞)` for a contact/limit, `(−∞,∞)` for an equality,
422
+ `[−maxForce·h, +maxForce·h]` for a motor). Each joint type just fills
423
+ in its rows; the existing per-body impulse-apply primitive
424
+ (`apply_impulse_to_body` + `world_inverse_inertia_apply`), the
425
+ per-substep warm-start, the islands, and the split-impulse / SPOOK
426
+ position handling are all reused. Contacts become *one* constraint
427
+ type among several rather than the hard-coded path.
428
+
429
+ The specific constraint set, its use-case mapping, and per-type
430
+ architecture-fit assessment are under review (see the constraints
431
+ sketch). High level: ball-socket / distance / spring / weld and the
432
+ grab constraint are near drop-ins on the row machinery; hinge /
433
+ prismatic / cone-twist / motors / limits add angular-row + bounded-row
434
+ mechanics (still within the impulse framework); raycast vehicles,
435
+ conveyor surface-velocity, and gear/pulley coupling are higher-level
436
+ systems or contact modifiers that sit *on top of* the primitives
437
+ rather than being generic rows.
438
+
439
+ **Decision: build ONE configurable 6-DOF constraint** (PhysX D6 / Jolt
440
+ SixDOF), implemented mode-by-mode. The `Joint` ECS component carries
441
+ `dofMode[6]` (3 linear, 3 angular) each `{locked|free|limited|spring|
442
+ motor}` + per-DOF limit/spring/motor config + warm-start accumulators.
443
+ Concrete joints are configs, not new code (ball-socket = lock 3 linear;
444
+ hinge = lock 3 linear + 2 angular; weld = lock 6; cone-twist = lock 3
445
+ linear + limit 3 angular; suspension = spring 1 linear + lock rest).
446
+
447
+ Phasing:
448
+ 1. [x] Constraint-row solver as a **parallel row set** in the TGS
449
+ substep loop (contacts left untouched, not ported lower risk).
450
+ `constraint/solve_constraints.js` reuses `world_inverse_inertia`,
451
+ per-substep warm-start, and the SPOOK position bias; `Joint`
452
+ component + `link_joint`/`unlink_joint` in PhysicsSystem;
453
+ `jointIterations` knob. Bodies need no collider.
454
+ 2. [x] **LOCKED linear DOFs ball-socket.** Pendulum (anchor pinned
455
+ to a world pivot, body swings) and a 2-link chain (body↔body,
456
+ joints stay connected, chain hangs) pass. **chains, ropes,
457
+ pendulums working.**
458
+ 3. [x] LOCKED angular + linear DOFs in the frame basis — **weld,
459
+ hinge, prismatic done**. Joint frame bases
460
+ (`localBasisA`/`localBasisB`); BOTH linear and angular rows now
461
+ resolve in frame A's axes (cleared the world-axis linear debt — the
462
+ solver is fully frame-relative). Angular: relative rotation
463
+ `qD = conj(qA)·qB` → small-angle error, ωB−ωA rows + SPOOK bias.
464
+ Linear: `C·axis` error, vA−vB rows. `asWeld()` / `asHinge(axis)` /
465
+ `asPrismatic(axis)` presets. Verified: weld holds pose + orientation
466
+ against an off-centre torque; hinge swings about its free axis only
467
+ (locked axes < 0.02); prismatic slides along its one free axis,
468
+ locked on the others; all LOCKED-mode tests still green after the
469
+ frame-basis rewrite.
470
+ 4. [x] LIMITED + MOTOR (bounded rows) doors, pistons, wheel
471
+ spin/drive, joint ROM. **LIMITED done** (linear + angular):
472
+ `setLinearLimit(axis,lo,hi)` / `setAngularLimit(axis,lo,hi)` set a
473
+ per-DOF travel/ROM range. The whole row set is now **one mode-
474
+ agnostic solve** parameterised by `(bias, clamp range)`: LOCKED is
475
+ the bilateral case (Baumgarte bias, unclamped); LIMITED is a
476
+ **speculative (β=1) one-sided velocity constraint** that removes
477
+ exactly the approach velocity so the DOF *lands on* its stop (no
478
+ penetration, no reboundan inelastic end-stop) and self-gates when
479
+ far from the bound; only the push-out side of the bias is clamped so
480
+ a teleport is eased out, not yanked. Verified: a vertical slider
481
+ falls freely then stops dead on its lower stop (lands at the bound,
482
+ no overshoot/rebound, locked axes held); a spun hinge stops dead on
483
+ each ±end-stop with no rebound and holds. Angular position is the
484
+ small-angle measure (`2·sin(θ/2)`) accurate for modest ROM, see
485
+ phase 6 for wide cones. **MOTOR next** (target-velocity row, impulse
486
+ clamped to `±maxForce·h`).
487
+ 5. [x] SPRING (SPOOK soft) suspension, bungees, soft ragdolls.
488
+ `setLinearSpring(axis,k,c)` / `setAngularSpring(axis,k,c)`. A
489
+ compliant (regularised) row in the same unified solve: per substep
490
+ `denom = c + h·k`, compliance = 1/(h·denom)`, restoring bias
491
+ `(k/denom)·C`, softened mass `1/(K+γ)`; the iteration carries one
492
+ extra `+ γ·λ_accum` term = 0 ⇒ the LOCKED/LIMITED/MOTOR rows are
493
+ bit-for-bit unchanged). Verified: a vertical strut settles at exactly
494
+ the m·g/k deflection and a stiffer spring sags less and stays stable;
495
+ an undamped spring oscillates about equilibrium (stores energy) while
496
+ a damped one comes to rest; a torsional spring holds a gravity-loaded
497
+ hinge at its balance angle. Suspension element ready (the simulated-
498
+ wheel option for phase 7); also the soft basis for cone-twist.
499
+ 6. [x] Cone-twist / swing-twist angular limits ragdolls. Opt-in
500
+ `Joint.swingTwist` (or the `asConeTwist(twistLo,twistHi,swingY[,swingZ])`
501
+ preset) switches the angular position measure from the per-axis
502
+ small-angle vector to a swing-twist decomposition: angular X = twist
503
+ about the bone, Y/Z = swing off it, each an **exact** angle. The
504
+ existing LIMITED/SPRING/LOCKED rows are reused unchanged on those
505
+ positions, so a twist/swing limit holds at the true angle at wide
506
+ ROM (a 1.2 rad swing stops at 1.2, where the small-angle proxy
507
+ drifts to ~1.287). Verified: exact swing/twist stops, free-within-
508
+ cone, twist/swing independence; default (small-angle) path untouched.
509
+ **Decision inlined, not the Quaternion method.** Benchmarked the
510
+ allocation-free inlined `swing_twist_error` against
511
+ `Quaternion.computeSwingAndTwist` (`swing_twist.bench.spec.js`): the
512
+ inline is **~5x** faster than the method with reused out-params and
513
+ **~10x** vs the naive fresh-allocation form (object property access +
514
+ normalize + a quaternion multiply + GC). In the per-substep
515
+ per-joint hot loop that margin is worth the duplicated math, so the
516
+ solver inlines it (the Quaternion method stays for general callers).
517
+ 7. [x] Vehicle layer **raycast-vehicle controller**
518
+ (`vehicle/RaycastVehicle.js`): single chassis body + raycast wheels.
519
+ Per frame (before `fixedUpdate`) each wheel casts its suspension ray,
520
+ applies a spring+damper suspension force along the contact normal
521
+ (`applyForceAt`), and a tyre-friction impulse (`applyImpulseAt`)
522
+ lateral grip that cancels side-slip plus longitudinal drive/brake,
523
+ clamped together to a friction circle μ·N. `addWheel`, `setSteering`,
524
+ `setDriveForce`, `setBrake`; per-wheel runtime (contact, compression,
525
+ normal, spin) for rendering. A controller on top of the public
526
+ `raycast` + force API, not a new constraint; the 6-DOF spring+motor
527
+ is the simulated-wheel alternative. Verified: hovers on its springs
528
+ (4 contacts, settled), drives/coasts/brakes along its axis, tyre grip
529
+ arrests a sideways shove, steering turns it upright, and it free-falls
530
+ cleanly when airborne. Note: suspension is one dt-force per frame (not
531
+ per-substep), so a resting chassis carries a ~g·h velocity-sample
532
+ artifact (it hovers stably; position is steady to sub-cm). Ray
533
+ accuracy follows `PhysicsSystem.raycast` now narrowphase-exact for
534
+ sphere / box / capsule / mesh / heightmap ground.
535
+ 8. [ ] Extras: pulley, gear, conveyor (contact surface-velocity),
536
+ breakable-joint flag.
537
+
538
+ Foundation gaps both now closed:
539
+ - [x] **Island integration.** Jointed dynamic-dynamic bodies are
540
+ unioned into one island (`IslandBuilder` Pass 1b), so a chain /
541
+ ragdoll sleeps and wakes as a unit; `__wake_joints` propagates wake
542
+ across a joint when one side is awake and the other asleep
543
+ (e.g. a kinematic/motor driver pulling a sleeping chain). Verified:
544
+ a damped chain settles and both links sleep in one sleep group.
545
+ - [x] **Generation-checked body references.** `solve_joints`,
546
+ `IslandBuilder` Pass 1b and `__wake_joints` all gate on
547
+ `storage.is_valid(packedId)`, so a joint to an unlinked / slot-reused
548
+ body goes inert instead of attaching to the wrong body or crashing.
549
+ Verified: unlinking a jointed body leaves the joint inert and the
550
+ survivor free.
551
+
552
+ References: Catto / Box2D-v3 joint solvers; Jolt's `Constraint` base
553
+ (`SetupVelocityConstraint` / `WarmStartVelocityConstraint` /
554
+ `SolveVelocityConstraint` / `SolvePositionConstraint`); PhysX D6 /
555
+ ODE joint taxonomy.
556
+
557
+ ### Stability
558
+ - [x] **Closed-form triangle-vs-primitive solvers** `sphere_triangle_contact`
559
+ / `box_triangle_contact` / `capsule_triangle_contact` (P1.1a–c), wired into
560
+ the concave decomposition dispatch in place of per-triangle GJK+EPA for
561
+ those primitives. Un-skipped the `narrowphase_concave.spec.js` ball-on-
562
+ heightmap / mesh-cube settle tests and the `PhysicsSystem.spec.js`
563
+ torus-knot test. Per-triangle GJK+EPA remains only as the fallback for
564
+ *other* convex shapes vs triangles. `compute_penetration` now routes
565
+ through the shared narrowphase dispatch (`deepest_pair_penetration`), so it
566
+ uses the closed-form per-triangle solvers too the old closed-mesh
567
+ over-report is gone; the half-space test is retained only as a
568
+ tunnel-recovery fallback.
569
+ - [x] **Edge-edge multi-point manifold** *resolved by design (no code
570
+ change needed).* An empirical SAT-source sweep over a wide range of
571
+ box-box orientations shows the single-point edge-cross branch only ever
572
+ wins for **transverse** edge crossings (inter-edge angle 83-90°), where
573
+ a single closest-pair point is geometrically exact. A near-parallel edge
574
+ pair gives a near-degenerate `edgeA × edgeB` that never becomes the SAT
575
+ minimum, so near-parallel ("line") edge contacts resolve through the
576
+ multi-point **face-clipping** path instead confirmed by regression
577
+ tests in `box_box_manifold.spec.js` (near-parallel tilted boxes → ≥ 2
578
+ points; transverse crossing exactly 1 exact point). The originally
579
+ planned refinement targeted a case the geometry can't produce, so it is
580
+ closed rather than implemented.
581
+ - [x] **Per-contact source-collider tracking (materials)** multi-material
582
+ compound bodies now get accurate per-contact friction / restitution. The
583
+ narrowphase combines the specific (colliderA, colliderB) pair's
584
+ coefficients at dispatch time (the only place that knows the source
585
+ collider on each side — `contact/combine_material.js`) and stamps them
586
+ into the manifold (CONTACT_STRIDE grown 14 16, offsets 14/15); the
587
+ solver reads them per contact instead of from the body's primary collider.
588
+ Regression test: an asymmetric-friction compound body yaws when shoved
589
+ (the grippy collider drags), and a symmetric control does not. Still
590
+ primary-collider-only: the contact-filter callback's collider args and the
591
+ body-level sensor / concave flags (smaller follow-up).
592
+ - [ ] **Joint-aware island sleep (ragdoll settle quality).** A draped,
593
+ self-colliding 10-joint ragdoll does not fully sleep in 10 s — surfaced by
594
+ a 1000-seed Monte-Carlo sweep (`PhysicsSystem.ragdoll.spec.js`, `.skip`):
595
+ for unlucky seeds a distal limb sustains a settled limit cycle (settled
596
+ finite-difference accel up to ~1094 m/ / ~1479 rad/s² at a limb end vs a
597
+ ~55 m/s² median bounded, non-growing, penetration-free, so a quality gap
598
+ not a divergence). The sleep test today is per-body `|v|²+|ω|²`; an island
599
+ over-constrained by cone-twist limits + self-contacts keeps small residual
600
+ jiggle above the per-body threshold so it never crosses into sleep.
601
+ Candidate fixes: sleep a jointed/contacting island on its AGGREGATE motion
602
+ rather than the per-body minimum, and/or a settled-regime relaxation (zero
603
+ restitution + extra position iterations) once an island's energy is low.
604
+ The sweep flags the worst seeds for replay. (Test infra also adds
605
+ per-point kinematics tracking joint anchors + limb ends, with
606
+ displacement→velocity→acceleration and the angular equivalents.)
607
+ - [x] **Box-on-heightmap settlingRESOLVED.** A dynamic box dropped onto a
608
+ static HeightMapShape flat seam straddle AND the sloped dip — settles to
609
+ full rest; the `PhysicsSystem.heightmap.spec.js` dip-drop reproducer is
610
+ un-skipped and passing. Fixed by the combination of the same-feature-id
611
+ contact de-dup in `redetect_pair_geometry` (1:1 claimed matching, so the
612
+ several contacts one triangle emits all sharing its feature_id no longer
613
+ collapse onto a single candidate) and the HeightMapShape3D
614
+ collision-tessellation work; together they give the box a stable per-substep
615
+ contact set instead of a churning / collapsing one.
616
+
617
+ Root cause, for the record: the historical "never settles" rattle was a
618
+ depth divergence between the once-per-step `narrowphase_step` and the
619
+ per-substep `redetect_pair_geometry`. At the SAME pose, re-detection
620
+ over-reported a box-vs-triangle penetration as ~1 m (the "exit through the
621
+ far side" class) where narrowphase reported ~1 cm, launching the box into an
622
+ eternal vertical bounce (touch ~1 m over-correction separate → fall back
623
+ re-contact). Both paths call the identical `dispatch_pair`, so the
624
+ divergence was the same-fid collapse corrupting the matched geometry; with
625
+ the de-dup in place the two paths now agree (verified: both report
626
+ [0.0100 ×4] at a 0.01-deep flat contact, in-cell and 4-triangle straddle).
627
+
628
+ Guards: `narrowphase/redetect_pair_geometry.spec.js` now pins BOTH
629
+ invariants contacts sharing a feature_id keep distinct witnesses, and
630
+ re-detect depth == narrowphase depth at a fixed pose (in-cell and
631
+ 4-triangle seam-straddle cases); `ecs/PhysicsSystem.heightmap.spec.js` pins
632
+ the observable settle. The depth-equality regression test the earlier
633
+ diagnostic trail asked for is in place, closing this out.
634
+
635
+ ### Performance / Scale
636
+ - [x] **Per-body linear CCD shape-cast** opt-in continuous collision for
637
+ fast movers where the speculative margin isn't enough.
638
+ `RigidBodyFlags.CCD` (off by default) + `ccd/linear_sweep.js`.
639
+ - **Approach (Box2D `b2_continuousPhysics`-style conservative
640
+ advancement).** After the substep solver produces each body's final
641
+ pose (between `apply_restitution` and the sleep test), a flagged fast
642
+ mover's primary collider is swept along its NET step translation
643
+ (start-of-step final pose) through the existing `shape_cast` TOI
644
+ engine. On the first blocker the body is clamped to the contact pose
645
+ and its inbound normal velocity removed (an inelastic stop); the next
646
+ discrete step resolves the now-touching contact with the real
647
+ material / restitution. Reuses `shape_cast` wholesale — no new
648
+ geometry. Start positions captured in Stage 1 over the post-wake awake
649
+ set; the pass iterates the awake list in storage order (deterministic).
650
+ - **Motion gate absolute slop, NOT body extent.** A body is swept when
651
+ it moved more than `CCD_MIN_SWEEP_DISTANCE` (1 mm) this step. The gate
652
+ exists only to skip near-stationary bodies (degenerate sweep + cost).
653
+ It is deliberately *not* a fraction of the body's own size: tunnelling
654
+ risk is set by the **obstacle's** thickness, not the mover's a 2 m
655
+ sphere drifting 0.5 m/step still passes clean through a 1 cm floor, so
656
+ an extent-based gate would (wrongly) wait until the body moved more
657
+ than its own radius and miss every thin-obstacle tunnel below that
658
+ speed.
659
+ - **No self-clamp on resting/sliding contacts.** The sweep ignores an
660
+ impact at `t 0` (`CCD_INITIAL_OVERLAP_EPS`): an initial overlap is a
661
+ contact the body already sits/slides on, owned by the discrete solver,
662
+ not a tunnel clamping there would freeze the body to the surface.
663
+ - **Measured (falling-tower bench, 1000 random shapes onto a 1 cm floor,
664
+ 600 ticks; clean A/B on identical code via `ccdEnabled`).** This bench
665
+ is the WRONG validation vehicle for CCD, and the numbers prove it: CCD
666
+ off **10/1000 tunnel**, median **42.7 ms**/step; all 1000 flagged
667
+ **50/1000 tunnel**, median **61.6 ms**. CCD makes it *worse*. The bench
668
+ is a dense-pile **squeeze-through** scenario 1000 bodies stacked on a
669
+ 1 cm floor, forced through by the column's weight over many steps — which
670
+ is a *solver* limitation, not a missed single-step fly-through. CCD's
671
+ post-solve clamp + velocity-kill fights the solver in a dense settling
672
+ pile (it teleports mutually-stacking dynamics and breaks warm-start), so
673
+ flagging a whole pile is an anti-pattern. CCD's real job stopping a
674
+ sparse fast mover against thin geometry — is validated by
675
+ `ccd/linear_sweep.spec.js` (9 tests: a fast cube tunnels a thin floor
676
+ without the flag and is stopped with it; deterministic; resting bodies
677
+ undisturbed). **Correction:** an earlier version of this entry and the
678
+ commit message (`42163b0d4`) claimed a "0/1000 tunnel, 58.2 ms" on-leg
679
+ that was never measured (a session tooling glitch); the real on-leg
680
+ is 50/1000 / 61.6 ms.
681
+ - **Scope (v1, documented):** linear sweep only (orientation fixed
682
+ through the sweep); primary same-entity collider (child-entity
683
+ colliders, synced outside the step, are not swept); EXACT against
684
+ static geometry, APPROXIMATE against other dynamics (their
685
+ start-of-step broadphase AABBs); the CCD stop is inelastic (the impact
686
+ itself doesn't bounce restitution applies on the next discrete
687
+ contact); Dynamic bodies only. A body both resting on one surface and
688
+ tunnelling another in the same step resolves only the resting contact
689
+ (the `t 0` skip) — rare; the next discrete step catches the rest.
690
+ - **Follow-ups (the dense-pile finding points the way):** sweep against
691
+ STATIC geometry only by default the dynamic-vs-dynamic clamp is the
692
+ source of the dense-pile interference above and is only ever
693
+ approximate anyway, so dropping it should make CCD purely additive
694
+ (stops you at static walls/floors, never fights the dynamic stack);
695
+ a proper TOI sub-solver for bullet-vs-dynamic; rotational / angular
696
+ CCD; multi-collider sweep for compound bodies.
697
+ - [x] **Broadphase BVH balance SAH rotation.** The dynamic AABB tree
698
+ (`core/bvh2/bvh3/BVH.js`, a Box2D port) used SAH-cost insertion but a
699
+ *height-only* AVL rotation (`balance_height`): height-balanced yet not
700
+ SAH-balanced, so queries walked more nodes than needed. Replaced the
701
+ rotation in `bubble_up_update` with `balance_rotate` the Box2D-v3 /
702
+ Kensler SAH-reducing rotation (for node A with children B, C, evaluate the
703
+ four child↔grandchild swaps and apply the one that most reduces the
704
+ surface-area cost). Deterministic; identical pair set.
705
+ - Measured (same-session A/B, heavy benches): raycast **−9%**
706
+ (28.2→25.6 µs/ray), falling-tower median **−10%**, settling-grid
707
+ median **−12%**, and the **990/1000-churn stress −27%**
708
+ (63.95→46.68 ms mean over 10k ticks) — biggest where the tree churns
709
+ hardest. Determinism (8-trial bit-identical) holds.
710
+ - **Insertion cost (measured):** `balance_rotate` does 4 surface-area
711
+ evaluations per bubble-up level vs `balance_height`'s single height
712
+ compare, so *pure bulk insertion* is **~1.4–1.5× slower** the 100k
713
+ synthetic insert bench (`BVH.spec.js`, drift-controlled interleaved
714
+ A/B) drops from **~37k ~25k inserts/sec** (~27→~40 µs/insert). This
715
+ is the balancer's worst case (insert-only, zero queries/refits to
716
+ amortise against). It does not show up end-to-end: static trees are
717
+ built once then queried forever, dynamic bodies insert once then
718
+ refit/query every frame, and even the 990/1000-swap stress test — the
719
+ maximal insert-churn workload is net **−27%**. Accepted.
720
+ - **Tradeoff (documented):** the contact solver's Gauss-Seidel order
721
+ follows broadphase traversal order (see `generate_pairs`), so the
722
+ different tree shape shifts convergence on near-aligned stacks the
723
+ synthetic 128-cube wall now sleeps at ~10 s (was ~6.9 s). It still
724
+ settles, doesn't creep / topple (all bug-guard assertions hold); only
725
+ the sleep *time* moved (that test's budget was bumped 9→11 s with a
726
+ note). Random-shape scenes (falling tower) were faster *and* settled
727
+ fine.
728
+ - **Follow-up:** decouple the solve order from tree shape — sort the
729
+ broadphase pair list by `(idA, idB)` before narrowphase so contact
730
+ order is body-id-deterministic regardless of tree shape. Then no tree
731
+ change can affect convergence (and the stack settles identically under
732
+ either balancer). Has a per-step sort cost + wide test re-baseline, so
733
+ it's its own task. `balance_height` is retained for comparison /
734
+ fallback.
735
+ - [ ] **Per-island parallel solve**: today's island data layout would
736
+ allow worker-based solving once `SharedArrayBuffer` is available.
737
+ Out-of-scope unless / until SAB is universally usable.
738
+
739
+ ### Features
740
+ - [ ] **Convex collision proxies for dynamic concave bodies.** The long-term
741
+ replacement for the interim per-substep concave re-detection (see
742
+ Limitations) and how every major engine handles dynamic non-convex
743
+ shapes: collide a *few* convex pieces, never the raw concave mesh.
744
+ 1. **3D convex hull builder** (meep has only 2D hulls today —
745
+ `core/geom/2d/convex-hull/`). A single hull of a mesh is one
746
+ collider / one broadphase leaf and covers the overwhelming majority
747
+ of dynamic objects (thrown props, debris). Pairs with the existing
748
+ "Convex hull shape + eigen-inertia" item below.
749
+ 2. **Few-hull (V-HACD-style) approximate convex decomposition** for
750
+ shapes whose concavity matters (a cup, a chair): ~8–64 fat convex
751
+ hulls = 8–64 colliders, two orders of magnitude below a tet mesh.
752
+ Each hull is convex stable contact feature → the TGS analytic refresh
753
+ is exact → no per-substep re-detection, no rocking. Granularity is the
754
+ whole point: collider/BVH-leaf count must stay small for an *awake*
755
+ dynamic body (the volumetric tet-mesher under `core/geom/3d/tetrahedra/`
756
+ is the wrong tool here thousands of pieces — and belongs to a future
757
+ FEM/soft-body subsystem, not rigid collision).
758
+ - [ ] **Convex hull shape** with eigen-based principal-axes inertia
759
+ derivation. Hooks `matrix_eigenvalues_in_place` from the existing
760
+ linalg layer.
761
+ - [~] **Cylinder / cone shapes.**
762
+ - [x] **`CylinderShape3D`** Y-aligned solid cylinder (radius + full
763
+ height, flat caps; the capsule's flat-cap sibling). Exact `support`,
764
+ capped-cylinder SDF, bounds, `contains` / `nearest_point` /
765
+ volume-sampling, equals/hash, `'cylinder'` JSON tag, `isCylinderShape3D`
766
+ marker. Convex routes through the narrowphase **GJK + EPA** fallback
767
+ (no marker dispatch needed); spec asserts overlap-detected +
768
+ MTV-separates vs sphere/box. Closed-form cylinder-vs-X contact pairs
769
+ are a future refinement (the curved side is the usual smooth-support
770
+ EPA case same status as pre-closed-form sphere/capsule).
771
+ - [ ] Closed-form cylinder contact pairs (cylinder × box / sphere / capsule
772
+ / plane) for multi-point cap manifolds + stable resting.
773
+ - [ ] **Cone shape** (+ closed-form / GJK fallback).
774
+
775
+ ### Rendering integration
776
+ - [ ] **Fixed-step render interpolation.** Not implemented yet. Physics writes
777
+ each body's pose straight into the ECS `Transform` once per fixed step
778
+ (`EntityManager.fixedUpdateStepSize`); with a render rate that doesn't match
779
+ the fixed rate, the rendered motion aliases (stutter / temporal aliasing,
780
+ worst at low fixed rates). The standard fix is to keep the previous and
781
+ current fixed-step pose per body and have the renderer lerp position /
782
+ slerp rotation by the accumulator's fractional remainder
783
+ (`alpha = leftover / fixedStep`). Open design questions: where the
784
+ double-buffered "previous pose" lives so it does NOT bloat the simulation
785
+ hot state or the netcode-replicated component set (a render-side component
786
+ vs. the physics body), and how teleports / kinematic snaps opt out of
787
+ interpolation for a frame. Sits at the physics↔render seam, not in the
788
+ solver.
789
+
790
+ ### API polish
791
+ - [x] **`overlap(shape, position, rotation, output, output_offset,
792
+ filter?)`** broadphase + narrowphase overlap query for kinematic
793
+ / AOE / selection use cases. Body_ids written into a caller-sized
794
+ Uint32Array buffer. Convex query shape only; concave candidates
795
+ are routed through the per-triangle decomposition path.
796
+ - [x] **`shapeCast(ray, shape, rotation, result, filter?)`** for
797
+ character controllers and kinematic shape sweeps. Broadphase
798
+ swept-AABB against both BVHs; per-candidate AABB-slab interval
799
+ narrowing + coarse step + GJK bisection for time-of-impact. The
800
+ output `result.normal` is the true contact-surface normal at the
801
+ kiss point, computed by re-running GJK + EPA at `best_t` on the
802
+ winning candidate (falls back to `-ray.direction` only on EPA
803
+ degeneracies).
804
+ - [x] **`compute_penetration(out_direction, shape_a, pos_a, rot_a,
805
+ shape_b, pos_b, rot_b)`** — standalone geometry primitive (no
806
+ PhysicsSystem) for resolving overlap between two shapes at given
807
+ poses. Returns depth + outward direction. **Hardened** to route through
808
+ the shared narrowphase dispatch (`deepest_pair_penetration`): exact
809
+ closed-form for sphere/box/capsule pairs (box-box via SAT), GJK+EPA for
810
+ general convex, closed-form per-triangle for convex × concave; the
811
+ half-space test is retained only for tunnel recovery.
812
+
813
+ ### Raycast narrowphase (done)
814
+
815
+ **Problem.** `raycast` (and the suspension ray inside `RaycastVehicle`) resolves
816
+ only to the nearest BVH leaf's *inflated* AABB: `result.t` is the distance to
817
+ that fattened box and `result.normal` is its face normal. Exact for an
818
+ axis-aligned box (modulo the broadphase margin), coarse for spheres / capsules /
819
+ rotated boxes / meshes / heightmaps. Refine each candidate against the true
820
+ shape to return the exact surface distance + normal. `shapeCast` already does
821
+ this for swept convex shapes via GJK+EPA; `raycast` should get the same
822
+ treatment with cheap analytic primitives on the hot path.
823
+
824
+ **Design.** Mirror `narrowphase_step`'s dispatch: closed-form ray tests for the
825
+ common primitives, a generic GJK fallback for the rest. The structural change is
826
+ in the BVH walk — the nearest *leaf AABB* is **not** the nearest *shape hit* (a
827
+ ray can clip a near fat-AABB but miss its shape while hitting a farther one), so
828
+ every crossing leaf must be refined, with subtrees pruned by inflated-AABB
829
+ `t_near` vs the best *refined* `t` (conservative-correct: a shape hit is always
830
+ ≥ its tight AABB entry ≥ its inflated AABB entry). A leaf whose ray crosses the
831
+ fat AABB but misses the true shape now contributes **no hit** — the key
832
+ correctness gain.
833
+
834
+ Phasing (each phase: implement → spec → run from `H:/git/moh` → commit):
835
+
836
+ 1. [x] **Ray-primitive helpers** — landed as `narrowphase/ray_shapes.js`
837
+ (local-frame `ray_sphere_local` / `ray_box_local` / `ray_capsule_local`,
838
+ not `core/geom`: the (`t`, normal, miss = `Infinity`, first-hit-from-outside)
839
+ convention is raycast-specific, and the dispatch shares one ray→local
840
+ transform across them). Built local-frame (unit direction ⇒ `t` preserved;
841
+ rotate the local normal back). Triangle MT is inlined in the concave path
842
+ (the existing `computeTriangleRayIntersection` writes a `SurfacePoint3` and
843
+ returns no `t` — unsuited to the buffer-flyweight loop). Colocated specs.
844
+ 2. [x] **Ray-narrowphase dispatch** `narrowphase/refine_ray_hit.js`:
845
+ `(shape, position, rotation, ox,oy,oz, dx,dy,dz, tMax, outNormal) → t`.
846
+ Type-marker dispatch (`isUnitSphereShape3D` / `isBoxShape3D` /
847
+ `isCapsuleShape3D`) to the analytic primitives; a generic convex fallback
848
+ for `TransformedShape3D` / `UnionShape3D` / other (GJK ray-cast, or reuse
849
+ `shape_cast` with a zero-radius `PointShape3D`).
850
+ 3. [x] **Concave path** in the dispatch: for `is_convex === false` (mesh /
851
+ heightmap), enumerate the triangles overlapping the ray's swept AABB
852
+ (`mesh_enumerate_triangles` / `heightmap_enumerate_triangles`), Möller–
853
+ Trumbore each, take the nearest; normal from the triangle winding.
854
+ 4. [x] **Rewire `queries/raycast.js`**: at each leaf, call `refine_ray_hit` on
855
+ the true shape + pose instead of accepting the AABB `t_near`; track the best
856
+ refined `(t, body, normal)`; keep subtree pruning on inflated-AABB `t_near`.
857
+ Same signature / `PhysicsSurfacePoint` result; drop the AABB-face-normal
858
+ block. Multi-collider bodies still resolve the primary collider only
859
+ (inherited BVH-leaf limitation; note it).
860
+ 5. [x] **Tests**: per-shape exactness (sphere / OBB / capsule / mesh /
861
+ heightmap) — exact `t` and true normal; the **fat-AABB-cross-but-shape-miss
862
+ ⇒ no hit** case (the correctness win); nearest-of-several across a near miss;
863
+ `filter` and `tMax` honoured. Re-verify `RaycastVehicle` (ride height now
864
+ exact — tighten the test bands if they shift by the old broadphase margin).
865
+ 6. [x] **Bench + docs**: a raycast micro-bench (analytic fast-path cost; confirm
866
+ the fat-AABB-miss rejection doesn't regress throughput); update the "Public
867
+ queries" entry, `raycast.js` header, and the `RaycastVehicle` "AABB-level"
868
+ caveat once exact.
869
+
870
+ Note: this sharpens `RaycastVehicle` suspension on non-box ground and every
871
+ shape query; it does not change the broadphase or any API surface.
872
+
873
+ ---
874
+
875
+ ## Future / out-of-scope
876
+
877
+ These are explicit architectural exclusions or post-v1 explorations.
878
+
879
+ ### Architecture
880
+ - **Cross-runtime bit-exact determinism**: a soft-float library would
881
+ replace `Math.sin/cos/exp/log/pow` in the hot path. The codebase is
882
+ already structured to make this a swap-in at `quat_integrate.js` and
883
+ tangent-basis construction in `build_manifold.js`. Not pursued because
884
+ the same-runtime determinism we have covers the common cases (single-
885
+ device replay, networked lockstep where all clients run the same JS
886
+ engine).
887
+ - **WASM / SIMD**: the engine targets pure-JS portability. SIMD would
888
+ invalidate the determinism story (V8 doesn't expose deterministic
889
+ Float64x2 ops).
890
+ - **Multi-threaded solver**: workers don't share memory cheaply without
891
+ `SharedArrayBuffer` plus the COOP/COEP HTTP headers, which are not
892
+ always available. Single-threaded is good-enough for the awake-body
893
+ budget that matters.
894
+ - **Packed-SoA body dynamics state — DECIDED AGAINST; do not re-open.** A
895
+ reviewer will note that `BodyStorage` is SoA for *identity* (entity /
896
+ generation / kind / flags / awake-set) but the per-body *dynamics* state —
897
+ velocity, inertia, mass, force/torque accumulators — lives on the `RigidBody`
898
+ **component object** (each carrying several `Vector3` = `Float64Array` + an
899
+ `onChanged` Signal), in a sparse `__bodies[]` array reached by pointer-chase,
900
+ not packed by slot. The same is true of `Collider` and `Joint`. This is a
901
+ deliberate, settled choice, not an oversight: **meep is an ECS engine, and
902
+ `RigidBody` / `Collider` / `Joint` are public-API components.** Them being
903
+ first-class objects is a UX choice and uniformity with the rest of the engine —
904
+ it is what makes them authorable, serializable (`toJSON`/`fromJSON` + binary
905
+ adapters), value-diffable (`equals`/`hash` for netcode replication), and
906
+ observable (`Vector3.onChanged`), exactly like every other component. A
907
+ packed-SoA body pool would buy locality on the *awake* integration sweeps at
908
+ the cost of breaking that uniformity and the public component contract. The
909
+ design instead leans on "mostly-sleeping" (per-body iteration is over the
910
+ *awake* set only), keeps the genuinely O(contacts) inner loop in flat SoA
911
+ (manifolds + solver scratch), and has the hot paths index the component
912
+ vectors' `Float64Array` backing directly to skip the observer (see
913
+ Determinism). This is final — do not resurface it as a "fix".
914
+ - **Single in-flight solve (module-scoped solver scratch).**
915
+ `solver/solve_contacts.js` keeps its cross-stage state (the `g_*` counters and
916
+ the `scratch_*` arrays) at module scope — one copy shared by all worlds, not
917
+ per `PhysicsSystem`. Deliberate: every world reuses one set of scratch, and
918
+ it's safe because the engine is single-threaded and steps one `fixedUpdate` at
919
+ a time. The ceiling it sets: two `PhysicsSystem` instances cannot be stepped
920
+ concurrently or re-entrantly (the second clobbers the first's solver scratch).
921
+ Accepted for a single-world, single-threaded engine; lifting it would need
922
+ per-system scratch (or a solver-context object threaded through the stages).
923
+
924
+ ### Simulation extensions
925
+ - **Soft body / cloth / fluids**: the SoA layout in `BodyStorage` and the
926
+ manifold cache are rigid-body shaped. A soft-body system would be a
927
+ parallel subsystem, not an extension.
928
+ - **Reduced-coordinate articulations** (MuJoCo / Featherstone-style):
929
+ game-physics audience runs in maximal coordinates by convention. Not
930
+ on the roadmap.
931
+
932
+ ### Game-side
933
+ - **Vehicle physics** (suspensions, drivetrains): a domain layer that
934
+ sits on top of the rigid-body primitives, not in `meep/`.
935
+ - **Character controllers**: same — `engine/control/first-person/` is the
936
+ natural home.
937
+
938
+ ---
939
+
940
+ ## Notable design files
941
+
942
+ - Original design plan: `C:\Users\Alex\.claude\plans\let-s-plan-to-implement-transient-harp.md`
943
+ - This file (state of play): `engine/physics/PLAN.md`