@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,9 +1,9 @@
1
1
  import { aabb3_transform_oriented } from "../../../core/geom/3d/aabb/aabb3_transform_oriented.js";
2
2
  import { v3_quat3_apply } from "../../../core/geom/vec3/v3_quat3_apply.js";
3
- import { mpr } from "../gjk/mpr.js";
4
3
  import { aabb_world_to_local } from "./decomposition/aabb_world_to_local.js";
5
4
  import { decompose_to_triangles } from "./decomposition/decompose_to_triangles.js";
6
5
  import { TRIANGLE_FLOAT_STRIDE } from "./decomposition/triangle_buffer_layout.js";
6
+ import { deepest_pair_penetration } from "./narrowphase_step.js";
7
7
  import { PosedShape } from "./PosedShape.js";
8
8
 
9
9
  /**
@@ -11,54 +11,45 @@ import { PosedShape } from "./PosedShape.js";
11
11
  * the other narrowphase utilities. Safe because PhysicsSystem queries
12
12
  * (and gameplay code calling this) run on the main thread.
13
13
  */
14
- const posed_a = new PosedShape();
15
14
  const posed_b = new PosedShape();
16
15
 
17
- // MPR result buffer — direction is "A's interior into B", magnitude is
18
- // the depth. Same convention as EPA but MPR converges reliably on
19
- // smooth-vs-smooth supports where EPA hits its iteration cap and
20
- // returns a noisy closest-face approximation.
21
- const mpr_result = new Float64Array(3);
16
+ /**
17
+ * Scratch normal written by the primary {@link deepest_pair_penetration} query.
18
+ * Copied to the caller's `out_direction` only when the depth clears
19
+ * {@link CONTACT_EPSILON}, preserving the "untouched on no overlap" contract.
20
+ * @type {Float64Array}
21
+ */
22
+ const primary_normal = new Float64Array(3);
22
23
 
23
24
  /**
24
- * Scratch buffers for the convex-vs-concave path (see
25
- * {@link compute_penetration_concave}).
25
+ * Scratch buffers for the convex-vs-concave recovery path (see
26
+ * {@link concave_recovery_penetration}).
26
27
  */
27
28
  const local_aabb = new Float64Array(6);
28
29
  const world_aabb = new Float64Array(6);
29
30
  const concave_query_aabb = new Float64Array(6);
30
31
  const scratch_v3 = new Float64Array(3);
31
- // Dedicated scratch for the per-triangle q · v · q* rotations below
32
- // (face normal + centroid). Distinct from `scratch_v3` (used by the
33
- // support-function call later in the same iteration) to keep the
34
- // data flow obvious.
35
32
  const scratch_rot = new Float64Array(3);
36
33
 
37
34
  /**
38
- * Per-pair triangle decomposition cap. Same rationale as
39
- * `narrowphase_step.MAX_TRIANGLES_PER_PAIR` and
40
- * `overlap_shape.MAX_TRIANGLES_PER_PAIR`: the query AABB is already
41
- * bounded by the convex shape's envelope, so a single pair typically
42
- * yields tens of triangles. Excess triangles are dropped by the
43
- * enumerator's bounds check.
35
+ * Per-pair triangle decomposition cap for the recovery path. Same rationale as
36
+ * `narrowphase_step.MAX_TRIANGLES_PER_PAIR`: the query AABB is bounded by the
37
+ * convex shape's envelope, so a single pair yields tens of triangles. Excess
38
+ * is dropped by the enumerator's bounds check.
44
39
  * @type {number}
45
40
  */
46
41
  const MAX_TRIANGLES_PER_PAIR = 1024;
47
42
  const triangle_buffer = new Float64Array(MAX_TRIANGLES_PER_PAIR * TRIANGLE_FLOAT_STRIDE);
48
43
 
49
44
  /**
50
- * Penetration depths below this are treated as no contact. GJK can
51
- * report tangent configurations as "overlap" at exact tangent the
52
- * Minkowski difference touches the origin and the algorithm can go
53
- * either way; if it says overlap, EPA then returns a sub-micron depth
54
- * that reflects numerical noise rather than a real penetration. The
55
- * "0 means no overlap" contract is more useful when small-noise hits
56
- * are filtered out at the source.
45
+ * Penetration depths below this are treated as no contact. The narrowphase
46
+ * dispatch can report sub-micron "overlap" at exact tangent (GJK/EPA noise,
47
+ * or a closed-form solver returning a hair of depth on a kissing contact); the
48
+ * "0 means no overlap" contract is more useful when that noise is filtered out
49
+ * at the source.
57
50
  *
58
- * 1e-4 m (100 µm) chosen to be well below any practical world-scale
59
- * tolerance while still being larger than typical EPA convergence
60
- * residuals at exact tangent. Smaller values would let near-tangent
61
- * floating-point noise leak through as "tiny positive depth".
51
+ * 1e-4 m (100 µm) is well below any practical world-scale tolerance while
52
+ * still larger than typical convergence residuals at exact tangent.
62
53
  *
63
54
  * @type {number}
64
55
  */
@@ -76,56 +67,56 @@ const CONTACT_EPSILON = 1e-4;
76
67
  * `-out_direction * return_value` to `position_b`) is the minimum
77
68
  * translation that produces separation.
78
69
  *
79
- * If the shapes do not overlap (or EPA degenerates on a tangent
80
- * contact), the return value is `0` and `out_direction` is left
81
- * untouched. Callers should treat 0 as "no penetration".
70
+ * If the shapes do not overlap, the return value is `0` and
71
+ * `out_direction` is left untouched. Callers should treat 0 as "no
72
+ * penetration".
82
73
  *
83
74
  * Sign convention matches the narrowphase's stored contact normal:
84
- * - `out_direction` ≡ "B → A" direction
85
- * - Negative of cast direction in shape_cast at the kiss point
86
- * - The outward normal of B's surface at the contact, pointing
87
- * toward A
75
+ * `out_direction` ≡ the "B → A" direction (B's outward surface normal at the
76
+ * contact, pointing toward A).
88
77
  *
89
- * Built on the existing GJK + EPA + PosedShape primitives — same
90
- * precision characteristics (essentially exact for sign-based supports
91
- * like cubes; asymptotic on curved supports like spheres near tangent,
92
- * where the polytope iteration cap leaves a small angular residual on
93
- * the direction).
78
+ * ## How it is computed (hardened)
94
79
  *
95
- * ## Non-convex support
80
+ * The query routes through the **same narrowphase contact dispatch the solver
81
+ * consumes** ({@link deepest_pair_penetration} → `dispatch_pair`) and reports
82
+ * the deepest contact. That makes it correct — not "correct sometimes" — for
83
+ * every shape pair the engine can build:
96
84
  *
97
- * Exactly **one** of the two shapes may be non-convex (heightmap,
98
- * mesh). The non-convex shape is decomposed into triangles overlapping
99
- * the convex shape's AABB (via the same machinery the narrowphase
100
- * uses), and per-triangle GJK + EPA is run; the deepest contact's
101
- * direction and depth are reported. Concave-vs-concave throws the
102
- * M×N triangle-pair cost is out of scope for this primitive (and is
103
- * also refused by the narrowphase for dynamic pairs).
85
+ * - **sphere / box / capsule pairs** exact closed-form solvers
86
+ * (`sphere_sphere`, `sphere_box`, `box_box` via SAT, `capsule_*`). Box-box
87
+ * in particular uses the true minimum-translation axis, so a small body
88
+ * resting on a large box reports the few-cm overlap through the near face
89
+ * rather than the metres-deep "exit through the far side" a centroid-seeded
90
+ * MPR portal used to return.
91
+ * - **general convex pairs** (anything without a closed form) → GJK + EPA,
92
+ * which is exact for polytopes and is only ever reached by polytope-like
93
+ * shapes here, since every curved primitive (sphere, capsule) has a closed
94
+ * form above.
95
+ * - **convex vs concave** (one of heightmap / mesh) → triangle decomposition
96
+ * over the convex AABB + the closed-form per-triangle solvers
97
+ * (`sphere_triangle`, `box_triangle`, `capsule_triangle`), deepest wins.
98
+ * These are bounded to each triangle's true 2-D extent, so the historical
99
+ * over-report on closed-mesh side faces (infinite-plane extrapolation) is
100
+ * gone.
104
101
  *
105
- * ## Non-convex precision limits
102
+ * ## Convex-vs-concave recovery (fully-tunnelled bodies)
106
103
  *
107
- * The concave path is a per-triangle half-space test (convex's deepest
108
- * point along −face_normal, compared to the triangle's plane). This
109
- * is exact for **heightmaps** adjacent triangles cover the
110
- * boundary cases, and the face normal IS the contact direction.
104
+ * The per-triangle closed-form solvers are intentionally one-sided: a convex
105
+ * shape that has crossed to the *inner* side of a surface produces no
106
+ * from-outside contact (the narrowphase won't shove a body deeper into the
107
+ * solid mid-step). For a standalone penetration / depenetration query that is
108
+ * the wrong answer — the shape *is* overlapping the solid and must be pushed
109
+ * back out. When the primary dispatch finds no contact for a convex-vs-concave
110
+ * pair, this function falls back to a half-space test
111
+ * ({@link concave_recovery_penetration}) that reports the outward push-out
112
+ * vector. This is exact for heightmap terrain and a valid (if not strictly
113
+ * minimal) recovery direction for closed meshes.
111
114
  *
112
- * For **closed meshes** the half-space test extrapolates each triangle
113
- * as an infinite plane, which can over-report depth on side faces
114
- * when the convex shape extends past a face's 2D extent. The
115
- * deepest-wins aggregation then picks a "false-deepest" face whose
116
- * direction may not be the geometrically optimal one. A closed-form
117
- * triangle-vs-X solver per primitive shape would fix this; until
118
- * then, the function reports *some* outward direction with positive
119
- * depth, which still resolves penetration over multiple iterations.
115
+ * Concave-vs-concave throws the M×N triangle-pair cost is out of scope (and
116
+ * is also refused by the narrowphase for dynamic pairs).
120
117
  *
121
- * Bodies fully inside the concave solid (or below a heightmap
122
- * surface) are correctly recovered: every face's deepest-inward
123
- * support point lands on the inward side, so the half-space test
124
- * fires, and the deepest face wins. The reported direction pushes
125
- * the body outward through that face.
126
- *
127
- * @param {Float64Array|number[]} out_direction length ≥ 3; receives
128
- * the unit separation direction (B → A) on penetration
118
+ * @param {Float64Array|number[]} out_direction length 3; receives the unit
119
+ * separation direction (B A) on penetration
129
120
  * @param {AbstractShape3D} shape_a in shape_a's local frame; may be concave
130
121
  * @param {{x:number,y:number,z:number}} position_a world position of A
131
122
  * @param {{x:number,y:number,z:number,w:number}} rotation_a world rotation of A
@@ -147,121 +138,59 @@ export function compute_penetration(
147
138
  throw new Error("compute_penetration: at most one shape may be non-convex (concave-vs-concave triangle-pair cost is out of scope)");
148
139
  }
149
140
 
150
- if (isConcaveA || isConcaveB) {
151
- return compute_penetration_concave(
152
- out_direction, isConcaveA,
153
- shape_a, position_a, rotation_a,
154
- shape_b, position_b, rotation_b
155
- );
156
- }
157
-
158
- return compute_penetration_convex(
159
- out_direction,
141
+ // ── Primary: the exact narrowphase dispatch, deepest contact = MTV. ──
142
+ const depth = deepest_pair_penetration(
143
+ primary_normal,
160
144
  shape_a, position_a, rotation_a,
161
145
  shape_b, position_b, rotation_b
162
146
  );
163
- }
164
147
 
165
- /**
166
- * Convex-vs-convex implementation — single MPR call.
167
- *
168
- * MPR (Minkowski Portal Refinement) gives an overlap-or-not answer
169
- * plus the MTV in one pass, with reliable convergence on smooth
170
- * supports where the previous GJK + EPA path struggled (sphere-vs-
171
- * sphere shallow overlap, sphere-vs-box near-tangent — the closest-
172
- * face direction would noise out by 20-30° before EPA hit its
173
- * iteration cap). MPR converges in 5-15 iterations on those same
174
- * configurations.
175
- * @private
176
- */
177
- function compute_penetration_convex(
178
- out_direction,
179
- shape_a, position_a, rotation_a,
180
- shape_b, position_b, rotation_b
181
- ) {
182
- posed_a.setup(shape_a, position_a, rotation_a);
183
- posed_b.setup(shape_b, position_b, rotation_b);
184
-
185
- if (!mpr(mpr_result, 0, posed_a, posed_b)) return 0;
186
-
187
- let ex = mpr_result[0], ey = mpr_result[1], ez = mpr_result[2];
188
- const depth = Math.sqrt(ex * ex + ey * ey + ez * ez);
189
- if (!(depth > CONTACT_EPSILON) || !Number.isFinite(depth)) return 0;
190
-
191
- // MTV direction sanity check: should point from A's centre toward
192
- // B's centre. Same trick as in `narrowphase_step` — even MPR can
193
- // settle on either side of the origin for symmetric configurations
194
- // (axis-aligned cubes), so dot against the body-centre axis and
195
- // flip if needed.
196
- const ab_x = position_b.x - position_a.x;
197
- const ab_y = position_b.y - position_a.y;
198
- const ab_z = position_b.z - position_a.z;
199
- if (ex * ab_x + ey * ab_y + ez * ab_z < 0) {
200
- ex = -ex; ey = -ey; ez = -ez;
148
+ if (depth > CONTACT_EPSILON && Number.isFinite(depth)) {
149
+ out_direction[0] = primary_normal[0];
150
+ out_direction[1] = primary_normal[1];
151
+ out_direction[2] = primary_normal[2];
152
+ return depth;
201
153
  }
202
154
 
203
- // MTV now points A B. out_direction is B A.
204
- const inv = 1 / depth;
205
- out_direction[0] = -ex * inv;
206
- out_direction[1] = -ey * inv;
207
- out_direction[2] = -ez * inv;
155
+ // ── Recovery: a convex shape fully crossed to the inner side of a concave
156
+ // surface produces no from-outside contact above. Push it back out. ──
157
+ if (isConcaveA !== isConcaveB) {
158
+ return concave_recovery_penetration(
159
+ out_direction, isConcaveA,
160
+ shape_a, position_a, rotation_a,
161
+ shape_b, position_b, rotation_b
162
+ );
163
+ }
208
164
 
209
- return depth;
165
+ return 0;
210
166
  }
211
167
 
212
168
  /**
213
- * Convex-vs-concave implementation decompose, per-triangle half-space
214
- * test, deepest-wins aggregation.
215
- *
216
- * Why the half-space test instead of per-triangle GJK + EPA:
217
- *
218
- * GJK + EPA on `Triangle3D` (a flat 2D shape) has a known degeneracy.
219
- * `Triangle3D.support` along the triangle's face-normal axis returns
220
- * the SAME vertex regardless of sign (all three vertices have equal
221
- * projection along that axis). GJK can't converge from this degenerate
222
- * support and returns false positives — sphere clearly above a flat
223
- * surface gets reported as overlapping, with EPA producing a non-zero
224
- * depth in some arbitrary direction.
225
- *
226
- * The half-space test is closed-form and exact for this case:
227
- *
228
- * 1. Compute the triangle's outward face normal in world frame
229
- * (winding gives outward-CCW per the enumerator's contract).
230
- * 2. Query `convex.support(-face_normal)` — the deepest point of the
231
- * convex shape along the face-normal axis in the inward direction.
232
- * 3. Project that point onto the face normal relative to the
233
- * triangle's centroid plane.
234
- * 4. If the projection is positive, the convex shape is entirely on
235
- * the outward side — no penetration. Skip.
236
- * 5. Otherwise the magnitude is the penetration depth, and the
237
- * face normal IS the contact direction.
238
- *
239
- * For continuous concave surfaces (heightmaps with adjacent triangles
240
- * sharing edges) the half-space test gives exact results: any penetrating
241
- * convex either crosses a triangle's plane within the triangle's 2D
242
- * extent (correct depth reported) or hits an adjacent triangle.
169
+ * Recovery fallback for a convex shape that has tunnelled to the inner side of
170
+ * a concave surface (heightmap / mesh), where the one-sided per-triangle
171
+ * solvers report no contact.
243
172
  *
244
- * For closed meshes (each face is a separate triangle, edges are shared
245
- * between facets at angles), the half-space test can over-report when a
246
- * convex is outside the triangle's 2D extent but still on the inward
247
- * side of its infinite plane. Deepest-wins aggregation makes this
248
- * harmless in practice the dominant face's depth wins, which is the
249
- * geometrically meaningful answer for the kinematic-resolution use case.
173
+ * Per-triangle half-space test, deepest-wins:
174
+ * 1. Face normal of the triangle in world frame (outward by winding).
175
+ * 2. `convex.support(-face_normal)` the convex's deepest point along the
176
+ * inward face axis.
177
+ * 3. Signed distance of that point to the triangle's plane. If positive the
178
+ * convex is fully outside this face skip. If negative, its magnitude is
179
+ * the depth and the face normal is the contact axis.
250
180
  *
251
- * One-sided rejection is built into the test itself: if the convex's
252
- * deepest inward point is still on the outward side, depth ≤ 0 → skip.
253
- * Bodies in fully-invalid states (inside a closed mesh, below a
254
- * heightmap surface) get rejected naturally and return 0.
181
+ * Exact for heightmaps (adjacent triangles cover the boundary); for closed
182
+ * meshes the infinite-plane extrapolation can over-report on side faces, but
183
+ * deepest-wins gives a valid outward push that resolves over iterations — and
184
+ * this path only runs once a body is already inside the solid, where any
185
+ * outward direction is progress.
255
186
  *
256
187
  * @private
257
188
  */
258
- function compute_penetration_concave(
189
+ function concave_recovery_penetration(
259
190
  out_direction, isConcaveA,
260
191
  shape_a, position_a, rotation_a,
261
192
  shape_b, position_b, rotation_b
262
193
  ) {
263
- // Internally normalise: "concave" side is what we decompose;
264
- // "convex" side is wrapped in PosedShape and queried via support.
265
194
  const concave_shape = isConcaveA ? shape_a : shape_b;
266
195
  const concave_pos = isConcaveA ? position_a : position_b;
267
196
  const concave_rot = isConcaveA ? rotation_a : rotation_b;
@@ -376,8 +305,6 @@ function compute_penetration_concave(
376
305
  if (best_depth === 0) return 0;
377
306
 
378
307
  // ── 6. Write out_direction in the user's "B → A" convention ────
379
- //
380
- // The face normal points OUTWARD from the concave's solid.
381
308
  // - isConcaveA: original A = concave. "B → A" = convex → concave
382
309
  // = INTO the solid = −face_normal.
383
310
  // - isConcaveB: original A = convex. "B → A" = concave → convex
@@ -1,3 +1,55 @@
1
+ /**
2
+ * Single-pair penetration query: the depth and world normal of the DEEPEST
3
+ * contact the narrowphase would generate for one posed shape pair.
4
+ *
5
+ * Routes through the exact same {@link dispatch_pair} the contact solver
6
+ * consumes — closed-form for every sphere / box / capsule pair (box-box via
7
+ * SAT, so the true minimum-translation axis is found rather than the
8
+ * centroid-seeded portal MPR would pick), triangle decomposition + closed-form
9
+ * per triangle for convex-vs-concave, and GJK + EPA (+ MPR) for any other
10
+ * convex pair. The deepest contact's depth is the minimum-translation distance
11
+ * and its normal is the MTV axis, so the result is correct for every shape pair
12
+ * the engine can build and agrees bit-for-bit with what the solver acts on.
13
+ *
14
+ * The normal follows the narrowphase's stored convention: a unit vector
15
+ * pointing from B toward A — the direction to translate A to separate it.
16
+ *
17
+ * Concave-vs-concave is not dispatched (the narrowphase skips it) and returns
18
+ * 0; callers needing to reject that case must check `is_convex` themselves.
19
+ *
20
+ * Not re-entrant: shares the module-level candidate / scratch buffers with
21
+ * {@link narrowphase_step}. Intended for main-thread queries run outside a
22
+ * step (depenetration, overlap depth, tooling) — never from inside one.
23
+ *
24
+ * @param {Float64Array|number[]} out_normal length ≥ 3; receives the unit B→A
25
+ * normal on penetration (untouched when the return value is 0)
26
+ * @param {AbstractShape3D} shapeA
27
+ * @param {{x:number,y:number,z:number}} posA
28
+ * @param {{x:number,y:number,z:number,w:number}} rotA
29
+ * @param {AbstractShape3D} shapeB
30
+ * @param {{x:number,y:number,z:number}} posB
31
+ * @param {{x:number,y:number,z:number,w:number}} rotB
32
+ * @returns {number} deepest penetration depth (> 0) or 0 if separated
33
+ */
34
+ export function deepest_pair_penetration(out_normal: Float64Array | number[], shapeA: AbstractShape3D, posA: {
35
+ x: number;
36
+ y: number;
37
+ z: number;
38
+ }, rotA: {
39
+ x: number;
40
+ y: number;
41
+ z: number;
42
+ w: number;
43
+ }, shapeB: AbstractShape3D, posB: {
44
+ x: number;
45
+ y: number;
46
+ z: number;
47
+ }, rotB: {
48
+ x: number;
49
+ y: number;
50
+ z: number;
51
+ w: number;
52
+ }): number;
1
53
  /**
2
54
  * For every pair in `pair_list`, do a cross-product over A's collider list ×
3
55
  * B's collider list, accumulate candidate contacts, reduce to ≤4, and write
@@ -1 +1 @@
1
- {"version":3,"file":"narrowphase_step.d.ts","sourceRoot":"","sources":["../../../../../src/engine/physics/narrowphase/narrowphase_step.js"],"names":[],"mappings":"AAovCA;;;;;;;;;;;GAWG;AACH,uFALW,MAAM,MAAM;IAAC,QAAQ,WAAW;IAAC,SAAS,YAAW;CAAC,CAAC,CAAC,QAyJlE;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,uEAJW,MAAM,UACN,MAAM;IAAC,QAAQ,WAAW;IAAC,SAAS,YAAW;CAAC,CAAC,UACjD,MAAM;IAAC,QAAQ,WAAW;IAAC,SAAS,YAAW;CAAC,CAAC,QAiD3D"}
1
+ {"version":3,"file":"narrowphase_step.d.ts","sourceRoot":"","sources":["../../../../../src/engine/physics/narrowphase/narrowphase_step.js"],"names":[],"mappings":"AAwyCA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,qDAVW,YAAY,GAAC,MAAM,EAAE,iCAGrB;IAAC,CAAC,EAAC,MAAM,CAAC;IAAA,CAAC,EAAC,MAAM,CAAC;IAAA,CAAC,EAAC,MAAM,CAAA;CAAC,QAC5B;IAAC,CAAC,EAAC,MAAM,CAAC;IAAA,CAAC,EAAC,MAAM,CAAC;IAAA,CAAC,EAAC,MAAM,CAAC;IAAA,CAAC,EAAC,MAAM,CAAA;CAAC,iCAErC;IAAC,CAAC,EAAC,MAAM,CAAC;IAAA,CAAC,EAAC,MAAM,CAAC;IAAA,CAAC,EAAC,MAAM,CAAA;CAAC,QAC5B;IAAC,CAAC,EAAC,MAAM,CAAC;IAAA,CAAC,EAAC,MAAM,CAAC;IAAA,CAAC,EAAC,MAAM,CAAC;IAAA,CAAC,EAAC,MAAM,CAAA;CAAC,GACnC,MAAM,CAyClB;AAED;;;;;;;;;;;GAWG;AACH,uFALW,MAAM,MAAM;IAAC,QAAQ,WAAW;IAAC,SAAS,YAAW;CAAC,CAAC,CAAC,QA0JlE;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,uEAJW,MAAM,UACN,MAAM;IAAC,QAAQ,WAAW;IAAC,SAAS,YAAW;CAAC,CAAC,UACjD,MAAM;IAAC,QAAQ,WAAW;IAAC,SAAS,YAAW;CAAC,CAAC,QAiD3D"}
@@ -1,6 +1,7 @@
1
1
  import { aabb3_transform_oriented } from "../../../core/geom/3d/aabb/aabb3_transform_oriented.js";
2
2
  import { Triangle3D } from "../../../core/geom/3d/shape/Triangle3D.js";
3
3
  import { body_id_index } from "../body/BodyStorage.js";
4
+ import { combine_friction, combine_restitution } from "../contact/combine_material.js";
4
5
  import { CONTACT_STRIDE, MAX_CONTACTS_PER_MANIFOLD } from "../contact/ManifoldStore.js";
5
6
  import { expanding_polytope_algorithm } from "../gjk/expanding_polytope_algorithm.js";
6
7
  import { gjk_with_axis } from "../gjk/gjk.js";
@@ -52,7 +53,7 @@ const capsule_box_multi_result = new Float64Array(CAPSULE_BOX_MAX_CONTACTS * CAP
52
53
 
53
54
  /**
54
55
  * Candidate-contact stride: wax, way, waz, wbx, wby, wbz, nx, ny, nz, depth,
55
- * feature_id.
56
+ * feature_id, friction, restitution.
56
57
  *
57
58
  * The `feature_id` (offset 10) is a stable cross-frame identifier of the
58
59
  * geometric feature pair that produced this contact — used by the
@@ -61,9 +62,26 @@ const capsule_box_multi_result = new Float64Array(CAPSULE_BOX_MAX_CONTACTS * CAP
61
62
  * corresponds to the same physical contact. A value of 0 means
62
63
  * "no feature info, fall back to position matching".
63
64
  *
65
+ * `friction` (offset 11) and `restitution` (offset 12) are the COMBINED
66
+ * coefficients for the specific (colliderA, colliderB) pair that produced this
67
+ * contact, combined here (the only place that knows the exact source collider
68
+ * on each side) and carried into the manifold so a compound body's per-collider
69
+ * materials are honoured per-contact.
70
+ *
71
+ * @type {number}
72
+ */
73
+ const CANDIDATE_STRIDE = 13;
74
+
75
+ /**
76
+ * Combined friction / restitution for the collider pair currently being
77
+ * dispatched. Set once at the top of {@link dispatch_pair} (which is called
78
+ * per collider pair) and written into every contact that call appends, so
79
+ * each contact carries the material of its actual source colliders. Module
80
+ * scratch rather than threaded through every `append_contact` call site.
64
81
  * @type {number}
65
82
  */
66
- const CANDIDATE_STRIDE = 11;
83
+ let g_pair_friction = 0;
84
+ let g_pair_restitution = 0;
67
85
 
68
86
  /**
69
87
  * Maximum number of contacts emitted into the per-pair manifold after the
@@ -194,6 +212,8 @@ function append_contact(count, wax, way, waz, wbx, wby, wbz, nx, ny, nz, depth,
194
212
  candidates[off + 6] = nx; candidates[off + 7] = ny; candidates[off + 8] = nz;
195
213
  candidates[off + 9] = depth;
196
214
  candidates[off + 10] = feature_id;
215
+ candidates[off + 11] = g_pair_friction;
216
+ candidates[off + 12] = g_pair_restitution;
197
217
 
198
218
  return count + 1;
199
219
  }
@@ -358,6 +378,20 @@ function dispatch_pair(count, colA, trA, colB, trB, gjk_axis_buf = null, gjk_axi
358
378
  const shapeA = colA.shape;
359
379
  const shapeB = colB.shape;
360
380
 
381
+ // Per-contact materials: combine the two source colliders' coefficients
382
+ // once here (this is the only place that knows the exact collider on each
383
+ // side) and stamp them onto every contact this dispatch appends. The
384
+ // `deepest_pair_penetration` query passes bare `{shape}` adapters with no
385
+ // material fields — it never writes to a manifold, so 0 is fine there.
386
+ const fa = colA.friction, fb = colB.friction;
387
+ if (fa !== undefined && fb !== undefined) {
388
+ g_pair_friction = combine_friction(fa, fb);
389
+ g_pair_restitution = combine_restitution(colA.restitution, colB.restitution);
390
+ } else {
391
+ g_pair_friction = 0;
392
+ g_pair_restitution = 0;
393
+ }
394
+
361
395
  // isSphereShape3D covers both UnitSphereShape3D (fixed radius 1) and
362
396
  // SphereShape3D (arbitrary radius). Both expose `radius`.
363
397
  const isSphereA = shapeA.isSphereShape3D === true;
@@ -1266,6 +1300,98 @@ function dispatch_pair(count, colA, trA, colB, trB, gjk_axis_buf = null, gjk_axi
1266
1300
  );
1267
1301
  }
1268
1302
 
1303
+ // Reusable single-pair adapters for the penetration query below — no per-call
1304
+ // allocation. dispatch_pair only reads `.shape` off a collider and
1305
+ // `.position` / `.rotation` off a transform, so these minimal stand-ins are
1306
+ // all it needs.
1307
+ const _pp_colA = { shape: null };
1308
+ const _pp_colB = { shape: null };
1309
+ const _pp_trA = { position: null, rotation: null };
1310
+ const _pp_trB = { position: null, rotation: null };
1311
+
1312
+ /**
1313
+ * Cold-start GJK seed for the one-shot penetration query. Re-zeroed before each
1314
+ * call so every query is independent (gjk_with_axis treats a zero vector as a
1315
+ * cold start) — no warm-start leakage between unrelated queries, which keeps
1316
+ * the result a pure function of its inputs.
1317
+ * @type {Float64Array}
1318
+ */
1319
+ const _pp_axis = new Float64Array(3);
1320
+
1321
+ /**
1322
+ * Single-pair penetration query: the depth and world normal of the DEEPEST
1323
+ * contact the narrowphase would generate for one posed shape pair.
1324
+ *
1325
+ * Routes through the exact same {@link dispatch_pair} the contact solver
1326
+ * consumes — closed-form for every sphere / box / capsule pair (box-box via
1327
+ * SAT, so the true minimum-translation axis is found rather than the
1328
+ * centroid-seeded portal MPR would pick), triangle decomposition + closed-form
1329
+ * per triangle for convex-vs-concave, and GJK + EPA (+ MPR) for any other
1330
+ * convex pair. The deepest contact's depth is the minimum-translation distance
1331
+ * and its normal is the MTV axis, so the result is correct for every shape pair
1332
+ * the engine can build and agrees bit-for-bit with what the solver acts on.
1333
+ *
1334
+ * The normal follows the narrowphase's stored convention: a unit vector
1335
+ * pointing from B toward A — the direction to translate A to separate it.
1336
+ *
1337
+ * Concave-vs-concave is not dispatched (the narrowphase skips it) and returns
1338
+ * 0; callers needing to reject that case must check `is_convex` themselves.
1339
+ *
1340
+ * Not re-entrant: shares the module-level candidate / scratch buffers with
1341
+ * {@link narrowphase_step}. Intended for main-thread queries run outside a
1342
+ * step (depenetration, overlap depth, tooling) — never from inside one.
1343
+ *
1344
+ * @param {Float64Array|number[]} out_normal length ≥ 3; receives the unit B→A
1345
+ * normal on penetration (untouched when the return value is 0)
1346
+ * @param {AbstractShape3D} shapeA
1347
+ * @param {{x:number,y:number,z:number}} posA
1348
+ * @param {{x:number,y:number,z:number,w:number}} rotA
1349
+ * @param {AbstractShape3D} shapeB
1350
+ * @param {{x:number,y:number,z:number}} posB
1351
+ * @param {{x:number,y:number,z:number,w:number}} rotB
1352
+ * @returns {number} deepest penetration depth (> 0) or 0 if separated
1353
+ */
1354
+ export function deepest_pair_penetration(out_normal, shapeA, posA, rotA, shapeB, posB, rotB) {
1355
+ _pp_colA.shape = shapeA;
1356
+ _pp_trA.position = posA;
1357
+ _pp_trA.rotation = rotA;
1358
+ _pp_colB.shape = shapeB;
1359
+ _pp_trB.position = posB;
1360
+ _pp_trB.rotation = rotB;
1361
+
1362
+ // Cold GJK seed — one-shot query, not a warm-started per-frame manifold.
1363
+ _pp_axis[0] = 0; _pp_axis[1] = 0; _pp_axis[2] = 0;
1364
+ const n = dispatch_pair(0, _pp_colA, _pp_trA, _pp_colB, _pp_trB, _pp_axis, 0);
1365
+ if (n === 0) {
1366
+ return 0;
1367
+ }
1368
+
1369
+ // Deepest contact = the minimum-translation depth; its stored normal is the
1370
+ // separation axis. (For multi-point manifolds — box-box, capsule-box, a
1371
+ // convex straddling several mesh triangles — every point shares the
1372
+ // separating axis, so the max depth along it is the distance to separate.)
1373
+ let best_depth = -1;
1374
+ let best_off = 0;
1375
+ for (let i = 0; i < n; i++) {
1376
+ const off = i * CANDIDATE_STRIDE;
1377
+ const d = candidates[off + 9];
1378
+ if (d > best_depth) {
1379
+ best_depth = d;
1380
+ best_off = off;
1381
+ }
1382
+ }
1383
+
1384
+ if (!(best_depth > 0) || !Number.isFinite(best_depth)) {
1385
+ return 0;
1386
+ }
1387
+
1388
+ out_normal[0] = candidates[best_off + 6];
1389
+ out_normal[1] = candidates[best_off + 7];
1390
+ out_normal[2] = candidates[best_off + 8];
1391
+
1392
+ return best_depth;
1393
+ }
1394
+
1269
1395
  /**
1270
1396
  * For every pair in `pair_list`, do a cross-product over A's collider list ×
1271
1397
  * B's collider list, accumulate candidate contacts, reduce to ≤4, and write
@@ -1408,7 +1534,8 @@ export function narrowphase_step(pair_list, manifolds, lists) {
1408
1534
  candidates[off + 3], candidates[off + 4], candidates[off + 5],
1409
1535
  candidates[off + 6], candidates[off + 7], candidates[off + 8],
1410
1536
  candidates[off + 9],
1411
- candidates[off + 10]
1537
+ candidates[off + 10],
1538
+ candidates[off + 11], candidates[off + 12]
1412
1539
  );
1413
1540
  const prev_j = cand_to_prev[k];
1414
1541
  if (prev_j !== -1) {
@@ -1 +1 @@
1
- {"version":3,"file":"solve_contacts.d.ts","sourceRoot":"","sources":["../../../../../src/engine/physics/solver/solve_contacts.js"],"names":[],"mappings":"AAgcA;;;;;;;;;;;;;GAaG;AACH,0FAHW,MAAM,GACJ,MAAM,CA4JlB;AAED;;;;;;;;;;;;;;;GAeG;AACH,2FAuCC;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wFAgEC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,iGAmFC;AAED;;;;;;;;GAQG;AACH,uFAFW,MAAM,QA2GhB;AAED;;;;;;;;;;;GAWG;AACH,yFA4DC;AAED;;;;;;;;;;;;;;GAcG;AACH,2FAFW,MAAM,QA4EhB;AAED;;;;;;;;;;;;;;;GAeG;AACH,oFAJW,MAAM,UACN,MAAM,cACN,MAAM,QAahB;AAxlCD;;;;;GAKG;AACH,0CAFU,MAAM,CAEuB;AAEvC;;;GAGG;AACH,0CAFU,MAAM,CAEsB"}
1
+ {"version":3,"file":"solve_contacts.d.ts","sourceRoot":"","sources":["../../../../../src/engine/physics/solver/solve_contacts.js"],"names":[],"mappings":"AAkbA;;;;;;;;;;;;;GAaG;AACH,0FAHW,MAAM,GACJ,MAAM,CA+JlB;AAED;;;;;;;;;;;;;;;GAeG;AACH,2FAuCC;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wFAgEC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,iGAmFC;AAED;;;;;;;;GAQG;AACH,uFAFW,MAAM,QA2GhB;AAED;;;;;;;;;;;GAWG;AACH,yFA4DC;AAED;;;;;;;;;;;;;;GAcG;AACH,2FAFW,MAAM,QA4EhB;AAED;;;;;;;;;;;;;;;GAeG;AACH,oFAJW,MAAM,UACN,MAAM,cACN,MAAM,QAahB;AA7kCD;;;;;GAKG;AACH,0CAFU,MAAM,CAEuB;AAEvC;;;GAGG;AACH,0CAFU,MAAM,CAEsB"}