@woosh/meep-engine 2.145.0 → 2.147.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 (99) hide show
  1. package/package.json +1 -1
  2. package/src/core/geom/3d/shape/HeightMapShape3D.d.ts +33 -3
  3. package/src/core/geom/3d/shape/HeightMapShape3D.d.ts.map +1 -1
  4. package/src/core/geom/3d/shape/HeightMapShape3D.js +486 -451
  5. package/src/engine/control/first-person/DESIGN_COLLISION.md +365 -352
  6. package/src/engine/control/first-person/FirstPersonPlayerController.d.ts +1 -14
  7. package/src/engine/control/first-person/FirstPersonPlayerController.d.ts.map +1 -1
  8. package/src/engine/control/first-person/FirstPersonPlayerControllerConfig.d.ts +20 -8
  9. package/src/engine/control/first-person/FirstPersonPlayerControllerConfig.d.ts.map +1 -1
  10. package/src/engine/control/first-person/FirstPersonPlayerControllerConfig.js +552 -546
  11. package/src/engine/control/first-person/TODO.md +13 -11
  12. package/src/engine/control/first-person/abilities/LedgeGrab.d.ts +8 -3
  13. package/src/engine/control/first-person/abilities/LedgeGrab.d.ts.map +1 -1
  14. package/src/engine/control/first-person/abilities/LedgeGrab.js +213 -199
  15. package/src/engine/control/first-person/abilities/Mantle.d.ts.map +1 -1
  16. package/src/engine/control/first-person/abilities/Mantle.js +195 -188
  17. package/src/engine/control/first-person/abilities/WallJump.d.ts.map +1 -1
  18. package/src/engine/control/first-person/abilities/WallJump.js +11 -3
  19. package/src/engine/control/first-person/abilities/WallRun.d.ts.map +1 -1
  20. package/src/engine/control/first-person/abilities/WallRun.js +183 -163
  21. package/src/engine/control/first-person/collision/KinematicMover.d.ts.map +1 -1
  22. package/src/engine/control/first-person/collision/KinematicMover.js +634 -592
  23. package/src/engine/control/first-person/prototype_first_person_controller.js +1003 -901
  24. package/src/engine/control/first-person/sensors/FirstPersonSensors.d.ts +9 -0
  25. package/src/engine/control/first-person/sensors/FirstPersonSensors.d.ts.map +1 -1
  26. package/src/engine/control/first-person/sensors/FirstPersonSensors.js +87 -77
  27. package/src/engine/control/first-person/sensors/FirstPersonSensorsSystem.d.ts +8 -0
  28. package/src/engine/control/first-person/sensors/FirstPersonSensorsSystem.d.ts.map +1 -1
  29. package/src/engine/control/first-person/sensors/FirstPersonSensorsSystem.js +229 -196
  30. package/src/engine/ecs/EntityManager.d.ts +34 -11
  31. package/src/engine/ecs/EntityManager.d.ts.map +1 -1
  32. package/src/engine/ecs/EntityManager.js +71 -42
  33. package/src/engine/interpolation/BinaryInterpolationAdapter.d.ts.map +1 -0
  34. package/src/engine/interpolation/Interpoland.d.ts +48 -0
  35. package/src/engine/interpolation/Interpoland.d.ts.map +1 -0
  36. package/src/engine/interpolation/Interpoland.js +49 -0
  37. package/src/engine/interpolation/Interpolated.d.ts +101 -0
  38. package/src/engine/interpolation/Interpolated.d.ts.map +1 -0
  39. package/src/engine/interpolation/Interpolated.js +149 -0
  40. package/src/engine/{network/sim → interpolation}/InterpolationLog.d.ts +1 -1
  41. package/src/engine/interpolation/InterpolationLog.d.ts.map +1 -0
  42. package/src/engine/{network/sim → interpolation}/InterpolationLog.js +2 -2
  43. package/src/engine/interpolation/InterpolationSystem.d.ts +116 -0
  44. package/src/engine/interpolation/InterpolationSystem.d.ts.map +1 -0
  45. package/src/engine/interpolation/InterpolationSystem.js +233 -0
  46. package/src/engine/interpolation/PoseInterpolationAdapter.d.ts +17 -0
  47. package/src/engine/interpolation/PoseInterpolationAdapter.d.ts.map +1 -0
  48. package/src/engine/interpolation/PoseInterpolationAdapter.js +61 -0
  49. package/src/engine/interpolation/TransformPoseSerializationAdapter.d.ts +35 -0
  50. package/src/engine/interpolation/TransformPoseSerializationAdapter.d.ts.map +1 -0
  51. package/src/engine/interpolation/TransformPoseSerializationAdapter.js +57 -0
  52. package/src/engine/interpolation/pose_interpoland.d.ts +18 -0
  53. package/src/engine/interpolation/pose_interpoland.d.ts.map +1 -0
  54. package/src/engine/interpolation/pose_interpoland.js +27 -0
  55. package/src/engine/network/NetworkSession.d.ts +2 -2
  56. package/src/engine/network/NetworkSession.d.ts.map +1 -1
  57. package/src/engine/network/NetworkSession.js +2 -2
  58. package/src/engine/network/adapters/QuaternionInterpolationAdapter.d.ts +1 -1
  59. package/src/engine/network/adapters/QuaternionInterpolationAdapter.d.ts.map +1 -1
  60. package/src/engine/network/adapters/QuaternionInterpolationAdapter.js +1 -1
  61. package/src/engine/network/adapters/TransformInterpolationAdapter.d.ts +1 -1
  62. package/src/engine/network/adapters/TransformInterpolationAdapter.d.ts.map +1 -1
  63. package/src/engine/network/adapters/TransformInterpolationAdapter.js +1 -1
  64. package/src/engine/network/adapters/Vector3InterpolationAdapter.d.ts +1 -1
  65. package/src/engine/network/adapters/Vector3InterpolationAdapter.d.ts.map +1 -1
  66. package/src/engine/network/adapters/Vector3InterpolationAdapter.js +1 -1
  67. package/src/engine/physics/INTEPOLATION_SYSTEM_PLAN.md +287 -0
  68. package/src/engine/physics/PLAN.md +944 -809
  69. package/src/engine/physics/body/BodyStorage.d.ts +9 -0
  70. package/src/engine/physics/body/BodyStorage.d.ts.map +1 -1
  71. package/src/engine/physics/body/BodyStorage.js +23 -0
  72. package/src/engine/physics/broadphase/generate_pairs.d.ts.map +1 -1
  73. package/src/engine/physics/broadphase/generate_pairs.js +7 -0
  74. package/src/engine/physics/ccd/linear_sweep.d.ts +97 -0
  75. package/src/engine/physics/ccd/linear_sweep.d.ts.map +1 -0
  76. package/src/engine/physics/ccd/linear_sweep.js +238 -0
  77. package/src/engine/physics/ecs/PhysicsSystem.d.ts +82 -3
  78. package/src/engine/physics/ecs/PhysicsSystem.d.ts.map +1 -1
  79. package/src/engine/physics/ecs/PhysicsSystem.js +227 -8
  80. package/src/engine/physics/ecs/RigidBodyFlags.d.ts +6 -0
  81. package/src/engine/physics/ecs/RigidBodyFlags.d.ts.map +1 -1
  82. package/src/engine/physics/ecs/RigidBodyFlags.js +6 -0
  83. package/src/engine/physics/narrowphase/box_triangle_contact.js +814 -811
  84. package/src/engine/physics/narrowphase/compute_penetration.d.ts.map +1 -1
  85. package/src/engine/physics/narrowphase/compute_penetration.js +325 -323
  86. package/src/engine/physics/narrowphase/decomposition/heightmap_enumerate_triangles.d.ts +27 -8
  87. package/src/engine/physics/narrowphase/decomposition/heightmap_enumerate_triangles.d.ts.map +1 -1
  88. package/src/engine/physics/narrowphase/decomposition/heightmap_enumerate_triangles.js +235 -204
  89. package/src/engine/physics/narrowphase/narrowphase_step.d.ts.map +1 -1
  90. package/src/engine/physics/narrowphase/narrowphase_step.js +97 -13
  91. package/src/engine/physics/queries/overlap_shape.d.ts.map +1 -1
  92. package/src/engine/physics/queries/overlap_shape.js +185 -183
  93. package/src/engine/simulation/Ticker.d.ts +14 -0
  94. package/src/engine/simulation/Ticker.d.ts.map +1 -1
  95. package/src/engine/simulation/Ticker.js +136 -1
  96. package/src/engine/network/sim/BinaryInterpolationAdapter.d.ts.map +0 -1
  97. package/src/engine/network/sim/InterpolationLog.d.ts.map +0 -1
  98. /package/src/engine/{network/sim → interpolation}/BinaryInterpolationAdapter.d.ts +0 -0
  99. /package/src/engine/{network/sim → interpolation}/BinaryInterpolationAdapter.js +0 -0
@@ -1,323 +1,325 @@
1
- import { aabb3_transform_oriented } from "../../../core/geom/3d/aabb/aabb3_transform_oriented.js";
2
- import { v3_quat3_apply } from "../../../core/geom/vec3/v3_quat3_apply.js";
3
- import { aabb_world_to_local } from "./decomposition/aabb_world_to_local.js";
4
- import { decompose_to_triangles } from "./decomposition/decompose_to_triangles.js";
5
- import { TRIANGLE_FLOAT_STRIDE } from "./decomposition/triangle_buffer_layout.js";
6
- import { deepest_pair_penetration } from "./narrowphase_step.js";
7
- import { PosedShape } from "./PosedShape.js";
8
-
9
- /**
10
- * Module-scoped scratch — same single-thread re-entrancy assumption as
11
- * the other narrowphase utilities. Safe because PhysicsSystem queries
12
- * (and gameplay code calling this) run on the main thread.
13
- */
14
- const posed_b = new PosedShape();
15
-
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);
23
-
24
- /**
25
- * Scratch buffers for the convex-vs-concave recovery path (see
26
- * {@link concave_recovery_penetration}).
27
- */
28
- const local_aabb = new Float64Array(6);
29
- const world_aabb = new Float64Array(6);
30
- const concave_query_aabb = new Float64Array(6);
31
- const scratch_v3 = new Float64Array(3);
32
- const scratch_rot = new Float64Array(3);
33
-
34
- /**
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.
39
- * @type {number}
40
- */
41
- const MAX_TRIANGLES_PER_PAIR = 1024;
42
- const triangle_buffer = new Float64Array(MAX_TRIANGLES_PER_PAIR * TRIANGLE_FLOAT_STRIDE);
43
-
44
- /**
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.
50
- *
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.
53
- *
54
- * @type {number}
55
- */
56
- const CONTACT_EPSILON = 1e-4;
57
-
58
- /**
59
- * Compute the penetration depth between two shapes at world poses,
60
- * returning the depth and writing the separation direction.
61
- *
62
- * If the shapes overlap, the return value is the positive distance
63
- * along `out_direction` you would have to translate `shape_a` to
64
- * separate it from `shape_b`. `out_direction` is the unit vector
65
- * pointing from `shape_b` toward `shape_a` — applying
66
- * `out_direction * return_value` to `position_a` (or equivalently
67
- * `-out_direction * return_value` to `position_b`) is the minimum
68
- * translation that produces separation.
69
- *
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".
73
- *
74
- * Sign convention matches the narrowphase's stored contact normal:
75
- * `out_direction` ≡ the "B → A" direction (B's outward surface normal at the
76
- * contact, pointing toward A).
77
- *
78
- * ## How it is computed (hardened)
79
- *
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:
84
- *
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.
101
- *
102
- * ## Convex-vs-concave recovery (fully-tunnelled bodies)
103
- *
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.
114
- *
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).
117
- *
118
- * @param {Float64Array|number[]} out_direction length 3; receives the unit
119
- * separation direction (B → A) on penetration
120
- * @param {AbstractShape3D} shape_a in shape_a's local frame; may be concave
121
- * @param {{x:number,y:number,z:number}} position_a world position of A
122
- * @param {{x:number,y:number,z:number,w:number}} rotation_a world rotation of A
123
- * @param {AbstractShape3D} shape_b in shape_b's local frame; may be concave
124
- * @param {{x:number,y:number,z:number}} position_b world position of B
125
- * @param {{x:number,y:number,z:number,w:number}} rotation_b world rotation of B
126
- * @returns {number} penetration depth (positive) on overlap, 0 otherwise
127
- * @throws {Error} if both shapes have `is_convex === false`
128
- */
129
- export function compute_penetration(
130
- out_direction,
131
- shape_a, position_a, rotation_a,
132
- shape_b, position_b, rotation_b
133
- ) {
134
- const isConcaveA = shape_a.is_convex === false;
135
- const isConcaveB = shape_b.is_convex === false;
136
-
137
- if (isConcaveA && isConcaveB) {
138
- throw new Error("compute_penetration: at most one shape may be non-convex (concave-vs-concave triangle-pair cost is out of scope)");
139
- }
140
-
141
- // ── Primary: the exact narrowphase dispatch, deepest contact = MTV. ──
142
- const depth = deepest_pair_penetration(
143
- primary_normal,
144
- shape_a, position_a, rotation_a,
145
- shape_b, position_b, rotation_b
146
- );
147
-
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;
153
- }
154
-
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
- }
164
-
165
- return 0;
166
- }
167
-
168
- /**
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.
172
- *
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.
180
- *
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.
186
- *
187
- * @private
188
- */
189
- function concave_recovery_penetration(
190
- out_direction, isConcaveA,
191
- shape_a, position_a, rotation_a,
192
- shape_b, position_b, rotation_b
193
- ) {
194
- const concave_shape = isConcaveA ? shape_a : shape_b;
195
- const concave_pos = isConcaveA ? position_a : position_b;
196
- const concave_rot = isConcaveA ? rotation_a : rotation_b;
197
- const convex_shape = isConcaveA ? shape_b : shape_a;
198
- const convex_pos = isConcaveA ? position_b : position_a;
199
- const convex_rot = isConcaveA ? rotation_b : rotation_a;
200
-
201
- // ── 1. Convex shape's world AABB ───────────────────────────────
202
- convex_shape.compute_bounding_box(local_aabb);
203
- aabb3_transform_oriented(
204
- world_aabb, 0,
205
- local_aabb[0], local_aabb[1], local_aabb[2],
206
- local_aabb[3], local_aabb[4], local_aabb[5],
207
- convex_pos.x, convex_pos.y, convex_pos.z,
208
- convex_rot.x, convex_rot.y, convex_rot.z, convex_rot.w
209
- );
210
-
211
- // ── 2. Project into concave's body-local frame ─────────────────
212
- aabb_world_to_local(
213
- concave_query_aabb, 0,
214
- world_aabb,
215
- concave_pos.x, concave_pos.y, concave_pos.z,
216
- concave_rot.x, concave_rot.y, concave_rot.z, concave_rot.w
217
- );
218
-
219
- // ── 3. Decompose to triangles ──────────────────────────────────
220
- const tri_count = decompose_to_triangles(
221
- triangle_buffer, 0, concave_shape,
222
- concave_query_aabb[0], concave_query_aabb[1], concave_query_aabb[2],
223
- concave_query_aabb[3], concave_query_aabb[4], concave_query_aabb[5]
224
- );
225
- if (tri_count === 0) return 0;
226
-
227
- // ── 4. Setup convex PosedShape (we'll call support on it per tri) ─
228
- posed_b.setup(convex_shape, convex_pos, convex_rot);
229
-
230
- const cqx = concave_rot.x;
231
- const cqy = concave_rot.y;
232
- const cqz = concave_rot.z;
233
- const cqw = concave_rot.w;
234
- const c_pos_x = concave_pos.x;
235
- const c_pos_y = concave_pos.y;
236
- const c_pos_z = concave_pos.z;
237
-
238
- // ── 5. Per-triangle half-space test, deepest-wins ──────────────
239
- let best_depth = 0;
240
- let best_fnx_w = 0, best_fny_w = 0, best_fnz_w = 0;
241
-
242
- for (let i = 0; i < tri_count; i++) {
243
- const tri_offset = i * TRIANGLE_FLOAT_STRIDE;
244
-
245
- const ax = triangle_buffer[tri_offset ];
246
- const ay = triangle_buffer[tri_offset + 1];
247
- const az = triangle_buffer[tri_offset + 2];
248
- const bx = triangle_buffer[tri_offset + 3];
249
- const by = triangle_buffer[tri_offset + 4];
250
- const bz = triangle_buffer[tri_offset + 5];
251
- const cx_v = triangle_buffer[tri_offset + 6];
252
- const cy_v = triangle_buffer[tri_offset + 7];
253
- const cz_v = triangle_buffer[tri_offset + 8];
254
-
255
- // Face normal in body-local: (B − A) × (C − A), then normalise.
256
- const e1x_l = bx - ax, e1y_l = by - ay, e1z_l = bz - az;
257
- const e2x_l = cx_v - ax, e2y_l = cy_v - ay, e2z_l = cz_v - az;
258
- let fnx_l = e1y_l * e2z_l - e1z_l * e2y_l;
259
- let fny_l = e1z_l * e2x_l - e1x_l * e2z_l;
260
- let fnz_l = e1x_l * e2y_l - e1y_l * e2x_l;
261
- const fn_mag = Math.sqrt(fnx_l * fnx_l + fny_l * fny_l + fnz_l * fnz_l);
262
- if (fn_mag < 1e-12) continue; // degenerate triangle
263
- const fn_inv = 1 / fn_mag;
264
- fnx_l *= fn_inv; fny_l *= fn_inv; fnz_l *= fn_inv;
265
-
266
- // Rotate face normal to world via the concave body's quaternion.
267
- v3_quat3_apply(scratch_rot, 0, fnx_l, fny_l, fnz_l, cqx, cqy, cqz, cqw);
268
- const fnx_w = scratch_rot[0];
269
- const fny_w = scratch_rot[1];
270
- const fnz_w = scratch_rot[2];
271
-
272
- // Centroid in body-local → world (one point on the triangle's plane).
273
- const cent_lx = (ax + bx + cx_v) / 3;
274
- const cent_ly = (ay + by + cy_v) / 3;
275
- const cent_lz = (az + bz + cz_v) / 3;
276
- v3_quat3_apply(scratch_rot, 0, cent_lx, cent_ly, cent_lz, cqx, cqy, cqz, cqw);
277
- const cent_wx = scratch_rot[0] + c_pos_x;
278
- const cent_wy = scratch_rot[1] + c_pos_y;
279
- const cent_wz = scratch_rot[2] + c_pos_z;
280
-
281
- // Deepest inward point of convex along -face_normal.
282
- posed_b.support(scratch_v3, 0, -fnx_w, -fny_w, -fnz_w);
283
-
284
- // Signed distance from that point to the triangle plane along
285
- // the face normal: > 0 ⇒ convex is fully on outward side,
286
- // 0 convex extends |dist| into the solid through this face.
287
- const signed_dist =
288
- (scratch_v3[0] - cent_wx) * fnx_w
289
- + (scratch_v3[1] - cent_wy) * fny_w
290
- + (scratch_v3[2] - cent_wz) * fnz_w;
291
-
292
- if (signed_dist >= 0) continue;
293
-
294
- const depth = -signed_dist;
295
- if (depth < CONTACT_EPSILON) continue;
296
-
297
- if (depth > best_depth) {
298
- best_depth = depth;
299
- best_fnx_w = fnx_w;
300
- best_fny_w = fny_w;
301
- best_fnz_w = fnz_w;
302
- }
303
- }
304
-
305
- if (best_depth === 0) return 0;
306
-
307
- // ── 6. Write out_direction in the user's "B → A" convention ────
308
- // - isConcaveA: original A = concave. "B → A" = convex → concave
309
- // = INTO the solid = −face_normal.
310
- // - isConcaveB: original A = convex. "B → A" = concaveconvex
311
- // = AWAY from the solid = +face_normal.
312
- if (isConcaveA) {
313
- out_direction[0] = -best_fnx_w;
314
- out_direction[1] = -best_fny_w;
315
- out_direction[2] = -best_fnz_w;
316
- } else {
317
- out_direction[0] = best_fnx_w;
318
- out_direction[1] = best_fny_w;
319
- out_direction[2] = best_fnz_w;
320
- }
321
-
322
- return best_depth;
323
- }
1
+ import { aabb3_transform_oriented } from "../../../core/geom/3d/aabb/aabb3_transform_oriented.js";
2
+ import { v3_quat3_apply } from "../../../core/geom/vec3/v3_quat3_apply.js";
3
+ import { aabb_world_to_local } from "./decomposition/aabb_world_to_local.js";
4
+ import { decompose_to_triangles } from "./decomposition/decompose_to_triangles.js";
5
+ import { TRIANGLE_FLOAT_STRIDE } from "./decomposition/triangle_buffer_layout.js";
6
+ import { deepest_pair_penetration } from "./narrowphase_step.js";
7
+ import { PosedShape } from "./PosedShape.js";
8
+
9
+ /**
10
+ * Module-scoped scratch — same single-thread re-entrancy assumption as
11
+ * the other narrowphase utilities. Safe because PhysicsSystem queries
12
+ * (and gameplay code calling this) run on the main thread.
13
+ */
14
+ const posed_b = new PosedShape();
15
+
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);
23
+
24
+ /**
25
+ * Scratch buffers for the convex-vs-concave recovery path (see
26
+ * {@link concave_recovery_penetration}).
27
+ */
28
+ const local_aabb = new Float64Array(6);
29
+ const world_aabb = new Float64Array(6);
30
+ const concave_query_aabb = new Float64Array(6);
31
+ const scratch_v3 = new Float64Array(3);
32
+ const scratch_rot = new Float64Array(3);
33
+
34
+ /**
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 — though
38
+ * a heightmap's count scales O(N²) with its `tessellation` (still inside the
39
+ * buffer for a bounded query at moderate tessellation). Excess is dropped by
40
+ * the enumerator's bounds check.
41
+ * @type {number}
42
+ */
43
+ const MAX_TRIANGLES_PER_PAIR = 1024;
44
+ const triangle_buffer = new Float64Array(MAX_TRIANGLES_PER_PAIR * TRIANGLE_FLOAT_STRIDE);
45
+
46
+ /**
47
+ * Penetration depths below this are treated as no contact. The narrowphase
48
+ * dispatch can report sub-micron "overlap" at exact tangent (GJK/EPA noise,
49
+ * or a closed-form solver returning a hair of depth on a kissing contact); the
50
+ * "0 means no overlap" contract is more useful when that noise is filtered out
51
+ * at the source.
52
+ *
53
+ * 1e-4 m (100 µm) is well below any practical world-scale tolerance while
54
+ * still larger than typical convergence residuals at exact tangent.
55
+ *
56
+ * @type {number}
57
+ */
58
+ const CONTACT_EPSILON = 1e-4;
59
+
60
+ /**
61
+ * Compute the penetration depth between two shapes at world poses,
62
+ * returning the depth and writing the separation direction.
63
+ *
64
+ * If the shapes overlap, the return value is the positive distance
65
+ * along `out_direction` you would have to translate `shape_a` to
66
+ * separate it from `shape_b`. `out_direction` is the unit vector
67
+ * pointing from `shape_b` toward `shape_a` applying
68
+ * `out_direction * return_value` to `position_a` (or equivalently
69
+ * `-out_direction * return_value` to `position_b`) is the minimum
70
+ * translation that produces separation.
71
+ *
72
+ * If the shapes do not overlap, the return value is `0` and
73
+ * `out_direction` is left untouched. Callers should treat 0 as "no
74
+ * penetration".
75
+ *
76
+ * Sign convention matches the narrowphase's stored contact normal:
77
+ * `out_direction` ≡ the "B → A" direction (B's outward surface normal at the
78
+ * contact, pointing toward A).
79
+ *
80
+ * ## How it is computed (hardened)
81
+ *
82
+ * The query routes through the **same narrowphase contact dispatch the solver
83
+ * consumes** ({@link deepest_pair_penetration} `dispatch_pair`) and reports
84
+ * the deepest contact. That makes it correct — not "correct sometimes" — for
85
+ * every shape pair the engine can build:
86
+ *
87
+ * - **sphere / box / capsule pairs** exact closed-form solvers
88
+ * (`sphere_sphere`, `sphere_box`, `box_box` via SAT, `capsule_*`). Box-box
89
+ * in particular uses the true minimum-translation axis, so a small body
90
+ * resting on a large box reports the few-cm overlap through the near face
91
+ * rather than the metres-deep "exit through the far side" a centroid-seeded
92
+ * MPR portal used to return.
93
+ * - **general convex pairs** (anything without a closed form) GJK + EPA,
94
+ * which is exact for polytopes and is only ever reached by polytope-like
95
+ * shapes here, since every curved primitive (sphere, capsule) has a closed
96
+ * form above.
97
+ * - **convex vs concave** (one of heightmap / mesh) triangle decomposition
98
+ * over the convex AABB + the closed-form per-triangle solvers
99
+ * (`sphere_triangle`, `box_triangle`, `capsule_triangle`), deepest wins.
100
+ * These are bounded to each triangle's true 2-D extent, so the historical
101
+ * over-report on closed-mesh side faces (infinite-plane extrapolation) is
102
+ * gone.
103
+ *
104
+ * ## Convex-vs-concave recovery (fully-tunnelled bodies)
105
+ *
106
+ * The per-triangle closed-form solvers are intentionally one-sided: a convex
107
+ * shape that has crossed to the *inner* side of a surface produces no
108
+ * from-outside contact (the narrowphase won't shove a body deeper into the
109
+ * solid mid-step). For a standalone penetration / depenetration query that is
110
+ * the wrong answer the shape *is* overlapping the solid and must be pushed
111
+ * back out. When the primary dispatch finds no contact for a convex-vs-concave
112
+ * pair, this function falls back to a half-space test
113
+ * ({@link concave_recovery_penetration}) that reports the outward push-out
114
+ * vector. This is exact for heightmap terrain and a valid (if not strictly
115
+ * minimal) recovery direction for closed meshes.
116
+ *
117
+ * Concave-vs-concave throws — the M×N triangle-pair cost is out of scope (and
118
+ * is also refused by the narrowphase for dynamic pairs).
119
+ *
120
+ * @param {Float64Array|number[]} out_direction length 3; receives the unit
121
+ * separation direction (B A) on penetration
122
+ * @param {AbstractShape3D} shape_a in shape_a's local frame; may be concave
123
+ * @param {{x:number,y:number,z:number}} position_a world position of A
124
+ * @param {{x:number,y:number,z:number,w:number}} rotation_a world rotation of A
125
+ * @param {AbstractShape3D} shape_b in shape_b's local frame; may be concave
126
+ * @param {{x:number,y:number,z:number}} position_b world position of B
127
+ * @param {{x:number,y:number,z:number,w:number}} rotation_b world rotation of B
128
+ * @returns {number} penetration depth (positive) on overlap, 0 otherwise
129
+ * @throws {Error} if both shapes have `is_convex === false`
130
+ */
131
+ export function compute_penetration(
132
+ out_direction,
133
+ shape_a, position_a, rotation_a,
134
+ shape_b, position_b, rotation_b
135
+ ) {
136
+ const isConcaveA = shape_a.is_convex === false;
137
+ const isConcaveB = shape_b.is_convex === false;
138
+
139
+ if (isConcaveA && isConcaveB) {
140
+ throw new Error("compute_penetration: at most one shape may be non-convex (concave-vs-concave triangle-pair cost is out of scope)");
141
+ }
142
+
143
+ // ── Primary: the exact narrowphase dispatch, deepest contact = MTV. ──
144
+ const depth = deepest_pair_penetration(
145
+ primary_normal,
146
+ shape_a, position_a, rotation_a,
147
+ shape_b, position_b, rotation_b
148
+ );
149
+
150
+ if (depth > CONTACT_EPSILON && Number.isFinite(depth)) {
151
+ out_direction[0] = primary_normal[0];
152
+ out_direction[1] = primary_normal[1];
153
+ out_direction[2] = primary_normal[2];
154
+ return depth;
155
+ }
156
+
157
+ // ── Recovery: a convex shape fully crossed to the inner side of a concave
158
+ // surface produces no from-outside contact above. Push it back out. ──
159
+ if (isConcaveA !== isConcaveB) {
160
+ return concave_recovery_penetration(
161
+ out_direction, isConcaveA,
162
+ shape_a, position_a, rotation_a,
163
+ shape_b, position_b, rotation_b
164
+ );
165
+ }
166
+
167
+ return 0;
168
+ }
169
+
170
+ /**
171
+ * Recovery fallback for a convex shape that has tunnelled to the inner side of
172
+ * a concave surface (heightmap / mesh), where the one-sided per-triangle
173
+ * solvers report no contact.
174
+ *
175
+ * Per-triangle half-space test, deepest-wins:
176
+ * 1. Face normal of the triangle in world frame (outward by winding).
177
+ * 2. `convex.support(-face_normal)` the convex's deepest point along the
178
+ * inward face axis.
179
+ * 3. Signed distance of that point to the triangle's plane. If positive the
180
+ * convex is fully outside this face → skip. If negative, its magnitude is
181
+ * the depth and the face normal is the contact axis.
182
+ *
183
+ * Exact for heightmaps (adjacent triangles cover the boundary); for closed
184
+ * meshes the infinite-plane extrapolation can over-report on side faces, but
185
+ * deepest-wins gives a valid outward push that resolves over iterations — and
186
+ * this path only runs once a body is already inside the solid, where any
187
+ * outward direction is progress.
188
+ *
189
+ * @private
190
+ */
191
+ function concave_recovery_penetration(
192
+ out_direction, isConcaveA,
193
+ shape_a, position_a, rotation_a,
194
+ shape_b, position_b, rotation_b
195
+ ) {
196
+ const concave_shape = isConcaveA ? shape_a : shape_b;
197
+ const concave_pos = isConcaveA ? position_a : position_b;
198
+ const concave_rot = isConcaveA ? rotation_a : rotation_b;
199
+ const convex_shape = isConcaveA ? shape_b : shape_a;
200
+ const convex_pos = isConcaveA ? position_b : position_a;
201
+ const convex_rot = isConcaveA ? rotation_b : rotation_a;
202
+
203
+ // ── 1. Convex shape's world AABB ───────────────────────────────
204
+ convex_shape.compute_bounding_box(local_aabb);
205
+ aabb3_transform_oriented(
206
+ world_aabb, 0,
207
+ local_aabb[0], local_aabb[1], local_aabb[2],
208
+ local_aabb[3], local_aabb[4], local_aabb[5],
209
+ convex_pos.x, convex_pos.y, convex_pos.z,
210
+ convex_rot.x, convex_rot.y, convex_rot.z, convex_rot.w
211
+ );
212
+
213
+ // ── 2. Project into concave's body-local frame ─────────────────
214
+ aabb_world_to_local(
215
+ concave_query_aabb, 0,
216
+ world_aabb,
217
+ concave_pos.x, concave_pos.y, concave_pos.z,
218
+ concave_rot.x, concave_rot.y, concave_rot.z, concave_rot.w
219
+ );
220
+
221
+ // ── 3. Decompose to triangles ──────────────────────────────────
222
+ const tri_count = decompose_to_triangles(
223
+ triangle_buffer, 0, concave_shape,
224
+ concave_query_aabb[0], concave_query_aabb[1], concave_query_aabb[2],
225
+ concave_query_aabb[3], concave_query_aabb[4], concave_query_aabb[5]
226
+ );
227
+ if (tri_count === 0) return 0;
228
+
229
+ // ── 4. Setup convex PosedShape (we'll call support on it per tri) ─
230
+ posed_b.setup(convex_shape, convex_pos, convex_rot);
231
+
232
+ const cqx = concave_rot.x;
233
+ const cqy = concave_rot.y;
234
+ const cqz = concave_rot.z;
235
+ const cqw = concave_rot.w;
236
+ const c_pos_x = concave_pos.x;
237
+ const c_pos_y = concave_pos.y;
238
+ const c_pos_z = concave_pos.z;
239
+
240
+ // ── 5. Per-triangle half-space test, deepest-wins ──────────────
241
+ let best_depth = 0;
242
+ let best_fnx_w = 0, best_fny_w = 0, best_fnz_w = 0;
243
+
244
+ for (let i = 0; i < tri_count; i++) {
245
+ const tri_offset = i * TRIANGLE_FLOAT_STRIDE;
246
+
247
+ const ax = triangle_buffer[tri_offset ];
248
+ const ay = triangle_buffer[tri_offset + 1];
249
+ const az = triangle_buffer[tri_offset + 2];
250
+ const bx = triangle_buffer[tri_offset + 3];
251
+ const by = triangle_buffer[tri_offset + 4];
252
+ const bz = triangle_buffer[tri_offset + 5];
253
+ const cx_v = triangle_buffer[tri_offset + 6];
254
+ const cy_v = triangle_buffer[tri_offset + 7];
255
+ const cz_v = triangle_buffer[tri_offset + 8];
256
+
257
+ // Face normal in body-local: (B A) × (C − A), then normalise.
258
+ const e1x_l = bx - ax, e1y_l = by - ay, e1z_l = bz - az;
259
+ const e2x_l = cx_v - ax, e2y_l = cy_v - ay, e2z_l = cz_v - az;
260
+ let fnx_l = e1y_l * e2z_l - e1z_l * e2y_l;
261
+ let fny_l = e1z_l * e2x_l - e1x_l * e2z_l;
262
+ let fnz_l = e1x_l * e2y_l - e1y_l * e2x_l;
263
+ const fn_mag = Math.sqrt(fnx_l * fnx_l + fny_l * fny_l + fnz_l * fnz_l);
264
+ if (fn_mag < 1e-12) continue; // degenerate triangle
265
+ const fn_inv = 1 / fn_mag;
266
+ fnx_l *= fn_inv; fny_l *= fn_inv; fnz_l *= fn_inv;
267
+
268
+ // Rotate face normal to world via the concave body's quaternion.
269
+ v3_quat3_apply(scratch_rot, 0, fnx_l, fny_l, fnz_l, cqx, cqy, cqz, cqw);
270
+ const fnx_w = scratch_rot[0];
271
+ const fny_w = scratch_rot[1];
272
+ const fnz_w = scratch_rot[2];
273
+
274
+ // Centroid in body-local → world (one point on the triangle's plane).
275
+ const cent_lx = (ax + bx + cx_v) / 3;
276
+ const cent_ly = (ay + by + cy_v) / 3;
277
+ const cent_lz = (az + bz + cz_v) / 3;
278
+ v3_quat3_apply(scratch_rot, 0, cent_lx, cent_ly, cent_lz, cqx, cqy, cqz, cqw);
279
+ const cent_wx = scratch_rot[0] + c_pos_x;
280
+ const cent_wy = scratch_rot[1] + c_pos_y;
281
+ const cent_wz = scratch_rot[2] + c_pos_z;
282
+
283
+ // Deepest inward point of convex along -face_normal.
284
+ posed_b.support(scratch_v3, 0, -fnx_w, -fny_w, -fnz_w);
285
+
286
+ // Signed distance from that point to the triangle plane along
287
+ // the face normal: > 0 ⇒ convex is fully on outward side,
288
+ // ≤ 0 convex extends |dist| into the solid through this face.
289
+ const signed_dist =
290
+ (scratch_v3[0] - cent_wx) * fnx_w
291
+ + (scratch_v3[1] - cent_wy) * fny_w
292
+ + (scratch_v3[2] - cent_wz) * fnz_w;
293
+
294
+ if (signed_dist >= 0) continue;
295
+
296
+ const depth = -signed_dist;
297
+ if (depth < CONTACT_EPSILON) continue;
298
+
299
+ if (depth > best_depth) {
300
+ best_depth = depth;
301
+ best_fnx_w = fnx_w;
302
+ best_fny_w = fny_w;
303
+ best_fnz_w = fnz_w;
304
+ }
305
+ }
306
+
307
+ if (best_depth === 0) return 0;
308
+
309
+ // ── 6. Write out_direction in the user's "B → A" convention ────
310
+ // - isConcaveA: original A = concave. "B → A" = convexconcave
311
+ // = INTO the solid = face_normal.
312
+ // - isConcaveB: original A = convex. "B → A" = concave → convex
313
+ // = AWAY from the solid = +face_normal.
314
+ if (isConcaveA) {
315
+ out_direction[0] = -best_fnx_w;
316
+ out_direction[1] = -best_fny_w;
317
+ out_direction[2] = -best_fnz_w;
318
+ } else {
319
+ out_direction[0] = best_fnx_w;
320
+ out_direction[1] = best_fny_w;
321
+ out_direction[2] = best_fnz_w;
322
+ }
323
+
324
+ return best_depth;
325
+ }