@woosh/meep-engine 2.143.0 → 2.145.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 (56) 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/PointShape3D.d.ts +1 -0
  8. package/src/core/geom/3d/shape/PointShape3D.d.ts.map +1 -1
  9. package/src/core/geom/3d/shape/PointShape3D.js +11 -0
  10. package/src/core/geom/3d/shape/SphereShape3D.d.ts +1 -0
  11. package/src/core/geom/3d/shape/SphereShape3D.d.ts.map +1 -1
  12. package/src/core/geom/3d/shape/SphereShape3D.js +4 -0
  13. package/src/core/geom/3d/shape/json/shape_to_type.d.ts.map +1 -1
  14. package/src/core/geom/3d/shape/json/shape_to_type.js +3 -0
  15. package/src/core/geom/3d/shape/json/type_adapters.d.ts +15 -0
  16. package/src/core/geom/3d/shape/json/type_adapters.d.ts.map +1 -1
  17. package/src/core/geom/3d/shape/json/type_adapters.js +16 -0
  18. package/src/engine/control/first-person/DESIGN_COLLISION.md +314 -217
  19. package/src/engine/control/first-person/FirstPersonPlayerControllerSystem.d.ts +104 -58
  20. package/src/engine/control/first-person/FirstPersonPlayerControllerSystem.d.ts.map +1 -1
  21. package/src/engine/control/first-person/FirstPersonPlayerControllerSystem.js +1828 -1789
  22. package/src/engine/control/first-person/TODO.md +17 -32
  23. package/src/engine/control/first-person/abilities/WallRun.d.ts.map +1 -1
  24. package/src/engine/control/first-person/abilities/WallRun.js +18 -35
  25. package/src/engine/control/first-person/collision/KinematicMover.d.ts +206 -0
  26. package/src/engine/control/first-person/collision/KinematicMover.d.ts.map +1 -0
  27. package/src/engine/control/first-person/collision/KinematicMover.js +592 -0
  28. package/src/engine/control/first-person/prototype_first_person_controller.js +65 -0
  29. package/src/engine/graphics/render/buffer/simple-fx/ao/SAOShader.js +18 -9
  30. package/src/engine/physics/PLAN.md +145 -41
  31. package/src/engine/physics/contact/ManifoldStore.d.ts +28 -2
  32. package/src/engine/physics/contact/ManifoldStore.d.ts.map +1 -1
  33. package/src/engine/physics/contact/ManifoldStore.js +37 -3
  34. package/src/engine/physics/contact/combine_material.d.ts +30 -0
  35. package/src/engine/physics/contact/combine_material.d.ts.map +1 -0
  36. package/src/engine/physics/contact/combine_material.js +35 -0
  37. package/src/engine/physics/ecs/Collider.d.ts +15 -0
  38. package/src/engine/physics/ecs/Collider.d.ts.map +1 -1
  39. package/src/engine/physics/ecs/Collider.js +34 -0
  40. package/src/engine/physics/ecs/Joint.d.ts +18 -0
  41. package/src/engine/physics/ecs/Joint.d.ts.map +1 -1
  42. package/src/engine/physics/ecs/Joint.js +70 -0
  43. package/src/engine/physics/ecs/PhysicsSystem.d.ts +9 -4
  44. package/src/engine/physics/ecs/PhysicsSystem.d.ts.map +1 -1
  45. package/src/engine/physics/ecs/PhysicsSystem.js +9 -4
  46. package/src/engine/physics/ecs/RigidBody.d.ts +15 -0
  47. package/src/engine/physics/ecs/RigidBody.d.ts.map +1 -1
  48. package/src/engine/physics/ecs/RigidBody.js +46 -0
  49. package/src/engine/physics/narrowphase/compute_penetration.d.ts +41 -41
  50. package/src/engine/physics/narrowphase/compute_penetration.d.ts.map +1 -1
  51. package/src/engine/physics/narrowphase/compute_penetration.js +96 -169
  52. package/src/engine/physics/narrowphase/narrowphase_step.d.ts +52 -0
  53. package/src/engine/physics/narrowphase/narrowphase_step.d.ts.map +1 -1
  54. package/src/engine/physics/narrowphase/narrowphase_step.js +130 -3
  55. package/src/engine/physics/solver/solve_contacts.d.ts.map +1 -1
  56. package/src/engine/physics/solver/solve_contacts.js +10 -21
@@ -1,255 +1,352 @@
1
- # Collision handling — construction plan
1
+ # Collision handling — ground-up construction plan
2
2
 
3
- Companion to DESIGN.md (base controller) and TODO.md. This is the
4
- roadmap for taking the controller's collision response from the current
5
- 2.5D split to a unified, slope-aware, anti-tunnelling mover modelled on
6
- the Quake → Source `TryPlayerMove` / `CategorizePosition` lineage —
7
- using the now-accurate `shapeCast` / `raycast` the physics engine
8
- gained over the P1–P6 narrowphase work.
3
+ Companion to DESIGN.md (base controller) and TODO.md.
9
4
 
10
- Every phase is **guard-test-first**: write the failing spec that pins
11
- the behaviour, then implement until green, then run the full
12
- first-person suite. Phases are ordered so the controller stays shippable
13
- and tested after each one no flag day.
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.
14
18
 
15
19
  ---
16
20
 
17
- ## 1. Goal & non-goals
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
+ ```
18
38
 
19
- **Goal.** A single swept move-and-slide that handles walls, floors,
20
- ceilings, ramps, and getting unstuck with true surface normals — so
21
- abilities (Slide, WallRun, Mantle) inherit correct collision for free
22
- by routing their motion through it.
39
+ The control layer's output each tick is a **desired velocity** (it
40
+ already computes thisintent+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.
23
45
 
24
- **Non-goals.**
25
- - Bunny-hop / strafe-jump emergent acceleration. We deliberately
26
- *preserve* momentum (Mirror's Edge / modern-CoD model), not *amplify*
27
- it (Quake / Titanfall model). See the momentum note in DESIGN.md.
28
- - A full dynamics solver for the player. The player stays a
29
- `KinematicPosition` body; the controller owns the Transform.
30
- - Replacing the abilities' bespoke motion models — only giving them a
31
- correct shared mover to call.
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.
32
50
 
33
51
  ---
34
52
 
35
- ## 2. Current state (one paragraph)
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.
36
58
 
37
- `_integrateVerticalAndResolveGround` does a **2.5D split**: gravity
38
- horizontal-only swept slide (`_moveAndSlide`, 4-iteration collide-and-
39
- slide, `SKIN = 0.005`, `CAST_STEP_HEIGHT = 0.05`) → a *direct* vertical
40
- position add → a downward-ray ground resolver that snaps a scalar Y.
41
- The slide projects against true narrowphase normals (good), but: the
42
- ground resolver discards the surface normal (no slopes), vertical motion
43
- isn't swept (tunnels through thin floors, no ceiling detection), corners
44
- re-project planes independently (no crease/dead-stop), and there's no
45
- depenetration. Full analysis in the review that preceded this doc.
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).
46
68
 
47
69
  ---
48
70
 
49
- ## 3. Physics capabilities we can now lean on
71
+ ## 3. Physics capabilities we build on
50
72
 
51
- The P1–P6 narrowphase + query work made the two primitives we depend on
52
- narrowphase-exact. This plan is only feasible because of it.
73
+ The P1–P6 narrowphase/query work is the enabling foundation the
74
+ rebuild is only feasible because these are now narrowphase-exact.
53
75
 
54
76
  | Capability | Status | Source |
55
77
  |---|---|---|
56
- | `raycast` exact surface **normal** (sphere/box/capsule/mesh/heightmap) | ✅ landed | refine_ray_hit P1–P3 (`6f931b4`, `5cc2e3e`, `1a2a602`, `4c2292c`) |
57
- | `shapeCast` true contact **normal at TOI** via MPR (not broadphase −dir) | ✅ landed | MPR normal-recovery in `shape_cast.js` |
58
- | `shapeCast` **start-in-contact** → returns `t=0` + valid normal | ✅ landed | `shape_cast.js:195`, normal probe `:350` |
59
- | `shapeCast` analytic slab-narrowing (long-sweep accuracy/perf) | ✅ landed | `shape_cast.js:203` |
60
- | EPA adaptive tolerance (no silent sub-mm failure) | ✅ landed | P2.2 (`8ff15a6`) |
61
- | MPR fallback when EPA doesn't converge | ✅ landed | P3.1 (`419b94a`) |
62
- | box-box edge-edge closest-pair (not centre-midpoint) | ✅ landed | P3.2 (`419b94a`) |
63
- | capsule-vs-triangle closed-form multi-point manifold | ✅ landed | P1.1c (`eaa75fa`) |
64
- | GJK separating-axis cache (cheap warm casts) | ✅ landed | P2.1 (`60ca759`) |
65
- | `overlap(shape,pos,rot,out,off,filter)` body-id list | landed | `overlap_shape.js` |
66
- | `compute_penetration(...)` depth + B→A direction | ⚠️ **internal-only** | `compute_penetration.js` — not on PhysicsSystem |
67
-
68
- Query contract notes the plan relies on:
69
- - `shapeCast` `result.t` is a **world distance** when the ray direction
70
- is unit-length and `tMax = sweepLength` (how `_moveAndSlide` already
71
- uses it). New casts must keep that convention.
72
- - `result.position` is the **swept shape centre**, not the surface
73
- contact point. We never need the contact point in this plan; flagged
74
- in case edge/foot placement ever does.
75
- - `result.normal` is the **target's outward** surface normal.
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.
76
96
 
77
97
  ---
78
98
 
79
- ## 4. Target architecture
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).
80
105
 
81
- Replace the 2.5D split with a **unified swept solve + ground
82
- categorize**, the Source pattern:
106
+ It knows nothing about the controller just a capsule, a velocity, the
107
+ world, and an `up` axis. Single entry point:
83
108
 
84
109
  ```
85
- move(dt):
86
- applyGravityToVelocity() # vy -= g·dt
87
- delta = velocity · dt # full 3D, gravity folded in
88
- collideAndSlide(delta) # swept, all axes, crease-aware
89
- categorizeGround() # short down-probe → grounded + normal
90
- if grounded and walkable: stayOnGround() # snap to surface, zero vy
110
+ move(transform, shape, velocity, dt, filter) → MoveResult
111
+ // mutates transform.position; returns:
112
+ // { velocityX/Y/Z (corrected), grounded, groundNormal, hit }
91
113
  ```
92
114
 
93
- Three pieces:
94
-
95
- 1. **`collideAndSlide(delta)`**the existing 4-iteration loop,
96
- generalised to 3D and made crease-aware (two-plane seam projection,
97
- three-plane dead-stop). Gravity is part of `delta`, so floors and
98
- ceilings are just contact planes anti-tunnelling falls out.
99
-
100
- 2. **`categorizeGround()`** a short **downward `shapeCast`** (the
101
- capsule swept down by `SKIN + groundProbeBand`, e.g. ~6 cm). Returns
102
- `{grounded, surfaceY, normal}`. Replaces the scalar-Y resolver as the
103
- authoritative ground source when physics is present; the host
104
- resolver / flat-ground stays as the no-physics fallback, behind one
105
- `_probeGround()` function so callers never branch (uniform flow).
106
-
107
- 3. **`stayOnGround()`**when categorize says grounded on a walkable
108
- plane, snap `position.y` to `surfaceY` and zero `vy`. This is what
109
- kills the SKIN-vs-snap **bounce** (the bug we hit earlier): grounded
110
- is a *band test + active snap*, not a strict `y <= testY`
111
- inequality, so landing at `floor + SKIN` doesn't re-flag airborne.
112
-
113
- Walkability gate: `groundNormal.y >= MIN_WALK_NORMAL` (≈0.7, ~45°). On a
114
- steeper face the player isn't grounded they slide, because the slope
115
- is just another clip plane in `collideAndSlide` and gravity carries them
116
- down it.
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()`.
117
158
 
118
159
  ---
119
160
 
120
- ## 5. Phased plan
121
-
122
- ### Phase 1 — Ground normal + slope policy
123
- *Review gap #1. Highest feel/realism leverage; lowest risk.*
124
-
125
- - Add `_probeGround(runtime, bodyTransform) {grounded, surfaceY, normal}`.
126
- Physics path: downward `shapeCast` of the capsule, read `hit.normal`.
127
- No-physics path: existing resolver / flat-ground, normal = `(0,1,0)`.
128
- - Write `state.groundNormal`. Add `MIN_WALK_NORMAL` to config.
129
- - Reproject grounded horizontal velocity onto the ground plane
130
- (`v -= (v·n)·n`) so ramps don't launch (downhill) or clip (uphill).
131
- - Steep facenot grounded slides under gravity.
132
-
133
- **Guard tests** (new `GroundSlope.spec.js`): walk up a 30° ramp keeps
134
- speed and stays grounded; stand on shallow slope = grounded with correct
135
- normal; 60° face = not grounded, player slides downhill, not glued.
136
-
137
- **Risk:** low. Self-contained; resolver fallback unchanged for existing
138
- tests.
139
-
140
- ### Phase 2 Crease / corner handling
141
- *Review gap #3. Cheap correctness win in the existing loop.*
142
-
143
- - Track planes hit within one `_moveAndSlide` call. On a second plane
144
- that re-violates the first, project the residual onto the seam
145
- `normalize(n₁ × n₂)`. On a third plane in one frame, zero the residual
146
- (and the into-wall velocity) — Quake's dead-stop.
147
-
148
- **Guard tests** (extend `MoveAndSlide.spec.js`): oblique (non-axis)
149
- inside corner stops cleanly with no residual leak and no tick-to-tick
150
- chatter; narrow wedge dead-stops instead of squirting through.
151
-
152
- **Risk:** low–medium. Pure addition to the slide loop; existing
153
- single-plane tests must stay green.
154
-
155
- ### Phase 3 — Unify vertical into the swept solve
156
- *Review gap #2 (anti-tunnel). The structural change; do it after 1 & 2
157
- so slope + crease are already trustworthy.*
158
-
159
- - Fold gravity-driven vertical into the swept `delta`; remove the direct
160
- `position.y += vy·dt` add.
161
- - Replace the strict-inequality resolver snap with
162
- `categorizeGround()` + `stayOnGround()` (§4).
163
- - Route Slide / WallRun vertical through the same mover (TODO's "ability
164
- motion routing" they stop hand-integrating gravity).
165
-
166
- **Guard tests** (new `VerticalSweep.spec.js`): drop from height onto a
167
- thin physics slab no tunnelling, lands on top; jump straight into a
168
- ceiling stops, doesn't pass through; **the stationary-on-floor case
169
- stays at y≈0 across 120 ticks** (generalise the earlier no-bounce repro,
170
- now without an escape pass to mask it).
171
-
172
- **Risk:** medium–high. Changes the contract `_integrate…` exposes to
173
- abilities; they're updated in the same phase. This is the phase most
174
- likely to surface a physics gap (see §6).
175
-
176
- ### Phase 4 Depenetration safety net
177
- *Review gap #4. Minimal, not the rolled-back ring-buffer escape.*
178
-
179
- - One bounded nudge: if the capsule starts a tick overlapping (detect
180
- via `shapeCast` `t=0`, or `overlap()`), push out along the contact
181
- normal by `depth + SKIN`, capped at ~4 iterations. No ring buffer, no
182
- teleport, no rescue signal just don't stay solid.
183
- - **Blocked on §6 requirement #1** for a clean depth. Without it, the
184
- fallback is a fixed-step nudge along the normal (no exact depth),
185
- which is cruder but unblocks the phase.
186
-
187
- **Guard tests** (new `Depenetration.spec.js`): spawn the player
188
- straddling a box surface within a few ticks they're outside; a body
189
- that spawns on top of a standing player player ends up clear; deep
190
- burial terminates (no infinite loop), doesn't teleport.
191
-
192
- **Risk:** low if requirement #1 lands; medium otherwise (cruder nudge
193
- needs more tuning).
194
-
195
- ### Phase 5 — Real step-up *(optional / deferrable)*
196
- *Review gap #5.*
197
-
198
- - Source-style `raise move trace-down` for stairs above the 5 cm
199
- implicit step, OR keep stairs as the Mantle ability's job and document
200
- the curb ceiling. Likely **defer** Mantle already covers ledges; a
201
- decision, not necessarily code.
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). Three cooperating pieces — it took iterating to
199
+ find that all three are needed:
200
+
201
+ 1. **Explicit step-up** (`_tryStepUp`, Source/Jolt up-forward-down).
202
+ When a grounded move is blocked, decide step-vs-wall with a **thin
203
+ horizontal ray at the step-height plane** (`sy + stepHeight + skin`),
204
+ then only if clear lift by stepHeight, advance the residual
205
+ forward, drop onto the step, commit when it gained ground within
206
+ stepHeight, and **restore the horizontal velocity**. The velocity
207
+ restore is the crux for REALISTIC stairs: the capsule's round bottom
208
+ also rolls up low risers, but only with sustained forward momentum,
209
+ and a controller that reads back the slide-clipped velocity loses
210
+ that momentum at the riser and stalls. The step-up climbs without
211
+ depending on momentum and hands the velocity back so the player keeps
212
+ moving up the flight.
213
+
214
+ The step-vs-wall ray is what stops the round bottom climbing a
215
+ too-tall wall and the reason it's a THIN ray, not the obvious
216
+ "lift the capsule and sweep it forward" clearance cast: lifted so its
217
+ tip sits at stepHeight, the capsule's rounded bottom narrows through
218
+ the band `(stepHeight, stepHeight+radius)`, so a wall whose top lands
219
+ in that band is never reached by the swept shape — it reads "clear"
220
+ and the player climbs it, *faster speed reaching further into the
221
+ round-off* (the gray-box bug: jitter at a walk, clean climb at a
222
+ sprint). A thin ray has no round-off: it reads the obstacle's true
223
+ height, so a step (top below the plane) is passed over and a wall
224
+ (top above) is struck on its front face, at any approach speed.
225
+
226
+ The ray is cast along the **blocked direction** (the horizontal
227
+ velocity the slide removed = the obstacle's inward normal), from the
228
+ **pre-slide** centre, reaching the swept distance plus a forward
229
+ extent. Both matter for OBLIQUE approaches: at 45° the capsule meets a
230
+ wall with its normal-direction extent, so a ray along *travel* stops
231
+ short of the face and reads a wall as clear (climbing it); and
232
+ rounding a convex corner the slide carries the *post*-slide centre
233
+ just past the corner, so a ray from there shoots past the obstacle —
234
+ the pre-slide centre was still in front of what blocked it. The reach
235
+ tracking the sweep isn't the banned speed coupling: the climb-or-block
236
+ decision is the step-height plane alone; the reach only governs how
237
+ far ahead to look for what the slide already hit.
238
+
239
+ A single ray still can't catch a convex *point*: grazing a corner a
240
+ few degrees off its bisector, the blocked normal runs nearly parallel
241
+ to a face, so the ray slips past the corner just outside the footprint
242
+ and reads clear. The backstop is a post-hoc OVERLAP query — after
243
+ up-forward-down, if the destination overlaps geometry the climb landed
244
+ the round body ON a wall corner, not on a step (a real step top is
245
+ rested on `skin` above, never overlapping), so it's rejected. Probe for
246
+ the common case, overlap-test to catch what a ray threads past.
247
+
248
+ The same round-bottom perch is why the slide keeps every contact's true
249
+ normal (no "flatten steep contacts to vertical" — that would also rob
250
+ a too-steep *slope* of its downhill slide) and why the footprint
251
+ mount in (2) is gated on height above the **centre surface**, not the
252
+ feet (gating on the feet lets a capsule that rode up a hair mount the
253
+ next sliver and ratchet up a wall).
254
+ 2. **Two-probe ground categorize** for honest grounded-ness on a step
255
+ edge — *walkability* from a centre raycast (ignores a step's convex
256
+ top edge and a wall's side face, so a steep *slope* is correctly
257
+ not-grounded) and *rest height* from a footprint shapecast (mounts
258
+ a step the leading edge overhangs). A single probe can't tell a
259
+ climbable step edge from a steep slope; the split can.
260
+ 3. **Step-DOWN** = the ground-stick reach is `stepHeight`: walking off
261
+ a drop ≤ stepHeight snaps onto the lower surface (stays grounded);
262
+ a larger drop goes airborne.
263
+
264
+ Footgun caught in the prototype: my first stair test used *deep
265
+ overlapping* steps, where the round-bottom roll alone climbs and the
266
+ explicit step-up looked unnecessary. Realistic *thin* treads (shallower
267
+ than the capsule footprint) need the step-up — see the thin-tread tests.
268
+
269
+ **Guard tests** (`collision/Stairs.spec.js` + `KinematicMoverIntegration.spec.js`,
270
+ all green): climb a 5-step staircase; climb a thin-tread staircase
271
+ (treads < footprint) both at the mover level and through the full
272
+ controller; clear a single curb; a 0.5 m riser (> stepHeight) blocks;
273
+ a 0.5 m riser is a **clean wall — no edge ride-up, and clearing is
274
+ speed-independent** (walk and sprint both stop dead, the gray-box guard);
275
+ descend a staircase grounded the whole way.
276
+
277
+ ### Phase 4 — Motor seam + delete old code ✅ **landed**
278
+ The mover is now the **only** collision path when a `PhysicsSystem` is
279
+ present — the opt-in flag is gone; dispatch is on physics presence, not
280
+ a flag.
281
+
282
+ What shipped:
283
+ - `_integrateVerticalAndResolveGround` is now a thin dispatcher:
284
+ `_applyGravity` (the motor) → `_moveViaMover` (physics) **or**
285
+ `_moveFlatGround` (no physics) → `_detectJumpApex`.
286
+ - **Gravity** extracted to `_applyGravity`; **land / leave-ground**
287
+ extracted to `_onLand` / `_onLeaveGround`, shared by both move paths
288
+ (the duplicated scaffolding from Phase 1's wiring is gone).
289
+ - **Deleted** `_moveAndSlide` (+ its `CAST_STEP_HEIGHT` / `SKIN`
290
+ constants and the `slideRay` / `slideHit` scratch), the legacy ground-
291
+ resolution body, and the `useKinematicMover` flag.
292
+ - `MoveAndSlide.spec.js` deleted — it tested `_moveAndSlide`; its
293
+ scenarios (wall-stop, axis + oblique slide, anti-tunnel) are covered
294
+ by `collision/KinematicMover.spec.js`.
295
+
296
+ What stayed (deliberately):
297
+ - `useBuiltInFlatGround` / `groundY` / `groundResolver` remain — they're
298
+ the **no-physics** fallback (`_moveFlatGround`) for headless and
299
+ control-layer unit tests, and the resolver is still spec-covered. With
300
+ physics present they're unused (the mover probes the world).
301
+ - **WallRun still self-integrates** its reduced-gravity model rather than
302
+ routing through `move()`. Slide already routes through the mover (it
303
+ calls `_integrateVerticalAndResolveGround`); WallRun's custom model is
304
+ left as a future enhancement, not required for the legacy deletion.
305
+
306
+ **Result:** 29 suites / 236 tests green; the entire ability + jump +
307
+ momentum + posture suite passes through the consolidated path.
202
308
 
203
309
  ---
204
310
 
205
- ## 6. Unsatisfied physics requirements
206
-
207
- Things I'd want from the physics engine to build §5 cleanly. None block
208
- Phases 1–3; #1 shapes Phase 4.
209
-
210
- 1. **Public penetration/contact query `PhysicsSystem.computePenetration`
211
- (or `contact`).** `compute_penetration(out_dir, shapeA, posA, rotA,
212
- shapeB, posB, rotB) depth` already exists and is exactly right, but
213
- it's an **internal narrowphase utility, not exposed** on
214
- `PhysicsSystem`. Phase 4 depenetration wants `{normal, depth}` for the
215
- player capsule vs each overlapping body. Today I can get the *fact* of
216
- overlap (`overlap()` ids) and a *normal* (`shapeCast` `t=0`), but
217
- **not the depth** `shapeCast` discards it at `t=0`. Wiring this
218
- through is small (the function is written and tested) and is the
219
- single highest-value API add for the controller. **Priority: HIGH.**
220
-
221
- 2. **`overlap()` that can optionally emit per-body `{normal, depth}`,
222
- not just ids.** Subsumed by #1 if I call `computePenetration` per
223
- returned id acceptable. Only worth doing natively if profiling
224
- shows the per-body re-query matters. **Priority: LOW** (covered by #1).
225
-
226
- 3. **Contact *point* (not just swept-centre) from `shapeCast`.** Not
227
- needed for this plan, but any future foot-placement / ledge-edge or
228
- multi-point ground balancing wants the world contact point.
229
- `result.position` is the swept centre today. **Priority: LOW.**
230
-
231
- 4. **Multi-contact ground manifold from one query.** `shapeCast` returns
232
- the single closest-`t` contact. A capsule straddling the seam between
233
- two boxes gets one normal, not both — fine for v1 grounded snap, a
234
- limitation for precise edge-balancing. **Priority: LOW.**
235
-
236
- If only one thing gets built: **#1**. It turns Phase 4 from "tune a
237
- blind nudge" into "push out by the exact reported depth," and it's
238
- mostly plumbing over code that already exists and is tested.
311
+ ## 6. Physics requirements — status
312
+
313
+ **Resolved.** My one HIGH ask from the review a public penetration
314
+ query is **satisfied**: `compute_penetration` is public and is exactly
315
+ the right shape (unit B→A direction + scalar depth, capsule-vs-anything).
316
+ It graduates from "deferred safety net" to the **core recover/settle
317
+ primitive** the whole mover leans on (steps 1 & 5). Confirmed it works:
318
+ yes, this is precisely what's needed thank you for keeping it decoupled
319
+ and public.
320
+
321
+ **No remaining blocking requirements.** Phases 1–4 need nothing further
322
+ from the physics engine. The two low-priority niceties from the review
323
+ turned out not to bite:
324
+ - *Contact point from `shapeCast`* not needed; ground-surface Y is
325
+ derivable from the cast `t` + the capsule bottom offset.
326
+ - *Multi-contact manifold from one query* — covered for resting contact
327
+ by iterating `compute_penetration` over **every** `overlap()` body in
328
+ the recover pass; the single-sweep corner case is handled by the slide
329
+ iteration instead. So no native multi-contact query is required.
330
+
331
+ If anything *would* be a future nicety (not needed now): a swept query
332
+ returning the *set* of TOI-simultaneous contacts (for one-pass corner
333
+ creases instead of iterating). Strictly an optimisation; the iterative
334
+ slide is correct without it.
239
335
 
240
336
  ---
241
337
 
242
338
  ## 7. Constants (ours ← reference lineage)
243
339
 
244
- | Constant | Plan value | Reference | Note |
340
+ | Constant | Plan value | Reference | Phase |
245
341
  |---|---|---|---|
246
- | slide iterations | 4 | Quake `numbumps` 4 | unchanged |
247
- | `SKIN` | 0.005 m | Fauerby `veryCloseDistance` ~0.005 | unchanged |
248
- | `MIN_WALK_NORMAL` | ~0.7 (≈45°) | Quake3 `MIN_WALK_NORMAL` 0.7 / Source `normal.z ≥ 0.7` | **new, Phase 1** |
249
- | ground-probe band | ~0.06 m | Source `StayOnGround` ~2 u down-snap | **new, Phase 3** |
250
- | crease dead-stop | zero on 3rd plane | Quake `SV_FlyMove` | **new, Phase 2** |
251
- | step height | 0.05 m (implicit) | Source `sv_stepsize` 18 u (~0.34 m) | Phase 5 decision |
252
-
253
- Overbounce: we keep the clip at effective `1.0` gated on into-wall
254
- (`v·n < 0`), relying on `SKIN` for clearance, rather than Quake3's
255
- `OVERCLIP 1.001` nudge. No change planned — equivalent in practice.
342
+ | slide iterations | 4 | Quake `numbumps` 4 | 1 |
343
+ | `SKIN` | 0.005 m | Fauerby `veryCloseDistance` ~0.005 | 1 |
344
+ | recover max iters | ~4 | (convergence cap) | 1 |
345
+ | `MIN_WALK_NORMAL` | ~0.7 (≈45°) | Quake3 0.7 / Source `normal.z 0.7` | 2 |
346
+ | ground-probe band | ~0.06 m | Source `StayOnGround` ~2 u | 2 |
347
+ | crease dead-stop | zero on 3rd plane | Quake `SV_FlyMove` | 1 |
348
+ | `stepHeight` | ~0.3 m (tunable) | Source `sv_stepsize` 18 u (~0.34 m) | 3 |
349
+
350
+ Clip stays at effective overbounce `1.0` gated on into-wall (`v·n < 0`),
351
+ relying on `SKIN` for clearance — equivalent in practice to Quake3's
352
+ `OVERCLIP 1.001` nudge; no change planned.