@woosh/meep-engine 2.144.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 (60) hide show
  1. package/package.json +1 -1
  2. package/src/core/bvh2/bvh3/BVH.d.ts.map +1 -1
  3. package/src/core/bvh2/bvh3/BVH.js +158 -4
  4. package/src/core/geom/3d/shape/CylinderShape3D.d.ts +56 -0
  5. package/src/core/geom/3d/shape/CylinderShape3D.d.ts.map +1 -0
  6. package/src/core/geom/3d/shape/CylinderShape3D.js +223 -0
  7. package/src/core/geom/3d/shape/HeightMapShape3D.d.ts +33 -3
  8. package/src/core/geom/3d/shape/HeightMapShape3D.d.ts.map +1 -1
  9. package/src/core/geom/3d/shape/HeightMapShape3D.js +486 -451
  10. package/src/core/geom/3d/shape/json/shape_to_type.d.ts.map +1 -1
  11. package/src/core/geom/3d/shape/json/shape_to_type.js +3 -0
  12. package/src/core/geom/3d/shape/json/type_adapters.d.ts +15 -0
  13. package/src/core/geom/3d/shape/json/type_adapters.d.ts.map +1 -1
  14. package/src/core/geom/3d/shape/json/type_adapters.js +16 -0
  15. package/src/engine/control/first-person/DESIGN_COLLISION.md +365 -302
  16. package/src/engine/control/first-person/FirstPersonPlayerController.d.ts +1 -3
  17. package/src/engine/control/first-person/FirstPersonPlayerController.d.ts.map +1 -1
  18. package/src/engine/control/first-person/FirstPersonPlayerControllerConfig.d.ts +12 -2
  19. package/src/engine/control/first-person/FirstPersonPlayerControllerConfig.d.ts.map +1 -1
  20. package/src/engine/control/first-person/FirstPersonPlayerControllerConfig.js +7 -2
  21. package/src/engine/control/first-person/FirstPersonPlayerControllerSystem.d.ts +13 -0
  22. package/src/engine/control/first-person/FirstPersonPlayerControllerSystem.d.ts.map +1 -1
  23. package/src/engine/control/first-person/FirstPersonPlayerControllerSystem.js +16 -2
  24. package/src/engine/control/first-person/TODO.md +13 -11
  25. package/src/engine/control/first-person/abilities/WallJump.d.ts.map +1 -1
  26. package/src/engine/control/first-person/abilities/WallJump.js +11 -3
  27. package/src/engine/control/first-person/abilities/WallRun.d.ts.map +1 -1
  28. package/src/engine/control/first-person/abilities/WallRun.js +30 -35
  29. package/src/engine/control/first-person/collision/KinematicMover.d.ts +35 -5
  30. package/src/engine/control/first-person/collision/KinematicMover.d.ts.map +1 -1
  31. package/src/engine/control/first-person/collision/KinematicMover.js +634 -424
  32. package/src/engine/control/first-person/prototype_first_person_controller.js +1003 -901
  33. package/src/engine/physics/PLAN.md +943 -767
  34. package/src/engine/physics/body/BodyStorage.d.ts +9 -0
  35. package/src/engine/physics/body/BodyStorage.d.ts.map +1 -1
  36. package/src/engine/physics/body/BodyStorage.js +23 -0
  37. package/src/engine/physics/broadphase/generate_pairs.d.ts.map +1 -1
  38. package/src/engine/physics/broadphase/generate_pairs.js +7 -0
  39. package/src/engine/physics/ccd/linear_sweep.d.ts +97 -0
  40. package/src/engine/physics/ccd/linear_sweep.d.ts.map +1 -0
  41. package/src/engine/physics/ccd/linear_sweep.js +238 -0
  42. package/src/engine/physics/ecs/PhysicsSystem.d.ts +18 -3
  43. package/src/engine/physics/ecs/PhysicsSystem.d.ts.map +1 -1
  44. package/src/engine/physics/ecs/PhysicsSystem.js +59 -8
  45. package/src/engine/physics/ecs/RigidBodyFlags.d.ts +6 -0
  46. package/src/engine/physics/ecs/RigidBodyFlags.d.ts.map +1 -1
  47. package/src/engine/physics/ecs/RigidBodyFlags.js +6 -0
  48. package/src/engine/physics/narrowphase/box_triangle_contact.js +811 -811
  49. package/src/engine/physics/narrowphase/compute_penetration.d.ts.map +1 -1
  50. package/src/engine/physics/narrowphase/compute_penetration.js +325 -323
  51. package/src/engine/physics/narrowphase/decomposition/heightmap_enumerate_triangles.d.ts +27 -8
  52. package/src/engine/physics/narrowphase/decomposition/heightmap_enumerate_triangles.d.ts.map +1 -1
  53. package/src/engine/physics/narrowphase/decomposition/heightmap_enumerate_triangles.js +235 -204
  54. package/src/engine/physics/narrowphase/narrowphase_step.d.ts.map +1 -1
  55. package/src/engine/physics/narrowphase/narrowphase_step.js +70 -13
  56. package/src/engine/physics/queries/overlap_shape.d.ts.map +1 -1
  57. package/src/engine/physics/queries/overlap_shape.js +185 -183
  58. package/src/engine/simulation/Ticker.d.ts +14 -0
  59. package/src/engine/simulation/Ticker.d.ts.map +1 -1
  60. package/src/engine/simulation/Ticker.js +136 -1
@@ -1,302 +1,365 @@
1
- # Collision handling — ground-up construction plan
2
-
3
- Companion to DESIGN.md (base controller) and TODO.md.
4
-
5
- **Premise.** The control layer is excellent and stays. The collision
6
- layer is being **replaced wholesale** — the current
7
- `_moveAndSlide` + `_integrateVerticalAndResolveGround` + scalar
8
- ground-resolver regime is not a foundation to build on; it's scaffolding
9
- to delete once the replacement reaches parity. We keep relying on
10
- `PhysicsSystem` for all the heavy lifting (`shapeCast`, `raycast`,
11
- `overlap`, `compute_penetration`) — this plan does *more* with it, not
12
- less.
13
-
14
- Every phase is **guard-test-first**, and ordered so the controller stays
15
- shippable and green after each one. The new mover is built alongside,
16
- proven against ported + new specs, switched over, and only then is the
17
- old code deleted.
18
-
19
- ---
20
-
21
- ## 1. The seam: control produces motion, collision resolves it
22
-
23
- The clean split that makes a ground-up rebuild safe — the control layer
24
- already does its half today:
25
-
26
- ```
27
- CONTROL (sacred, unchanged) COLLISION (rebuilt)
28
- ────────────────────────── ──────────────────
29
- intent → desired velocity move(pose, shape, velocity, dt)
30
- accel curves, jump FSM, ──v──▶ → resolved position
31
- gravity/impulse, abilities, → corrected velocity
32
- mastery, posture → ground state {grounded, normal}
33
- ▲ │
34
- └────────── grounded, groundNormal ◀─────────┘
35
- (read next tick by jump FSM,
36
- gravity gating, slope logic)
37
- ```
38
-
39
- The control layer's output each tick is a **desired velocity** (it
40
- already computes this — intent+accel horizontally, gravity+jump
41
- vertically). The collision layer's *only* job is: given that velocity,
42
- the capsule pose, and the world, produce the actual resulting position,
43
- the velocity corrected for what it hit, and the ground state. It never
44
- invents motion; it only constrains it.
45
-
46
- One cleanup this forces (Phase 4): **gravity + jump-impulse application
47
- move out of the deleted collision method into a small motor helper**
48
- the base loop and abilities call. The solver stays purely about
49
- collision.
50
-
51
- ---
52
-
53
- ## 2. What we keep / replace / delete
54
-
55
- **Keep (untouched):** the whole control stack — intent, mono-exp accel,
56
- jump FSM, posture→collider sizing, abilities (Slide/WallRun/Mantle/
57
- LedgeGrab), mastery, camera composition, sensors.
58
-
59
- **Replace:** the regime that *consumes* velocity —
60
- `_moveAndSlide` (horizontal-only swept slide), the direct vertical
61
- position add, and the downward-ray scalar-Y ground snap.
62
-
63
- **Delete (Phase 4, after parity):** `_moveAndSlide`,
64
- `_integrateVerticalAndResolveGround`'s collision body, the
65
- `CAST_STEP_HEIGHT` implicit-step hack, and the scalar `groundResolver`
66
- *as the grounding authority* (it survives only as a no-physics fallback
67
- inside the new ground probe).
68
-
69
- ---
70
-
71
- ## 3. Physics capabilities we build on
72
-
73
- The P1–P6 narrowphase/query work is the enabling foundation — the
74
- rebuild is only feasible because these are now narrowphase-exact.
75
-
76
- | Capability | Status | Source |
77
- |---|---|---|
78
- | `raycast` exact surface **normal** (sphere/box/capsule/mesh/heightmap) | ✅ | refine_ray_hit P1–P3 (`6f931b4`…`4c2292c`) |
79
- | `shapeCast` true contact **normal at TOI** (MPR, not broadphase −dir) | ✅ | `shape_cast.js` normal-recovery |
80
- | `shapeCast` **start-in-contact** → `t=0` + valid normal | ✅ | `shape_cast.js:195`, `:350` |
81
- | `shapeCast` analytic slab-narrowing (long-sweep accuracy) | ✅ | `shape_cast.js:203` |
82
- | `compute_penetration(out, A,pa,ra, B,pb,rb) → depth`, `out`=unit B→A | ✅ **public** | `compute_penetration.js:138` |
83
- | `overlap(shape,pos,rot,out,off,filter)` → body-id list | ✅ | `overlap_shape.js` |
84
- | EPA adaptive tol / MPR fallback / box-box edge / capsule-tri manifold / GJK cache | ✅ | P2.2, P3.1, P3.2, P1.1c, P2.1 |
85
-
86
- Contracts the plan honours:
87
- - `shapeCast` `result.t` is a **world distance** when direction is
88
- unit-length and `tMax = sweepLength`. New casts keep that.
89
- - `compute_penetration` accepts the convex player capsule vs any
90
- shape (only concave-vs-concave throws); `out_direction` is the unit
91
- **B→A** separation, depth is the scalar return. Push the capsule (A)
92
- out along `+out_direction · depth`.
93
- - `shapeCast` `result.position` is the swept **centre**, not the
94
- contact point — we derive ground-surface Y from the cast `t` + the
95
- capsule's bottom offset, so we never need a contact point.
96
-
97
- ---
98
-
99
- ## 4. Target architecture — a dedicated `KinematicMover`
100
-
101
- Extract the collision solver into its own module (name placeholder —
102
- `KinematicMover` / `CharacterMover`), testable in isolation against a
103
- real `PhysicsSystem`, not buried in the 1500-line system file. Matches
104
- the repo's extraction taste (Spring, EyeOffsetStack, FirstPersonSensors).
105
-
106
- It knows nothing about the controller — just a capsule, a velocity, the
107
- world, and an `up` axis. Single entry point:
108
-
109
- ```
110
- move(transform, shape, velocity, dt, filter) → MoveResult
111
- // mutates transform.position; returns:
112
- // { velocityX/Y/Z (corrected), grounded, groundNormal, hit }
113
- ```
114
-
115
- Modelled on modern kinematic character controllers (Jolt
116
- `CharacterVirtual`, Source `TryPlayerMove`+`CategorizePosition`), the
117
- move is an explicit sequence — **recover → slide → (stairs) → ground →
118
- settle**:
119
-
120
- 1. **Recover (depenetration).** Before moving, get clear. `overlap()`
121
- the capsule; for each body, `compute_penetration` → push the capsule
122
- out by `depth · dir`; iterate a few times (deepest-first) to
123
- convergence. Handles spawned-in-geometry, world-crushed-into-us, and
124
- the start-solid case a pure sweep *cannot*. This is now a **core
125
- step**, available from Phase 1 — not a bolt-on — because
126
- `compute_penetration` is public.
127
-
128
- 2. **Sweep-and-slide (unified 3D).** Sweep the *full* velocity·dt
129
- (gravity folded in — no H/V split) via `shapeCast`; stop at TOI,
130
- clip velocity onto the contact tangent, project the residual, repeat
131
- up to N iterations. **Crease-aware**: a second plane re-violating the
132
- first → project onto the seam `normalize(n₁×n₂)`; a third plane in
133
- one move → dead-stop. Walls, floors, ceilings are all just contact
134
- planes, so anti-tunnelling (H and V) falls out of one loop.
135
-
136
- 3. **Stairs (explicit, optional per move).** When grounded and blocked
137
- by a low step: sweep up by `stepHeight`, sweep the residual forward,
138
- sweep back down — the Jolt/Source step pattern. Replaces the 5 cm
139
- implicit-cast hack with real stair climbing, gated so walls taller
140
- than `stepHeight` still block.
141
-
142
- 4. **Ground categorize + stick.** Short downward `shapeCast` (capsule
143
- swept down a `SKIN + band`); if it hits within the band with
144
- `normal.y ≥ MIN_WALK_NORMAL` (~0.7), set `grounded`, capture the
145
- normal, snap to the surface, and kill the into-ground velocity
146
- component. Band-test + active snap (not a strict `y ≤ testY`) is what
147
- structurally kills the landing **bounce**. Too-steep normal →
148
- not grounded → the slope was already a slide plane in step 2, so the
149
- player slides down it.
150
-
151
- 5. **Settle.** One final `compute_penetration` recover pass guarantees
152
- the tick ends penetration-free (resting-contact float drift
153
- insurance; usually a no-op).
154
-
155
- Slope handling, multi-body resting contact, and unstick all fall out of
156
- steps 1/2/4 no special cases. Abilities get correct collision for free
157
- by routing their motion through the same `move()`.
158
-
159
- ---
160
-
161
- ## 5. Phased plan (rebuild, not patch)
162
-
163
- ### Phase 1 `KinematicMover`: recover + unified sweep-and-slide
164
- Stand up the module. Implement **recover** (overlap +
165
- compute_penetration) and **unified 3D sweep-and-slide** (crease-aware).
166
- The system calls `move()` for the combined velocity·dt, replacing both
167
- `_moveAndSlide` and the direct vertical add. Grounding stays on the old
168
- resolver as a short-lived interim shim (removed in Phase 2) so this
169
- phase isolates "does the new mover move correctly."
170
-
171
- **Guard tests** (`KinematicMover.spec.js`, against a real PhysicsSystem):
172
- spawn straddling a box → recovered outside within a tick; wall stop;
173
- slide-along axis-aligned + oblique wall; **vertical** anti-tunnel (drop
174
- through a thin slab lands on top); jump into ceiling → stops; corner
175
- crease → clean stop, no leak/chatter. Port the existing
176
- `MoveAndSlide.spec.js` scenarios through the new path.
177
-
178
- **Risk:** medium. New module, but isolated; old grounding still in place.
179
-
180
- ### Phase 2 Ground categorize + slope + stick (inside the mover)
181
- Add steps 4 (categorize/stick) to the mover. Down-`shapeCast`
182
- `{grounded, normal}`; `MIN_WALK_NORMAL` gate; snap + kill into-ground
183
- velocity; reproject grounded velocity onto the slope. **Remove the
184
- interim grounding shim and the scalar resolver authority** (resolver
185
- demoted to no-physics fallback *inside* the probe, behind one function
186
- uniform flow). Feed `grounded`/`groundNormal` back to control.
187
-
188
- **Guard tests** (`GroundSlope.spec.js`): walk up a 30° ramp keeps speed,
189
- stays grounded, correct normal; stationary-on-floor stays at y≈0 across
190
- 120 ticks (the no-bounce repro, now with nothing masking it); 60° face →
191
- not grounded, slides down, not glued.
192
-
193
- **Risk:** medium. Changes grounding semantics; the bounce reconciliation
194
- lives here.
195
-
196
- ### Phase 3 Stairs ✅ **landed**
197
- Real stair climbing, gated by `stepHeight` (default 0.3 m, just under
198
- the capsule radius).
199
-
200
- The implementation diverged from the planned explicit "up-forward-down"
201
- step sweep that was tried and removed. The capsule's *round bottom*
202
- already rolls up a riser shorter than its radius (the slide moves the
203
- player up positionally), and an explicit step-up fought it (incremental
204
- wall-climbing, edge-wedging). What was actually needed was **honest
205
- ground categorization on a step edge**, which two probes split cleanly:
206
-
207
- - **Step-DOWN** = the ground-stick reach is `stepHeight`: walking off
208
- a drop ≤ stepHeight snaps onto the lower surface (stays grounded);
209
- a larger drop goes airborne.
210
- - **Step-UP** = categorize takes **walkability from a centre raycast**
211
- (sees the real planar surface; ignores a step's convex top edge and
212
- a wall's side face so a steep *slope* is correctly not-grounded)
213
- and **rest height from a footprint shapecast** (raises onto a step
214
- the leading edge overhangs, capped at `stepHeight`). A single probe
215
- can't tell a climbable step edge from a steep slope both are
216
- steep-normal contacts which is why the two are split.
217
-
218
- This is also what fixed the "launch off every low step" jank: with the
219
- player correctly grounded through the roll, the per-tick vertical
220
- velocity is zeroed and never accumulates.
221
-
222
- **Guard tests** (`collision/Stairs.spec.js`, all green): climb a 5-step
223
- staircase staying grounded; clear a single curb without stalling; a
224
- 0.5 m riser (> stepHeight) blocks; descend a staircase grounded the
225
- whole way (no launch off each lip).
226
-
227
- ### Phase 4 Motor seam + delete old code ✅ **landed**
228
- The mover is now the **only** collision path when a `PhysicsSystem` is
229
- present the opt-in flag is gone; dispatch is on physics presence, not
230
- a flag.
231
-
232
- What shipped:
233
- - `_integrateVerticalAndResolveGround` is now a thin dispatcher:
234
- `_applyGravity` (the motor) `_moveViaMover` (physics) **or**
235
- `_moveFlatGround` (no physics) `_detectJumpApex`.
236
- - **Gravity** extracted to `_applyGravity`; **land / leave-ground**
237
- extracted to `_onLand` / `_onLeaveGround`, shared by both move paths
238
- (the duplicated scaffolding from Phase 1's wiring is gone).
239
- - **Deleted** `_moveAndSlide` (+ its `CAST_STEP_HEIGHT` / `SKIN`
240
- constants and the `slideRay` / `slideHit` scratch), the legacy ground-
241
- resolution body, and the `useKinematicMover` flag.
242
- - `MoveAndSlide.spec.js` deleted it tested `_moveAndSlide`; its
243
- scenarios (wall-stop, axis + oblique slide, anti-tunnel) are covered
244
- by `collision/KinematicMover.spec.js`.
245
-
246
- What stayed (deliberately):
247
- - `useBuiltInFlatGround` / `groundY` / `groundResolver` remain they're
248
- the **no-physics** fallback (`_moveFlatGround`) for headless and
249
- control-layer unit tests, and the resolver is still spec-covered. With
250
- physics present they're unused (the mover probes the world).
251
- - **WallRun still self-integrates** its reduced-gravity model rather than
252
- routing through `move()`. Slide already routes through the mover (it
253
- calls `_integrateVerticalAndResolveGround`); WallRun's custom model is
254
- left as a future enhancement, not required for the legacy deletion.
255
-
256
- **Result:** 29 suites / 236 tests green; the entire ability + jump +
257
- momentum + posture suite passes through the consolidated path.
258
-
259
- ---
260
-
261
- ## 6. Physics requirements status
262
-
263
- **Resolved.** My one HIGH ask from the review a public penetration
264
- query is **satisfied**: `compute_penetration` is public and is exactly
265
- the right shape (unit B→A direction + scalar depth, capsule-vs-anything).
266
- It graduates from "deferred safety net" to the **core recover/settle
267
- primitive** the whole mover leans on (steps 1 & 5). Confirmed it works:
268
- yes, this is precisely what's needed thank you for keeping it decoupled
269
- and public.
270
-
271
- **No remaining blocking requirements.** Phases 1–4 need nothing further
272
- from the physics engine. The two low-priority niceties from the review
273
- turned out not to bite:
274
- - *Contact point from `shapeCast`* not needed; ground-surface Y is
275
- derivable from the cast `t` + the capsule bottom offset.
276
- - *Multi-contact manifold from one query* — covered for resting contact
277
- by iterating `compute_penetration` over **every** `overlap()` body in
278
- the recover pass; the single-sweep corner case is handled by the slide
279
- iteration instead. So no native multi-contact query is required.
280
-
281
- If anything *would* be a future nicety (not needed now): a swept query
282
- returning the *set* of TOI-simultaneous contacts (for one-pass corner
283
- creases instead of iterating). Strictly an optimisation; the iterative
284
- slide is correct without it.
285
-
286
- ---
287
-
288
- ## 7. Constants (ours reference lineage)
289
-
290
- | Constant | Plan value | Reference | Phase |
291
- |---|---|---|---|
292
- | slide iterations | 4 | Quake `numbumps` 4 | 1 |
293
- | `SKIN` | 0.005 m | Fauerby `veryCloseDistance` ~0.005 | 1 |
294
- | recover max iters | ~4 | — (convergence cap) | 1 |
295
- | `MIN_WALK_NORMAL` | ~0.7 (≈45°) | Quake3 0.7 / Source `normal.z ≥ 0.7` | 2 |
296
- | ground-probe band | ~0.06 m | Source `StayOnGround` ~2 u | 2 |
297
- | crease dead-stop | zero on 3rd plane | Quake `SV_FlyMove` | 1 |
298
- | `stepHeight` | ~0.3 m (tunable) | Source `sv_stepsize` 18 u (~0.34 m) | 3 |
299
-
300
- Clip stays at effective overbounce `1.0` gated on into-wall (`v·n < 0`),
301
- relying on `SKIN` for clearance equivalent in practice to Quake3's
302
- `OVERCLIP 1.001` nudge; no change planned.
1
+ # Collision handling — ground-up construction plan
2
+
3
+ Companion to DESIGN.md (base controller) and TODO.md.
4
+
5
+ **Premise.** The control layer is excellent and stays. The collision
6
+ layer is being **replaced wholesale** — the current
7
+ `_moveAndSlide` + `_integrateVerticalAndResolveGround` + scalar
8
+ ground-resolver regime is not a foundation to build on; it's scaffolding
9
+ to delete once the replacement reaches parity. We keep relying on
10
+ `PhysicsSystem` for all the heavy lifting (`shapeCast`, `raycast`,
11
+ `overlap`, `compute_penetration`) — this plan does *more* with it, not
12
+ less.
13
+
14
+ Every phase is **guard-test-first**, and ordered so the controller stays
15
+ shippable and green after each one. The new mover is built alongside,
16
+ proven against ported + new specs, switched over, and only then is the
17
+ old code deleted.
18
+
19
+ ---
20
+
21
+ ## 1. The seam: control produces motion, collision resolves it
22
+
23
+ The clean split that makes a ground-up rebuild safe — the control layer
24
+ already does its half today:
25
+
26
+ ```
27
+ CONTROL (sacred, unchanged) COLLISION (rebuilt)
28
+ ────────────────────────── ──────────────────
29
+ intent → desired velocity move(pose, shape, velocity, dt)
30
+ accel curves, jump FSM, ──v──▶ → resolved position
31
+ gravity/impulse, abilities, → corrected velocity
32
+ mastery, posture → ground state {grounded, normal}
33
+ ▲ │
34
+ └────────── grounded, groundNormal ◀─────────┘
35
+ (read next tick by jump FSM,
36
+ gravity gating, slope logic)
37
+ ```
38
+
39
+ The control layer's output each tick is a **desired velocity** (it
40
+ already computes this — intent+accel horizontally, gravity+jump
41
+ vertically). The collision layer's *only* job is: given that velocity,
42
+ the capsule pose, and the world, produce the actual resulting position,
43
+ the velocity corrected for what it hit, and the ground state. It never
44
+ invents motion; it only constrains it.
45
+
46
+ One cleanup this forces (Phase 4): **gravity + jump-impulse application
47
+ move out of the deleted collision method into a small motor helper**
48
+ the base loop and abilities call. The solver stays purely about
49
+ collision.
50
+
51
+ ---
52
+
53
+ ## 2. What we keep / replace / delete
54
+
55
+ **Keep (untouched):** the whole control stack — intent, mono-exp accel,
56
+ jump FSM, posture→collider sizing, abilities (Slide/WallRun/Mantle/
57
+ LedgeGrab), mastery, camera composition, sensors.
58
+
59
+ **Replace:** the regime that *consumes* velocity —
60
+ `_moveAndSlide` (horizontal-only swept slide), the direct vertical
61
+ position add, and the downward-ray scalar-Y ground snap.
62
+
63
+ **Delete (Phase 4, after parity):** `_moveAndSlide`,
64
+ `_integrateVerticalAndResolveGround`'s collision body, the
65
+ `CAST_STEP_HEIGHT` implicit-step hack, and the scalar `groundResolver`
66
+ *as the grounding authority* (it survives only as a no-physics fallback
67
+ inside the new ground probe).
68
+
69
+ ---
70
+
71
+ ## 3. Physics capabilities we build on
72
+
73
+ The P1–P6 narrowphase/query work is the enabling foundation — the
74
+ rebuild is only feasible because these are now narrowphase-exact.
75
+
76
+ | Capability | Status | Source |
77
+ |---|---|---|
78
+ | `raycast` exact surface **normal** (sphere/box/capsule/mesh/heightmap) | ✅ | refine_ray_hit P1–P3 (`6f931b4`…`4c2292c`) |
79
+ | `shapeCast` true contact **normal at TOI** (MPR, not broadphase −dir) | ✅ | `shape_cast.js` normal-recovery |
80
+ | `shapeCast` **start-in-contact** → `t=0` + valid normal | ✅ | `shape_cast.js:195`, `:350` |
81
+ | `shapeCast` analytic slab-narrowing (long-sweep accuracy) | ✅ | `shape_cast.js:203` |
82
+ | `compute_penetration(out, A,pa,ra, B,pb,rb) → depth`, `out`=unit B→A | ✅ **public** | `compute_penetration.js:138` |
83
+ | `overlap(shape,pos,rot,out,off,filter)` → body-id list | ✅ | `overlap_shape.js` |
84
+ | EPA adaptive tol / MPR fallback / box-box edge / capsule-tri manifold / GJK cache | ✅ | P2.2, P3.1, P3.2, P1.1c, P2.1 |
85
+
86
+ Contracts the plan honours:
87
+ - `shapeCast` `result.t` is a **world distance** when direction is
88
+ unit-length and `tMax = sweepLength`. New casts keep that.
89
+ - `compute_penetration` accepts the convex player capsule vs any
90
+ shape (only concave-vs-concave throws); `out_direction` is the unit
91
+ **B→A** separation, depth is the scalar return. Push the capsule (A)
92
+ out along `+out_direction · depth`.
93
+ - `shapeCast` `result.position` is the swept **centre**, not the
94
+ contact point — we derive ground-surface Y from the cast `t` + the
95
+ capsule's bottom offset, so we never need a contact point.
96
+
97
+ ---
98
+
99
+ ## 4. Target architecture — a dedicated `KinematicMover`
100
+
101
+ Extract the collision solver into its own module (name placeholder —
102
+ `KinematicMover` / `CharacterMover`), testable in isolation against a
103
+ real `PhysicsSystem`, not buried in the 1500-line system file. Matches
104
+ the repo's extraction taste (Spring, EyeOffsetStack, FirstPersonSensors).
105
+
106
+ It knows nothing about the controller — just a capsule, a velocity, the
107
+ world, and an `up` axis. Single entry point:
108
+
109
+ ```
110
+ move(transform, shape, velocity, dt, filter) → MoveResult
111
+ // mutates transform.position; returns:
112
+ // { velocityX/Y/Z (corrected), grounded, groundNormal, hit }
113
+ ```
114
+
115
+ Modelled on modern kinematic character controllers (Jolt
116
+ `CharacterVirtual`, Source `TryPlayerMove`+`CategorizePosition`), the
117
+ move is an explicit sequence — **recover → slide → (stairs) → ground →
118
+ settle**:
119
+
120
+ 1. **Recover (depenetration).** Before moving, get clear. `overlap()`
121
+ the capsule; for each body, `compute_penetration` → push the capsule
122
+ out by `depth · dir`; iterate a few times (deepest-first) to
123
+ convergence. Handles spawned-in-geometry, world-crushed-into-us, and
124
+ the start-solid case a pure sweep *cannot*. This is now a **core
125
+ step**, available from Phase 1 — not a bolt-on — because
126
+ `compute_penetration` is public.
127
+
128
+ 2. **Sweep-and-slide (unified 3D).** Sweep the *full* velocity·dt
129
+ (gravity folded in — no H/V split) via `shapeCast`; stop at TOI,
130
+ clip velocity onto the contact tangent, project the residual, repeat
131
+ up to N iterations. **Crease-aware**: a second plane re-violating the
132
+ first → project onto the seam `normalize(n₁×n₂)`; a third plane in
133
+ one move → dead-stop. Walls, floors, ceilings are all just contact
134
+ planes, so anti-tunnelling (H and V) falls out of one loop.
135
+
136
+ 3. **Stairs (explicit, optional per move).** When grounded and blocked
137
+ by a low step: sweep up by `stepHeight`, sweep the residual forward,
138
+ sweep back down — the Jolt/Source step pattern. Replaces the 5 cm
139
+ implicit-cast hack with real stair climbing, gated so walls taller
140
+ than `stepHeight` still block.
141
+
142
+ 4. **Ground categorize + stick.** Short downward `shapeCast` (capsule
143
+ swept down a `SKIN + band`); if it hits within the band with
144
+ `normal.y ≥ MIN_WALK_NORMAL` (~0.7), set `grounded`, capture the
145
+ normal, snap to the surface, and kill the into-ground velocity
146
+ component. Band-test + active snap (not a strict `y ≤ testY`) is what
147
+ structurally kills the landing **bounce**. Too-steep normal →
148
+ not grounded → the slope was already a slide plane in step 2, so the
149
+ player slides down it.
150
+
151
+ 5. **Settle.** One final `compute_penetration` recover pass guarantees
152
+ the tick ends penetration-free (resting-contact float drift
153
+ insurance; usually a no-op).
154
+
155
+ **No-slip walkable (one gate, not two).** Between recover and slide,
156
+ when the feet rest on walkable ground (`_groundedAt` a walkable normal
157
+ within the stick band, the *same* `MIN_WALK_NORMAL` as step 4), the
158
+ downward **gravity** velocity is dropped before the slide. Otherwise
159
+ gravity projects onto a ramp's tangent as a downhill **drift** and a
160
+ standing player creeps down every slope it can walk. A biped grips any
161
+ slope it could walk up; the instant its feet slip it could not have
162
+ climbed either — so *"doesn't slide down"* and *"can be walked up"* are
163
+ the same threshold, not two independent tunables. A too-steep face fails
164
+ the gate, keeps its gravity, and slides (step 4) — purchase and no-slip
165
+ appear and vanish together as the one knob moves. A jump (`v.y > 0`) is
166
+ exempt so the launch is never cancelled.
167
+
168
+ Slope handling, multi-body resting contact, and unstick all fall out of
169
+ steps 1/2/4 no special cases. Abilities get correct collision for free
170
+ by routing their motion through the same `move()`.
171
+
172
+ ---
173
+
174
+ ## 5. Phased plan (rebuild, not patch)
175
+
176
+ ### Phase 1 — `KinematicMover`: recover + unified sweep-and-slide ✅ **landed**
177
+ Stand up the module. Implement **recover** (overlap +
178
+ compute_penetration) and **unified 3D sweep-and-slide** (crease-aware).
179
+ The system calls `move()` for the combined velocity·dt, replacing both
180
+ `_moveAndSlide` and the direct vertical add. Grounding stays on the old
181
+ resolver as a short-lived interim shim (removed in Phase 2) so this
182
+ phase isolates "does the new mover move correctly."
183
+
184
+ **Guard tests** (`KinematicMover.spec.js`, against a real PhysicsSystem):
185
+ spawn straddling a box recovered outside within a tick; wall stop;
186
+ slide-along axis-aligned + oblique wall; **vertical** anti-tunnel (drop
187
+ through a thin slab → lands on top); jump into ceiling → stops; corner
188
+ crease clean stop, no leak/chatter. Port the existing
189
+ `MoveAndSlide.spec.js` scenarios through the new path.
190
+
191
+ **Risk:** medium. New module, but isolated; old grounding still in place.
192
+
193
+ ### Phase 2 Ground categorize + slope + stick (inside the mover) ✅ **landed**
194
+ Add steps 4 (categorize/stick) to the mover. Down-`shapeCast` →
195
+ `{grounded, normal}`; `MIN_WALK_NORMAL` gate; snap + kill into-ground
196
+ velocity; reproject grounded velocity onto the slope. **Remove the
197
+ interim grounding shim and the scalar resolver authority** (resolver
198
+ demoted to no-physics fallback *inside* the probe, behind one function —
199
+ uniform flow). Feed `grounded`/`groundNormal` back to control.
200
+
201
+ **Guard tests** (`GroundSlope.spec.js`): walk up a 30° ramp keeps speed,
202
+ stays grounded, correct normal; stationary-on-floor stays at y≈0 across
203
+ 120 ticks (the no-bounce repro, now with nothing masking it); 60° face →
204
+ not grounded, slides down, not glued.
205
+
206
+ **Risk:** medium. Changes grounding semantics; the bounce reconciliation
207
+ lives here.
208
+
209
+ ### Phase 3 Stairs ✅ **landed**
210
+ Real stair climbing, gated by `stepHeight` (default 0.3 m, just under
211
+ the capsule radius). Three cooperating pieces it took iterating to
212
+ find that all three are needed:
213
+
214
+ 1. **Explicit step-up** (`_tryStepUp`, Source/Jolt up-forward-down).
215
+ When a grounded move is blocked, decide step-vs-wall with a **thin
216
+ horizontal ray at the step-height plane** (`sy + stepHeight + skin`),
217
+ then — only if clear — lift by stepHeight, advance the residual
218
+ forward, drop onto the step, commit when it gained ground within
219
+ stepHeight, and **restore the horizontal velocity**. The velocity
220
+ restore is the crux for REALISTIC stairs: the capsule's round bottom
221
+ also rolls up low risers, but only with sustained forward momentum,
222
+ and a controller that reads back the slide-clipped velocity loses
223
+ that momentum at the riser and stalls. The step-up climbs without
224
+ depending on momentum and hands the velocity back so the player keeps
225
+ moving up the flight.
226
+
227
+ The step-vs-wall ray is what stops the round bottom climbing a
228
+ too-tall wall and the reason it's a THIN ray, not the obvious
229
+ "lift the capsule and sweep it forward" clearance cast: lifted so its
230
+ tip sits at stepHeight, the capsule's rounded bottom narrows through
231
+ the band `(stepHeight, stepHeight+radius)`, so a wall whose top lands
232
+ in that band is never reached by the swept shape — it reads "clear"
233
+ and the player climbs it, *faster speed reaching further into the
234
+ round-off* (the gray-box bug: jitter at a walk, clean climb at a
235
+ sprint). A thin ray has no round-off: it reads the obstacle's true
236
+ height, so a step (top below the plane) is passed over and a wall
237
+ (top above) is struck on its front face, at any approach speed.
238
+
239
+ The ray is cast along the **blocked direction** (the horizontal
240
+ velocity the slide removed = the obstacle's inward normal), from the
241
+ **pre-slide** centre, reaching the swept distance plus a forward
242
+ extent. Both matter for OBLIQUE approaches: at 45° the capsule meets a
243
+ wall with its normal-direction extent, so a ray along *travel* stops
244
+ short of the face and reads a wall as clear (climbing it); and
245
+ rounding a convex corner the slide carries the *post*-slide centre
246
+ just past the corner, so a ray from there shoots past the obstacle —
247
+ the pre-slide centre was still in front of what blocked it. The reach
248
+ tracking the sweep isn't the banned speed coupling: the climb-or-block
249
+ decision is the step-height plane alone; the reach only governs how
250
+ far ahead to look for what the slide already hit.
251
+
252
+ A single ray still can't catch a convex *point*: grazing a corner a
253
+ few degrees off its bisector, the blocked normal runs nearly parallel
254
+ to a face, so the ray slips past the corner just outside the footprint
255
+ and reads clear. The backstop is a post-hoc OVERLAP query — after
256
+ up-forward-down, if the destination overlaps geometry the climb landed
257
+ the round body ON a wall corner, not on a step (a real step top is
258
+ rested on `skin` above, never overlapping), so it's rejected. Probe for
259
+ the common case, overlap-test to catch what a ray threads past.
260
+
261
+ The same round-bottom perch is why the slide keeps every contact's true
262
+ normal (no "flatten steep contacts to vertical" — that would also rob
263
+ a too-steep *slope* of its downhill slide) and why the footprint
264
+ mount in (2) is gated on height above the **centre surface**, not the
265
+ feet (gating on the feet lets a capsule that rode up a hair mount the
266
+ next sliver and ratchet up a wall).
267
+ 2. **Two-probe ground categorize** for honest grounded-ness on a step
268
+ edge *walkability* from a centre raycast (ignores a step's convex
269
+ top edge and a wall's side face, so a steep *slope* is correctly
270
+ not-grounded) and *rest height* from a footprint shapecast (mounts
271
+ a step the leading edge overhangs). A single probe can't tell a
272
+ climbable step edge from a steep slope; the split can.
273
+ 3. **Step-DOWN** = the ground-stick reach is `stepHeight`: walking off
274
+ a drop stepHeight snaps onto the lower surface (stays grounded);
275
+ a larger drop goes airborne.
276
+
277
+ Footgun caught in the prototype: my first stair test used *deep
278
+ overlapping* steps, where the round-bottom roll alone climbs and the
279
+ explicit step-up looked unnecessary. Realistic *thin* treads (shallower
280
+ than the capsule footprint) need the step-up — see the thin-tread tests.
281
+
282
+ **Guard tests** (`collision/Stairs.spec.js` + `KinematicMoverIntegration.spec.js`,
283
+ all green): climb a 5-step staircase; climb a thin-tread staircase
284
+ (treads < footprint) both at the mover level and through the full
285
+ controller; clear a single curb; a 0.5 m riser (> stepHeight) blocks;
286
+ a 0.5 m riser is a **clean wall — no edge ride-up, and clearing is
287
+ speed-independent** (walk and sprint both stop dead, the gray-box guard);
288
+ descend a staircase grounded the whole way.
289
+
290
+ ### Phase 4 Motor seam + delete old code ✅ **landed**
291
+ The mover is now the **only** collision path when a `PhysicsSystem` is
292
+ present the opt-in flag is gone; dispatch is on physics presence, not
293
+ a flag.
294
+
295
+ What shipped:
296
+ - `_integrateVerticalAndResolveGround` is now a thin dispatcher:
297
+ `_applyGravity` (the motor) `_moveViaMover` (physics) **or**
298
+ `_moveFlatGround` (no physics) `_detectJumpApex`.
299
+ - **Gravity** extracted to `_applyGravity`; **land / leave-ground**
300
+ extracted to `_onLand` / `_onLeaveGround`, shared by both move paths
301
+ (the duplicated scaffolding from Phase 1's wiring is gone).
302
+ - **Deleted** `_moveAndSlide` (+ its `CAST_STEP_HEIGHT` / `SKIN`
303
+ constants and the `slideRay` / `slideHit` scratch), the legacy ground-
304
+ resolution body, and the `useKinematicMover` flag.
305
+ - `MoveAndSlide.spec.js` deleted — it tested `_moveAndSlide`; its
306
+ scenarios (wall-stop, axis + oblique slide, anti-tunnel) are covered
307
+ by `collision/KinematicMover.spec.js`.
308
+
309
+ What stayed (deliberately):
310
+ - `useBuiltInFlatGround` / `groundY` / `groundResolver` remain — they're
311
+ the **no-physics** fallback (`_moveFlatGround`) for headless and
312
+ control-layer unit tests, and the resolver is still spec-covered. With
313
+ physics present they're unused (the mover probes the world).
314
+ - **WallRun still self-integrates** its reduced-gravity model rather than
315
+ routing through `move()`. Slide already routes through the mover (it
316
+ calls `_integrateVerticalAndResolveGround`); WallRun's custom model is
317
+ left as a future enhancement, not required for the legacy deletion.
318
+
319
+ **Result:** 29 suites / 236 tests green; the entire ability + jump +
320
+ momentum + posture suite passes through the consolidated path.
321
+
322
+ ---
323
+
324
+ ## 6. Physics requirements — status
325
+
326
+ **Resolved.** My one HIGH ask from the review — a public penetration
327
+ query — is **satisfied**: `compute_penetration` is public and is exactly
328
+ the right shape (unit B→A direction + scalar depth, capsule-vs-anything).
329
+ It graduates from "deferred safety net" to the **core recover/settle
330
+ primitive** the whole mover leans on (steps 1 & 5). Confirmed it works:
331
+ yes, this is precisely what's needed — thank you for keeping it decoupled
332
+ and public.
333
+
334
+ **No remaining blocking requirements.** Phases 1–4 need nothing further
335
+ from the physics engine. The two low-priority niceties from the review
336
+ turned out not to bite:
337
+ - *Contact point from `shapeCast`* — not needed; ground-surface Y is
338
+ derivable from the cast `t` + the capsule bottom offset.
339
+ - *Multi-contact manifold from one query* — covered for resting contact
340
+ by iterating `compute_penetration` over **every** `overlap()` body in
341
+ the recover pass; the single-sweep corner case is handled by the slide
342
+ iteration instead. So no native multi-contact query is required.
343
+
344
+ If anything *would* be a future nicety (not needed now): a swept query
345
+ returning the *set* of TOI-simultaneous contacts (for one-pass corner
346
+ creases instead of iterating). Strictly an optimisation; the iterative
347
+ slide is correct without it.
348
+
349
+ ---
350
+
351
+ ## 7. Constants (ours ← reference lineage)
352
+
353
+ | Constant | Plan value | Reference | Phase |
354
+ |---|---|---|---|
355
+ | slide iterations | 4 | Quake `numbumps` 4 | 1 |
356
+ | `SKIN` | 0.005 m | Fauerby `veryCloseDistance` ~0.005 | 1 |
357
+ | recover max iters | ~4 | — (convergence cap) | 1 |
358
+ | `MIN_WALK_NORMAL` | ~0.7 (≈45°) | Quake3 0.7 / Source `normal.z ≥ 0.7` | 2 |
359
+ | ground-probe band | ~0.06 m | Source `StayOnGround` ~2 u | 2 |
360
+ | crease dead-stop | zero on 3rd plane | Quake `SV_FlyMove` | 1 |
361
+ | `stepHeight` | ~0.3 m (tunable) | Source `sv_stepsize` 18 u (~0.34 m) | 3 |
362
+
363
+ Clip stays at effective overbounce `1.0` gated on into-wall (`v·n < 0`),
364
+ relying on `SKIN` for clearance — equivalent in practice to Quake3's
365
+ `OVERCLIP 1.001` nudge; no change planned.