@driftengine/physics 3.61.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 (136) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +9 -0
  3. package/README.md +503 -0
  4. package/dist/bodies.d.ts +121 -0
  5. package/dist/bodies.js +298 -0
  6. package/dist/buoyancy.d.ts +46 -0
  7. package/dist/buoyancy.js +45 -0
  8. package/dist/cloth.d.ts +226 -0
  9. package/dist/cloth.js +587 -0
  10. package/dist/collide/aabb.d.ts +10 -0
  11. package/dist/collide/aabb.js +11 -0
  12. package/dist/collide/body.d.ts +202 -0
  13. package/dist/collide/body.js +142 -0
  14. package/dist/collide/eject.d.ts +39 -0
  15. package/dist/collide/eject.js +142 -0
  16. package/dist/collide/index.d.ts +18 -0
  17. package/dist/collide/index.js +5 -0
  18. package/dist/collide/scratch.d.ts +8 -0
  19. package/dist/collide/scratch.js +9 -0
  20. package/dist/collide/segment.d.ts +12 -0
  21. package/dist/collide/segment.js +109 -0
  22. package/dist/collide/sweep.d.ts +3 -0
  23. package/dist/collide/sweep.js +1036 -0
  24. package/dist/colliderSet.d.ts +239 -0
  25. package/dist/colliderSet.js +546 -0
  26. package/dist/compoundContact.d.ts +70 -0
  27. package/dist/compoundContact.js +221 -0
  28. package/dist/controller.d.ts +351 -0
  29. package/dist/controller.js +641 -0
  30. package/dist/cylinder.d.ts +78 -0
  31. package/dist/cylinder.js +294 -0
  32. package/dist/cylinderContact.d.ts +7 -0
  33. package/dist/cylinderContact.js +704 -0
  34. package/dist/decompose.d.ts +105 -0
  35. package/dist/decompose.js +979 -0
  36. package/dist/events.d.ts +43 -0
  37. package/dist/events.js +95 -0
  38. package/dist/executor.d.ts +147 -0
  39. package/dist/executor.js +173 -0
  40. package/dist/faces.d.ts +26 -0
  41. package/dist/faces.js +34 -0
  42. package/dist/fingerprint.d.ts +61 -0
  43. package/dist/fingerprint.js +139 -0
  44. package/dist/groundMotor.d.ts +34 -0
  45. package/dist/groundMotor.js +41 -0
  46. package/dist/heightfieldShape.d.ts +128 -0
  47. package/dist/heightfieldShape.js +328 -0
  48. package/dist/index.d.ts +73 -0
  49. package/dist/index.js +47 -0
  50. package/dist/island.d.ts +55 -0
  51. package/dist/island.js +227 -0
  52. package/dist/islandSolve.d.ts +70 -0
  53. package/dist/islandSolve.js +171 -0
  54. package/dist/islandWorker.d.ts +18 -0
  55. package/dist/islandWorker.js +125 -0
  56. package/dist/joints.d.ts +180 -0
  57. package/dist/joints.js +1094 -0
  58. package/dist/manifold.d.ts +89 -0
  59. package/dist/manifold.js +608 -0
  60. package/dist/mass.d.ts +53 -0
  61. package/dist/mass.js +353 -0
  62. package/dist/meshContact.d.ts +57 -0
  63. package/dist/meshContact.js +393 -0
  64. package/dist/meshShape.d.ts +76 -0
  65. package/dist/meshShape.js +175 -0
  66. package/dist/pairs.d.ts +62 -0
  67. package/dist/pairs.js +154 -0
  68. package/dist/query.d.ts +71 -0
  69. package/dist/query.js +683 -0
  70. package/dist/ragdoll.d.ts +135 -0
  71. package/dist/ragdoll.js +489 -0
  72. package/dist/shape.d.ts +177 -0
  73. package/dist/shape.js +533 -0
  74. package/dist/solver.d.ts +136 -0
  75. package/dist/solver.js +601 -0
  76. package/dist/stage.d.ts +167 -0
  77. package/dist/stage.js +350 -0
  78. package/dist/stallEscape.d.ts +141 -0
  79. package/dist/stallEscape.js +254 -0
  80. package/dist/sweptStep.d.ts +173 -0
  81. package/dist/sweptStep.js +472 -0
  82. package/dist/tree.d.ts +61 -0
  83. package/dist/tree.js +348 -0
  84. package/dist/vehicle.d.ts +154 -0
  85. package/dist/vehicle.js +343 -0
  86. package/dist/workerPool.d.ts +107 -0
  87. package/dist/workerPool.js +239 -0
  88. package/dist/workers.d.ts +37 -0
  89. package/dist/workers.js +35 -0
  90. package/dist/world.d.ts +298 -0
  91. package/dist/world.js +712 -0
  92. package/package.json +55 -0
  93. package/src/bodies.ts +369 -0
  94. package/src/buoyancy.ts +78 -0
  95. package/src/cloth.ts +734 -0
  96. package/src/collide/aabb.ts +28 -0
  97. package/src/collide/body.ts +336 -0
  98. package/src/collide/eject.ts +156 -0
  99. package/src/collide/index.ts +26 -0
  100. package/src/collide/scratch.ts +10 -0
  101. package/src/collide/segment.ts +127 -0
  102. package/src/collide/sweep.ts +1132 -0
  103. package/src/colliderSet.ts +637 -0
  104. package/src/compoundContact.ts +284 -0
  105. package/src/controller.ts +817 -0
  106. package/src/cylinder.ts +340 -0
  107. package/src/cylinderContact.ts +825 -0
  108. package/src/decompose.ts +1198 -0
  109. package/src/events.ts +108 -0
  110. package/src/executor.ts +224 -0
  111. package/src/faces.ts +37 -0
  112. package/src/fingerprint.ts +146 -0
  113. package/src/groundMotor.ts +53 -0
  114. package/src/heightfieldShape.ts +395 -0
  115. package/src/index.ts +164 -0
  116. package/src/island.ts +219 -0
  117. package/src/islandSolve.ts +218 -0
  118. package/src/islandWorker.ts +141 -0
  119. package/src/joints.ts +1367 -0
  120. package/src/manifold.ts +798 -0
  121. package/src/mass.ts +424 -0
  122. package/src/meshContact.ts +443 -0
  123. package/src/meshShape.ts +261 -0
  124. package/src/pairs.ts +169 -0
  125. package/src/query.ts +844 -0
  126. package/src/ragdoll.ts +662 -0
  127. package/src/shape.ts +656 -0
  128. package/src/solver.ts +873 -0
  129. package/src/stage.ts +406 -0
  130. package/src/stallEscape.ts +362 -0
  131. package/src/sweptStep.ts +609 -0
  132. package/src/tree.ts +366 -0
  133. package/src/vehicle.ts +489 -0
  134. package/src/workerPool.ts +283 -0
  135. package/src/workers.ts +38 -0
  136. package/src/world.ts +961 -0
@@ -0,0 +1,1036 @@
1
+ import { ColliderSet } from '../colliderSet.js';
2
+ import { AXIS_X, AXIS_Y, AXIS_Z, bodyBounds, bodyFrame, createBodyBounds, createBodyFrame, } from './body.js';
3
+ import { hits } from './scratch.js';
4
+ /**
5
+ * The sweep: movement resolved one axis at a time against static obstacles.
6
+ *
7
+ * Exact, branch-cheap, allocation-free and deterministic, which is what lets a stored replay
8
+ * reproduce a run (AGENTS.md, "Determinism").
9
+ */
10
+ /** Gap preserved between body and obstacle so faces never interpenetrate. */
11
+ const SKIN = 0.001;
12
+ /** Shrink applied to cross-axis overlap tests to avoid phantom edge catches. */
13
+ const SHRINK = 0.0005;
14
+ /*
15
+ * The pair currently being swept, held at module scope so the per-axis work
16
+ * carries no arguments and allocates nothing. `sweepShapePair` owns this state;
17
+ * nothing else reads it.
18
+ */
19
+ let swBodyShape;
20
+ let swColShape;
21
+ let swBody;
22
+ let swFrame;
23
+ let swPx = 0;
24
+ let swPy = 0;
25
+ let swPz = 0;
26
+ let swMinX = 0;
27
+ let swMinY = 0;
28
+ let swMinZ = 0;
29
+ let swMaxX = 0;
30
+ let swMaxY = 0;
31
+ let swMaxZ = 0;
32
+ let swTx = 0;
33
+ let swTy = 0;
34
+ let swTz = 0;
35
+ /** Travel window: the pair touches only while every axis window is open at once. */
36
+ let winEnter = 0;
37
+ let winExit = 0;
38
+ /**
39
+ * Whether the axis that decided `winEnter` is a *support* contact: its plane
40
+ * horizontal enough to stand on, with the body arriving from above. The bound
41
+ * is deliberately below any floor/wall line a caller enforces (cos 70° = 0.34),
42
+ * so the engine never claims support on a face the game would call a wall.
43
+ */
44
+ let winSupport = true;
45
+ /**
46
+ * The outward direction of the axis that decided `winEnter` — from the obstacle
47
+ * toward the body. Companion to `winSupport`, which is a boolean about the same
48
+ * contact; see `Body.contactNormalX`.
49
+ */
50
+ let winNx = 0;
51
+ let winNy = 0;
52
+ let winNz = 0;
53
+ const SUPPORT_NY = 0.3;
54
+ /** Projection intervals written by the project helpers. */
55
+ let colLo = 0;
56
+ let colHi = 0;
57
+ let bodLo = 0;
58
+ let bodHi = 0;
59
+ /** Project the collider onto an axis: its shape's points, or its box analytically. */
60
+ function projectCollider(nx, ny, nz) {
61
+ const shape = swColShape;
62
+ if (shape === undefined) {
63
+ const centre = (swMinX + swMaxX) * 0.5 * nx + (swMinY + swMaxY) * 0.5 * ny + (swMinZ + swMaxZ) * 0.5 * nz;
64
+ const reach = Math.abs(nx) * (swMaxX - swMinX) * 0.5 +
65
+ Math.abs(ny) * (swMaxY - swMinY) * 0.5 +
66
+ Math.abs(nz) * (swMaxZ - swMinZ) * 0.5;
67
+ colLo = centre - reach;
68
+ colHi = centre + reach;
69
+ return;
70
+ }
71
+ const v = shape.vertices;
72
+ let lo = Infinity;
73
+ let hi = -Infinity;
74
+ for (let i = 0; i < v.length; i += 3) {
75
+ const d = (v[i] ?? 0) * nx + (v[i + 1] ?? 0) * ny + (v[i + 2] ?? 0) * nz;
76
+ if (d < lo)
77
+ lo = d;
78
+ if (d > hi)
79
+ hi = d;
80
+ }
81
+ const grow = shape.radius + discGrowth(shape, nx, ny, nz);
82
+ colLo = lo - grow;
83
+ colHi = hi + grow;
84
+ }
85
+ /**
86
+ * How far a cylinder's disc rounding reaches along an axis, given in the shape's own coordinates.
87
+ *
88
+ * `sideRadius` grows a shape perpendicular to the segment through its first two points, so along a
89
+ * direction `n` it reaches `sideRadius * sqrt(1 - (n · axis)^2)` — the full radius across the axis
90
+ * and nothing along it. Zero for every shape that is not a cylinder, which is the common case and
91
+ * costs one compare.
92
+ *
93
+ * **This is the exact half of the cylinder's support**, and the axis list this file folds is the
94
+ * approximate half: the candidate axes come from the two shapes' enumerated features, and a curved
95
+ * side has none. Each axis tested is answered exactly; the set of axes tested is a superset of a
96
+ * box's and still not the whole family a cylinder's side spans. A sweep that tests more axes can
97
+ * only stop a body earlier, so what that costs is a kinematic body stopping slightly short of a
98
+ * cylinder at a glancing angle, never passing through one. `collideShapes` is the exact path and
99
+ * is what the dynamics use.
100
+ */
101
+ function discGrowth(shape, nx, ny, nz) {
102
+ if (shape.sideRadius <= 0)
103
+ return 0;
104
+ const v = shape.vertices;
105
+ const ax = (v[3] ?? 0) - (v[0] ?? 0);
106
+ const ay = (v[4] ?? 0) - (v[1] ?? 0);
107
+ const az = (v[5] ?? 0) - (v[2] ?? 0);
108
+ const length = Math.sqrt(ax * ax + ay * ay + az * az);
109
+ if (length < 1e-9)
110
+ return shape.sideRadius;
111
+ const along = (ax * nx + ay * ny + az * nz) / length;
112
+ return shape.sideRadius * Math.sqrt(Math.max(0, 1 - along * along));
113
+ }
114
+ /** Project the body's shape (or its box) onto an axis, through its frame. */
115
+ function projectBody(nx, ny, nz) {
116
+ const f = swFrame;
117
+ const centre = swPx * nx + swPy * ny + swPz * nz;
118
+ const shape = swBodyShape;
119
+ if (shape === undefined) {
120
+ const reach = Math.abs(f.rx * nx + f.ry * ny + f.rz * nz) * swBody.hx +
121
+ Math.abs(f.ux * nx + f.uy * ny + f.uz * nz) * swBody.hy +
122
+ Math.abs(f.fx * nx + f.fy * ny + f.fz * nz) * swBody.hz;
123
+ bodLo = centre - reach;
124
+ bodHi = centre + reach;
125
+ return;
126
+ }
127
+ // The axis in the body's own coordinates, so the points stay untransformed.
128
+ const mx = f.rx * nx + f.ry * ny + f.rz * nz;
129
+ const my = f.ux * nx + f.uy * ny + f.uz * nz;
130
+ const mz = f.fx * nx + f.fy * ny + f.fz * nz;
131
+ const v = shape.vertices;
132
+ let lo = Infinity;
133
+ let hi = -Infinity;
134
+ for (let i = 0; i < v.length; i += 3) {
135
+ const d = (v[i] ?? 0) * mx + (v[i + 1] ?? 0) * my + (v[i + 2] ?? 0) * mz;
136
+ if (d < lo)
137
+ lo = d;
138
+ if (d > hi)
139
+ hi = d;
140
+ }
141
+ const grow = shape.radius + discGrowth(shape, mx, my, mz);
142
+ bodLo = centre + lo - grow;
143
+ bodHi = centre + hi + grow;
144
+ }
145
+ /**
146
+ * Fold one candidate separating axis into the travel window.
147
+ *
148
+ * Both shapes project to an interval, and travel slides the body's at a known
149
+ * rate, so overlap on this axis is a window of travel. Returns false when the
150
+ * axis proves the pair can never touch — separated and not closing, or the
151
+ * combined window has emptied.
152
+ */
153
+ function foldAxis(nx, ny, nz) {
154
+ const len = nx * nx + ny * ny + nz * nz;
155
+ // Parallel cross products degenerate to nothing; the face axes cover them.
156
+ if (len < 1e-12)
157
+ return true;
158
+ const inv = 1 / Math.sqrt(len);
159
+ nx *= inv;
160
+ ny *= inv;
161
+ nz *= inv;
162
+ projectCollider(nx, ny, nz);
163
+ projectBody(nx, ny, nz);
164
+ const rate = swTx * nx + swTy * ny + swTz * nz;
165
+ if (rate > -1e-9 && rate < 1e-9) {
166
+ /*
167
+ * Travel does not change this projection: either it always overlaps, or
168
+ * never. Shrunk by SKIN, so a body resting its SKIN gap away from a face —
169
+ * which is exactly where contact leaves it — reads as clear and slides
170
+ * along it instead of catching on every seam.
171
+ */
172
+ return bodHi > colLo + SKIN && bodLo < colHi - SKIN;
173
+ }
174
+ /*
175
+ * Grown by SKIN: contact stops a skin's width short of the face, the same
176
+ * gap the axis-aligned path has always kept. Stopping *at* the face left the
177
+ * body touching it, and a touching body read as "already overlapping" on the
178
+ * next tick — the walk-out rule then waved the same move through, and a
179
+ * character standing on a floor fell through it one tick after landing.
180
+ */
181
+ let s1 = (colLo - SKIN - bodHi) / rate;
182
+ let s2 = (colHi + SKIN - bodLo) / rate;
183
+ if (s1 > s2) {
184
+ const swap = s1;
185
+ s1 = s2;
186
+ s2 = swap;
187
+ }
188
+ if (s1 > winEnter) {
189
+ winEnter = s1;
190
+ /*
191
+ * Which side of this axis the body is on, at the moment of touch. Both
192
+ * projections slide by `rate · s1` on the body's side before comparing.
193
+ *
194
+ * This one test answers two questions. **Support** — the plane is walkably
195
+ * horizontal and the body arrived on top of it, rather than kissing a side, an
196
+ * edge or an underside. And **which way is out**, which is the same fact without
197
+ * the horizontality requirement: a body above the obstacle's interval escapes
198
+ * along `+n`, one below it along `−n`.
199
+ */
200
+ const above = bodLo + rate * s1 >= colHi - SKIN * 4;
201
+ const below = bodHi + rate * s1 <= colLo + SKIN * 4;
202
+ if (ny >= SUPPORT_NY)
203
+ winSupport = above;
204
+ else if (ny <= -SUPPORT_NY)
205
+ winSupport = below;
206
+ else
207
+ winSupport = false;
208
+ /*
209
+ * Neither above nor below means the body straddles the obstacle's interval on
210
+ * this axis, which is what "already interpenetrating" looks like from here. The
211
+ * reverse of the travel direction is then the honest answer: it is the one escape
212
+ * this sweep can actually vouch for.
213
+ */
214
+ if (above) {
215
+ winNx = nx;
216
+ winNy = ny;
217
+ winNz = nz;
218
+ }
219
+ else if (below) {
220
+ winNx = -nx;
221
+ winNy = -ny;
222
+ winNz = -nz;
223
+ }
224
+ else {
225
+ winNx = -swTx;
226
+ winNy = -swTy;
227
+ winNz = -swTz;
228
+ }
229
+ }
230
+ if (s2 < winExit)
231
+ winExit = s2;
232
+ // No travel satisfies every axis at once, so this obstacle is never touched.
233
+ return winEnter <= winExit;
234
+ }
235
+ /**
236
+ * How far a body — tilted, shaped, or both — may travel along one world axis
237
+ * before it touches an obstacle that may itself be a shape.
238
+ *
239
+ * This replaces growing bounds into a bigger axis-aligned box: a bigger box is
240
+ * solid where the real body is not, so it stops a character short of geometry
241
+ * they can see clear air beside. The volume that collides is the volume that
242
+ * is drawn, on both sides.
243
+ *
244
+ * Separating axes, generalised: each side contributes its face normals (world
245
+ * axes for a plain box, frame axes for a boxy body), and their edge directions
246
+ * cross-multiply for the edge-on-edge cases. A round side has no features and
247
+ * contributes its sweep direction and point-to-corner axes instead. The axis
248
+ * list is fixed per shape pair — enumerated from build-time data, never
249
+ * iterated to a tolerance — so two machines replay the same run bit for bit.
250
+ *
251
+ * Returns the largest travel in `[0, |delta|]` that stays clear, as a
252
+ * magnitude. A pair already overlapping when the move begins is let out rather
253
+ * than trapped, the same rule the axis-aligned path uses — a body put inside
254
+ * geometry by a spawn or a deck rising under it has to be able to walk out.
255
+ */
256
+ function sweepShapePair(bodyShape, body, f, px, py, pz, colShape, bMinX, bMinY, bMinZ, bMaxX, bMaxY, bMaxZ, axis, delta) {
257
+ const want = Math.abs(delta);
258
+ const dir = delta < 0 ? -1 : 1;
259
+ swBodyShape = bodyShape;
260
+ swColShape = colShape;
261
+ swBody = body;
262
+ swFrame = f;
263
+ swPx = px;
264
+ swPy = py;
265
+ swPz = pz;
266
+ swMinX = bMinX;
267
+ swMinY = bMinY;
268
+ swMinZ = bMinZ;
269
+ swMaxX = bMaxX;
270
+ swMaxY = bMaxY;
271
+ swMaxZ = bMaxZ;
272
+ swTx = axis === AXIS_X ? dir : 0;
273
+ swTy = axis === AXIS_Y ? dir : 0;
274
+ swTz = axis === AXIS_Z ? dir : 0;
275
+ // True entry time, allowed negative: the sign is what tells a body resting
276
+ // against a face (entry ≈ 0, hold it there) from one genuinely inside it
277
+ // (entry well past, let it out).
278
+ winEnter = -Infinity;
279
+ winExit = Infinity;
280
+ winSupport = true;
281
+ winNx = 0;
282
+ winNy = 0;
283
+ winNz = 0;
284
+ // The collider's faces — world axes where it is only its box.
285
+ if (colShape === undefined) {
286
+ if (!foldAxis(1, 0, 0) || !foldAxis(0, 1, 0) || !foldAxis(0, 0, 1))
287
+ return want;
288
+ }
289
+ else {
290
+ const fn = colShape.faceNormals;
291
+ for (let i = 0; i < fn.length; i += 3) {
292
+ if (!foldAxis(fn[i] ?? 0, fn[i + 1] ?? 0, fn[i + 2] ?? 0))
293
+ return want;
294
+ }
295
+ }
296
+ // The body's faces, rotated into the world — frame axes where it is only its box.
297
+ if (bodyShape === undefined) {
298
+ if (!foldAxis(f.rx, f.ry, f.rz) || !foldAxis(f.ux, f.uy, f.uz) || !foldAxis(f.fx, f.fy, f.fz)) {
299
+ return want;
300
+ }
301
+ }
302
+ else {
303
+ const fn = bodyShape.faceNormals;
304
+ for (let i = 0; i < fn.length; i += 3) {
305
+ const lx = fn[i] ?? 0;
306
+ const ly = fn[i + 1] ?? 0;
307
+ const lz = fn[i + 2] ?? 0;
308
+ if (!foldAxis(f.rx * lx + f.ux * ly + f.fx * lz, f.ry * lx + f.uy * ly + f.fy * lz, f.rz * lx + f.uz * ly + f.fz * lz)) {
309
+ return want;
310
+ }
311
+ }
312
+ }
313
+ // Edge crosses. A round side has no edges; its direction of travel stands in,
314
+ // which is exactly the axis a rolling contact needs.
315
+ const colEdges = colShape === undefined ? 3 : Math.max(1, colShape.edgeDirs.length / 3);
316
+ const bodyEdges = bodyShape === undefined ? 3 : Math.max(1, bodyShape.edgeDirs.length / 3);
317
+ for (let c = 0; c < colEdges; c++) {
318
+ let cx;
319
+ let cy;
320
+ let cz;
321
+ if (colShape === undefined) {
322
+ cx = c === 0 ? 1 : 0;
323
+ cy = c === 1 ? 1 : 0;
324
+ cz = c === 2 ? 1 : 0;
325
+ }
326
+ else if (colShape.edgeDirs.length === 0) {
327
+ cx = swTx;
328
+ cy = swTy;
329
+ cz = swTz;
330
+ }
331
+ else {
332
+ cx = colShape.edgeDirs[c * 3] ?? 0;
333
+ cy = colShape.edgeDirs[c * 3 + 1] ?? 0;
334
+ cz = colShape.edgeDirs[c * 3 + 2] ?? 0;
335
+ }
336
+ for (let b = 0; b < bodyEdges; b++) {
337
+ let ex;
338
+ let ey;
339
+ let ez;
340
+ if (bodyShape === undefined) {
341
+ ex = b === 0 ? f.rx : b === 1 ? f.ux : f.fx;
342
+ ey = b === 0 ? f.ry : b === 1 ? f.uy : f.fy;
343
+ ez = b === 0 ? f.rz : b === 1 ? f.uz : f.fz;
344
+ }
345
+ else if (bodyShape.edgeDirs.length === 0) {
346
+ ex = swTx;
347
+ ey = swTy;
348
+ ez = swTz;
349
+ }
350
+ else {
351
+ const lx = bodyShape.edgeDirs[b * 3] ?? 0;
352
+ const ly = bodyShape.edgeDirs[b * 3 + 1] ?? 0;
353
+ const lz = bodyShape.edgeDirs[b * 3 + 2] ?? 0;
354
+ ex = f.rx * lx + f.ux * ly + f.fx * lz;
355
+ ey = f.ry * lx + f.uy * ly + f.fy * lz;
356
+ ez = f.rz * lx + f.uz * ly + f.fz * lz;
357
+ }
358
+ if (!foldAxis(cy * ez - cz * ey, cz * ex - cx * ez, cx * ey - cy * ex))
359
+ return want;
360
+ }
361
+ }
362
+ // A round body meets corners the face axes cannot see: point-to-centre axes.
363
+ // A cylinder counts as round here too: its side is curved, and the corner axes below are the
364
+ // only ones that see a curved surface at all. More axes can only stop a body earlier.
365
+ const bodyRound = bodyShape !== undefined && (bodyShape.faceNormals.length === 0 || bodyShape.sideRadius > 0);
366
+ const colRound = colShape !== undefined && (colShape.faceNormals.length === 0 || colShape.sideRadius > 0);
367
+ if (bodyRound) {
368
+ if (colShape === undefined) {
369
+ for (let i = 0; i < 8; i++) {
370
+ const vx = i & 1 ? swMaxX : swMinX;
371
+ const vy = i & 2 ? swMaxY : swMinY;
372
+ const vz = i & 4 ? swMaxZ : swMinZ;
373
+ if (!foldAxis(vx - px, vy - py, vz - pz))
374
+ return want;
375
+ }
376
+ }
377
+ else {
378
+ const v = colShape.vertices;
379
+ for (let i = 0; i < v.length; i += 3) {
380
+ if (!foldAxis((v[i] ?? 0) - px, (v[i + 1] ?? 0) - py, (v[i + 2] ?? 0) - pz))
381
+ return want;
382
+ }
383
+ }
384
+ if (colRound && !foldAxis(swTx, swTy, swTz))
385
+ return want;
386
+ }
387
+ if (colRound && !bodyRound) {
388
+ const cx = colShape.vertices[0] ?? 0;
389
+ const cy = colShape.vertices[1] ?? 0;
390
+ const cz = colShape.vertices[2] ?? 0;
391
+ if (bodyShape === undefined) {
392
+ for (let i = 0; i < 8; i++) {
393
+ const sx = i & 1 ? body.hx : -body.hx;
394
+ const sy = i & 2 ? body.hy : -body.hy;
395
+ const sz = i & 4 ? body.hz : -body.hz;
396
+ const wx = px + f.rx * sx + f.ux * sy + f.fx * sz;
397
+ const wy = py + f.ry * sx + f.uy * sy + f.fy * sz;
398
+ const wz = pz + f.rz * sx + f.uz * sy + f.fz * sz;
399
+ if (!foldAxis(cx - wx, cy - wy, cz - wz))
400
+ return want;
401
+ }
402
+ }
403
+ else {
404
+ const v = bodyShape.vertices;
405
+ for (let i = 0; i < v.length; i += 3) {
406
+ const lx = v[i] ?? 0;
407
+ const ly = v[i + 1] ?? 0;
408
+ const lz = v[i + 2] ?? 0;
409
+ const wx = px + f.rx * lx + f.ux * ly + f.fx * lz;
410
+ const wy = py + f.ry * lx + f.uy * ly + f.fy * lz;
411
+ const wz = pz + f.rz * lx + f.uz * ly + f.fz * lz;
412
+ if (!foldAxis(cx - wx, cy - wy, cz - wz))
413
+ return want;
414
+ }
415
+ }
416
+ }
417
+ // Nothing constrained the travel at all.
418
+ if (winEnter === -Infinity)
419
+ return want;
420
+ /*
421
+ * Genuinely inside when the move begins — a spawn, a deck risen underneath —
422
+ * is let out rather than trapped; that has always been the rule. The
423
+ * threshold placement is load-bearing: a body at rest sits a skin short of
424
+ * the face, so its next identical move computes an entry of exactly −SKIN.
425
+ * A threshold at −SKIN put the resting state on the boundary and let
426
+ * floating point decide, tick by tick, whether the floor existed — measured
427
+ * as the character falling through the world one tick after landing. Two skins
428
+ * puts rest a full skin inside the held band; only real interpenetration
429
+ * beyond that opens the way out.
430
+ */
431
+ if (winEnter < -SKIN * 2) {
432
+ /*
433
+ * **Out is allowed. *Anywhere* is not, and that difference is the whole of this.**
434
+ *
435
+ * This returned the full travel, unconditionally, which reads as "a body that is already
436
+ * inside something is not constrained by it" — and that is exactly what it did. A character
437
+ * whose body had ended up inside a column could walk straight on through it and out the far
438
+ * side, because every move it asked for was granted in full. Reported over and over as
439
+ * *"the character sometimes pass though"*, and it is the same defect behind the rest of
440
+ * that family: a move nothing clamps leaves `contactSupport` at the `true` it starts with,
441
+ * so the body is told it is standing, and every backstop a consumer has is waiting for a
442
+ * refusal that never comes.
443
+ *
444
+ * Measured on a capture between two courtyard pillars 0.41 m apart with a body 0.70 m wide
445
+ * overlapping both: held forward carried it clean across their span and off the world.
446
+ *
447
+ * The overlap runs from `winEnter` to `winExit` along the direction of travel, so the body
448
+ * leaves in `winExit` going forward and in `-winEnter` going back. Travelling toward the
449
+ * nearer of the two is coming out; travelling toward the further one is going deeper. Only
450
+ * the first is granted, and it is granted whole, so nothing is ever sealed in: whichever
451
+ * way is shortest is always open.
452
+ *
453
+ * `winExit` is `Infinity` where nothing ahead bounds the overlap at all, which is not a way
454
+ * out and compares as one that never arrives.
455
+ *
456
+ * **Held to a body whose middle is between this obstacle's floor and its lid**, which is the
457
+ * same test `ejectFromSolid` is gated on and for the same reason: it separates being *in* a
458
+ * solid from the shallow overlaps ordinary play is full of. A collider is sunk under the
459
+ * surface it draws, so a character standing on one has its feet inside the box by design; a
460
+ * skate corner clips the flank of a slab in passing. Neither is burial, both compute an
461
+ * entry past the threshold above, and restricting either costs mobility where there was
462
+ * never a wall — measured as a character perched on a rim taking 1.77 s to come off it instead
463
+ * of 0.20 s, because a two-centimetre graze on a neighbouring box was being treated as a
464
+ * solid to be respected.
465
+ */
466
+ /*
467
+ * **Horizontal travel only, and gravity is why.**
468
+ *
469
+ * "The nearer exit is the way out" is a statement about *passing through* something, and
470
+ * passing through is a horizontal idea here. Applied to a falling body it says the opposite
471
+ * of what it means: a body overlapping the flank of a tall wall by a few centimetres has its
472
+ * shortest exit *upward*, so descending reads as taking the long way and the fall is refused.
473
+ * A body pinched between two walls then hangs in clear air for ever, which is the failure
474
+ * this whole rule exists to end rather than one to introduce. Measured on the smallest case
475
+ * that shows it — two walls closer together than the body is wide — as zero metres of descent
476
+ * in five seconds.
477
+ *
478
+ * A body buried under something has `ejectFromSolid` for the vertical, which knows to prefer
479
+ * up and knows how to clear every overlap at once. Nothing needs this axis, and everything
480
+ * that falls needs it left alone.
481
+ */
482
+ /*
483
+ * **And a ceiling counts as something you are inside, which the middle test alone cannot
484
+ * see.**
485
+ *
486
+ * The test above asks whether the body's *centre* is between this obstacle's floor and its
487
+ * lid, and for anything a body stands on or squeezes between that is exactly right. It is
488
+ * blind to the one arrangement where the body is under the obstacle rather than in it: a
489
+ * deck overhead, low enough that the character's *head* is inside it while the centre is still
490
+ * below the deck's floor. The gate reads false, the rule declines, and the full travel is
491
+ * granted — so the deck stops existing for the whole body, not merely for the head.
492
+ *
493
+ * Measured where a route crosses over itself with 1.75 m of clearance for a 1.70 m body:
494
+ * standing normally on the lower deck, the head is already 0.148 m inside the upper one,
495
+ * and from there `moveAxis` delivers a **full metre** of travel further in against **zero**
496
+ * resistance. What that costs is not a clipped head. It is everything downstream — the
497
+ * character walks on into a pocket, the rescues push them deeper because nothing pushes back,
498
+ * and they come to rest sealed on all six axes with 0.3 mm of headroom. Reported as *"even
499
+ * if the head cannot physically pass through, I'm able to go inside and get stuck"* and
500
+ * *"I shouldn't be able to ENTER there"*.
501
+ *
502
+ * `swMinY >= swBody.y` is the whole of it: an obstacle whose floor is at or above the body's
503
+ * middle is over the body, not under it. Nothing a body rests on can satisfy it, so every
504
+ * surface in the world is untouched — and this is reached only when the travel direction
505
+ * shows real interpenetration in the first place.
506
+ *
507
+ * What it grants is unchanged and is the point: the nearer exit is still open and still
508
+ * whole, so a body under a deck can always back out the way it came. Only going further in
509
+ * is refused.
510
+ */
511
+ if (axis !== AXIS_Y && ((swBody.y > swMinY && swBody.y < swMaxY) || swMinY >= swBody.y)) {
512
+ return winExit <= -winEnter ? want : 0;
513
+ }
514
+ return want;
515
+ }
516
+ if (winEnter >= want)
517
+ return want;
518
+ return winEnter > 0 ? winEnter : 0;
519
+ }
520
+ /** Scratch for `moveAxis` and `bodyBounds`; the tick may not allocate. */
521
+ const sweepBounds = createBodyBounds();
522
+ const sweepFrame = createBodyFrame();
523
+ /**
524
+ * Move `body` along one axis by up to `delta`, clamped by the first obstacle.
525
+ * Returns the distance actually moved (compare with `delta` to detect a hit).
526
+ */
527
+ /**
528
+ * How far past one span's edge a coordinate sits, as a sign: -1 below, +1 above, 0 inside.
529
+ *
530
+ * The direction that takes a body **further off** the face it is perched on the rim of, which
531
+ * is the one thing a caller cannot work out from a contact normal: a rim perch's normal points
532
+ * straight up, because up really is the way out of the clamp. Off is sideways, and only the
533
+ * sweep knows which sideways, because only the sweep saw the obstacle's footprint.
534
+ */
535
+ /**
536
+ * Is this column standing over the obstacle itself, rather than merely over its bounding box?
537
+ *
538
+ * **The box is the answer only for a box, and this used to give it for everything.** Support asks
539
+ * whether the body's own column is over the thing holding it up, and a bounding box stands in for
540
+ * the footprint — which is exact for an axis-aligned collider and enormously wrong for a diagonal
541
+ * one. A ribbon's deck is tiled with wedges whose top face is the drawn triangle; a wedge lying
542
+ * across the world axes has a box several times its own area, and every column inside that box was
543
+ * being called standing.
544
+ *
545
+ * What that produced is a walkable ledge in every cut in a deck. A body landing at the mouth of a
546
+ * gap catches the deck's last wedge with the edge of its shoulder, its centre a quarter of a metre
547
+ * out over the hole — and was told it was standing, so nothing slid it off and nothing let it
548
+ * fall. Measured on one route's day 23: a band about a metre wide over an authored gap where a
549
+ * body lands, reports grounded, and stays for ever, resting one collider give below a deck the
550
+ * surface correctly says is not there.
551
+ *
552
+ * For a convex volume the column hits it exactly when the point is inside the shape's shadow on
553
+ * the ground plane, and the faces with a horizontal normal are what bound that shadow — a wedge's
554
+ * sides, a slab's flanks. Faces looking up or down bound nothing there and are skipped, which is
555
+ * also what keeps this from rejecting the middle of a perfectly ordinary lid.
556
+ *
557
+ * Once per move rather than once per candidate: only the obstacle that actually clamped the move
558
+ * has any say in whether the body is standing on something.
559
+ */
560
+ function overFootprint(x, z, shape, minX, maxX, minZ, maxZ) {
561
+ if (shape === undefined) {
562
+ footprintOutX = rimOut(x, minX, maxX);
563
+ footprintOutZ = rimOut(z, minZ, maxZ);
564
+ return footprintOutX === 0 && footprintOutZ === 0;
565
+ }
566
+ const normals = shape.faceNormals;
567
+ const vertices = shape.vertices;
568
+ /* A shape's own rounding widens its shadow, and a sphere has no faces at all to bound one. */
569
+ const grow = shape.radius;
570
+ let worstPast = -Infinity;
571
+ footprintOutX = 0;
572
+ footprintOutZ = 0;
573
+ for (let i = 0; i < normals.length; i += 3) {
574
+ const nx = normals[i] ?? 0;
575
+ const nz = normals[i + 2] ?? 0;
576
+ const length = Math.sqrt(nx * nx + nz * nz);
577
+ /* Looking up or down: it bounds the volume, not its shadow. */
578
+ if (length < FOOTPRINT_MIN_HORIZONTAL)
579
+ continue;
580
+ const ux = nx / length;
581
+ const uz = nz / length;
582
+ /*
583
+ * **Both ends of the line, because that is how the list is stored.**
584
+ *
585
+ * `faceNormals` holds one entry per *direction* — a normal and its opposite appear once
586
+ * between them — so a slab has three entries and not six. Testing only `dot <= max` bounds
587
+ * one side of each and leaves the other open, which is not a small error: it left the far
588
+ * end and one flank of every deck unbounded, so a column past them was still called standing
589
+ * and the ledge this exists to remove survived the fix meant to remove it.
590
+ */
591
+ let high = -Infinity;
592
+ let low = Infinity;
593
+ for (let v = 0; v < vertices.length; v += 3) {
594
+ const reach = ux * (vertices[v] ?? 0) + uz * (vertices[v + 2] ?? 0);
595
+ if (reach > high)
596
+ high = reach;
597
+ if (reach < low)
598
+ low = reach;
599
+ }
600
+ const here = ux * x + uz * z;
601
+ const pastHigh = here - high - grow;
602
+ if (pastHigh > worstPast) {
603
+ /* The face the column is furthest outside is the one it fell off, so its outward normal
604
+ is the way further off — which is what a caller with nothing to stand on needs. */
605
+ worstPast = pastHigh;
606
+ footprintOutX = ux;
607
+ footprintOutZ = uz;
608
+ }
609
+ const pastLow = low - grow - here;
610
+ if (pastLow > worstPast) {
611
+ worstPast = pastLow;
612
+ footprintOutX = -ux;
613
+ footprintOutZ = -uz;
614
+ }
615
+ }
616
+ return worstPast <= 0;
617
+ }
618
+ /**
619
+ * Is anything at all under this column at the height the body came to rest?
620
+ *
621
+ * **`overFootprint` answers about one collider; this answers about the floor.** Support is asked
622
+ * of the obstacle that won the clamp, which is exact for a floor made of a single thing and wrong
623
+ * everywhere two colliders abut to make one. A body straddling the join is inside both, and the
624
+ * one that wins is not always the one its centre is over: the box *behind* it wins as readily as
625
+ * the box in front, and then the column is outside the winner's footprint by construction, with a
626
+ * rim that points back the way the body came.
627
+ *
628
+ * What that costs is not a stumble. `contactSupport` is a body's grounding, so a false refusal
629
+ * takes the jump, keeps re-clamping the fall, and hands the consumer a direction to push the body
630
+ * in — and pushing it *back over the join* re-arms the refusal on the next tick. Captured on the
631
+ * back edge of a terrace, where one collider ends at z = 9.4046 and the strip behind it begins:
632
+ * the body accelerates across the join for five ticks, is shoved back 0.06 m with its velocity
633
+ * replaced, and does it again for as long as the key is held. Net progress zero, on flat ground,
634
+ * with nothing drawn there. A jump clears it because a body in the air has no downward clamp to
635
+ * be refused.
636
+ *
637
+ * The height test is what keeps this from becoming "always say yes". A candidate counts as the
638
+ * floor the body is on only if its lid is at the height the body actually came to rest, so a
639
+ * lower deck below a ledge is still a drop and the rim perch this whole test exists to refuse is
640
+ * refused exactly as before: over a hole there is nothing to find. There is deliberately no upper
641
+ * bound — a lid *above* the resting height belongs to something the body is inside rather than on,
642
+ * which is `ejectFromSolid`'s question, and shoving such a body sideways off a rim it is not on is
643
+ * the wrong answer to it.
644
+ *
645
+ * Runs only where the winner already said no, so the common path pays nothing for it, and it
646
+ * reads the same query the sweep already made rather than making another.
647
+ */
648
+ function columnOverFloor(body, boxes, count, restY) {
649
+ const data = boxes.data;
650
+ for (let h = 0; h < count; h++) {
651
+ const index = hits[h] ?? 0;
652
+ const o = index * 6;
653
+ /* Below the feet by more than a join's worth: a floor, but not this one. */
654
+ if ((data[o + 4] ?? 0) < restY - FLOOR_JOIN)
655
+ continue;
656
+ if (overFootprint(body.x, body.z, boxes.shapeAt(index), data[o] ?? 0, data[o + 3] ?? 0, data[o + 2] ?? 0, data[o + 5] ?? 0)) {
657
+ return true;
658
+ }
659
+ }
660
+ return false;
661
+ }
662
+ /**
663
+ * How far below the body's feet a lid may be and still be the same floor.
664
+ *
665
+ * Two colliders authored to meet share a lid height exactly, and a body clamped by one rests a
666
+ * `SKIN` above it, so the whole question is decided inside a few thousandths of a metre. Four
667
+ * skins is room for that and for the float noise of a world built hundreds of metres from the
668
+ * origin, and it is far under any step a character would have to fall to.
669
+ */
670
+ const FLOOR_JOIN = SKIN * 4;
671
+ /**
672
+ * How far above the sole a contact may be and still count as underfoot, as a fraction of the
673
+ * body's half-height.
674
+ *
675
+ * Roughly ankle height on a standing character — 0.26 m on a 1.70 m body — and expressed
676
+ * against the body rather than in metres, because what counts as *underfoot* is a fact about
677
+ * the body and not about the world. A compound character's feet and the legs immediately above
678
+ * them fall inside it; its chest and its head do not, and those are the ones this exists to
679
+ * refuse.
680
+ */
681
+ const UNDERFOOT_BAND = 0.3;
682
+ /**
683
+ * The lowest point of one part, along the body's own up, in body space.
684
+ *
685
+ * A shape's `vertices` are its local hull and its `radius` is rounding grown around them, so the
686
+ * sole of a rounded skate sits a radius below its lowest vertex. Called only on a downward move
687
+ * by a body made of parts: four parts of eight points on the character this was measured for.
688
+ */
689
+ function partFloor(part) {
690
+ const v = part.shape.vertices;
691
+ let lowest = Infinity;
692
+ for (let i = 1; i < v.length; i += 3) {
693
+ const y = v[i] ?? 0;
694
+ if (y < lowest)
695
+ lowest = y;
696
+ }
697
+ return (lowest === Infinity ? 0 : lowest) + part.oy - part.shape.radius;
698
+ }
699
+ /**
700
+ * Which way is *off* the shape whose shadow `overFootprint` last found a column outside.
701
+ *
702
+ * Written by that call and read straight after it, in the one place both run. A pair of module
703
+ * scratch values rather than an out parameter, because this sits in the tick and the house rules
704
+ * allow no allocation there.
705
+ */
706
+ let footprintOutX = 0;
707
+ let footprintOutZ = 0;
708
+ /**
709
+ * How much horizontal a face normal needs before it says anything about the shadow.
710
+ *
711
+ * A deck's lid is not exactly level — it banks and it climbs — so a threshold of zero would let
712
+ * the lid's own slight lean bound the footprint and cut a walkable surface down to a sliver of
713
+ * itself. A fifth is well past any lid a character can stand on (`MIN_GROUND_NORMAL_Y` is cos 70°,
714
+ * whose horizontal part is 0.94) and well under any side face worth bounding with.
715
+ */
716
+ const FOOTPRINT_MIN_HORIZONTAL = 0.2;
717
+ function rimOut(centre, min, max) {
718
+ if (centre < min)
719
+ return -1;
720
+ if (centre > max)
721
+ return 1;
722
+ return 0;
723
+ }
724
+ export function moveAxis(body, boxes, axis, delta) {
725
+ if (delta === 0)
726
+ return 0;
727
+ /*
728
+ * The body's *oriented* bounds, not its nominal half-extents. A body lying along a
729
+ * banked deck occupies a taller, wider column than an upright one, and resolving it
730
+ * against the smaller box is what let a drawn body reach into solid geometry.
731
+ */
732
+ const bounds = bodyBounds(body, sweepBounds);
733
+ const frame = bodyFrame(body, sweepFrame);
734
+ const minX = body.x - bounds.hx;
735
+ const maxX = body.x + bounds.hx;
736
+ const minY = body.y - bounds.hy;
737
+ const maxY = body.y + bounds.hy;
738
+ const minZ = body.z - bounds.hz;
739
+ const maxZ = body.z + bounds.hz;
740
+ // Sweep region: the body's box grown along the axis of travel, so nothing
741
+ // the move could reach is ever missed.
742
+ const count = boxes.query(axis === AXIS_X && delta < 0 ? minX + delta : minX, axis === AXIS_Y && delta < 0 ? minY + delta : minY, axis === AXIS_Z && delta < 0 ? minZ + delta : minZ, axis === AXIS_X && delta > 0 ? maxX + delta : maxX, axis === AXIS_Y && delta > 0 ? maxY + delta : maxY, axis === AXIS_Z && delta > 0 ? maxZ + delta : maxZ, hits);
743
+ const data = boxes.data;
744
+ /*
745
+ * Where this body's soles are, in its own space: the floor of its lowest part, so a contact
746
+ * can be asked whether the body met it with something it stands on. Computed once here rather
747
+ * than per candidate, and only where the question is asked at all — a downward move by a body
748
+ * made of parts.
749
+ */
750
+ const soleParts = axis === AXIS_Y && delta <= 0 ? body.parts : undefined;
751
+ let soleFloor = Infinity;
752
+ if (soleParts !== undefined) {
753
+ for (let p = 0; p < soleParts.length; p++) {
754
+ const part = soleParts[p];
755
+ if (part === undefined)
756
+ continue;
757
+ const floor = partFloor(part);
758
+ if (floor < soleFloor)
759
+ soleFloor = floor;
760
+ }
761
+ }
762
+ // Support of the contact that finally clamps the move; true when unblocked.
763
+ let clampSupport = true;
764
+ /*
765
+ * The obstacle that won the clamp, kept rather than judged on the spot.
766
+ *
767
+ * Whether a contact is *support* is a question about the winner alone, and asking it at every
768
+ * candidate meant asking it several times a move and throwing all but one answer away — which
769
+ * mattered less than the fact that it could only be asked of a box. Kept here, it can be asked
770
+ * of the real volume once. See `overFootprint`.
771
+ */
772
+ let clampWalkable = true;
773
+ let clampShape;
774
+ let clampMinX = 0;
775
+ let clampMaxX = 0;
776
+ let clampMinZ = 0;
777
+ let clampMaxZ = 0;
778
+ /*
779
+ * And which way is *off* it, when a downward contact is a walkable plane the body's own
780
+ * column is not over — the rim perch `clampSupport` exists to refuse. Zero when the refusal
781
+ * had another reason, or when there was no refusal. See `Body.contactRimX`.
782
+ */
783
+ let clampRimX = 0;
784
+ let clampRimZ = 0;
785
+ // And which way is out of it. Zero until something actually clamps the move.
786
+ let clampNx = 0;
787
+ let clampNy = 0;
788
+ let clampNz = 0;
789
+ for (let h = 0; h < count; h++) {
790
+ const index = hits[h] ?? 0;
791
+ const o = index * 6;
792
+ const bMinX = data[o] ?? 0;
793
+ const bMinY = data[o + 1] ?? 0;
794
+ const bMinZ = data[o + 2] ?? 0;
795
+ const bMaxX = data[o + 3] ?? 0;
796
+ const bMaxY = data[o + 4] ?? 0;
797
+ const bMaxZ = data[o + 5] ?? 0;
798
+ const colShape = boxes.shapeAt(index);
799
+ /*
800
+ * Anything with a real volume on either side — a tilted body, a shaped
801
+ * body, a shaped obstacle — is resolved by sweeping the volumes, not by
802
+ * growing axis-aligned boxes around them. The bounds above are the broad
803
+ * phase; `sweepShapePair` is the answer, so a body is stopped by exactly
804
+ * the geometry it would really touch and by nothing else.
805
+ */
806
+ if (frame.tilted ||
807
+ colShape !== undefined ||
808
+ body.shape !== undefined ||
809
+ body.parts !== undefined) {
810
+ let room = Math.abs(delta);
811
+ /*
812
+ * The frame rotates about the body's pivot (its feet, for a standing
813
+ * character), so the world position of anything local is
814
+ * `pos + pivot + R·(local − pivot)` — for the upright frame that is
815
+ * exactly `pos + local`, unchanged.
816
+ */
817
+ const pivotY = body.pivotY ?? 0;
818
+ const baseX = body.x - frame.rx * 0 - frame.ux * pivotY;
819
+ const baseY = body.y + pivotY - frame.uy * pivotY;
820
+ const baseZ = body.z - frame.uz * pivotY;
821
+ const parts = body.parts;
822
+ if (parts !== undefined && parts.length > 0) {
823
+ // A compound body is the nearest contact any of its parts makes.
824
+ for (let p = 0; p < parts.length; p++) {
825
+ const part = parts[p];
826
+ if (part === undefined)
827
+ continue;
828
+ /*
829
+ * **A part that is not a foot may collide, but it may not hold the body up.**
830
+ *
831
+ * Nothing here stands on its chest. A compound character caught on a deck's edge by
832
+ * the torso — whose box carries the arms — hangs there with its skates a full metre
833
+ * clear of anything, and every rule downstream believes it: the contact plane is
834
+ * walkable, the column is over solid ground, so the body is *standing*, and it stays
835
+ * standing for as long as the player leaves it. Measured at the reporter's own
836
+ * capture as the whole body's fall clamping at **0.0000 m** while the skates alone
837
+ * fall **1.1106 m**, and reported as being *"magnetized in the mid part of the
838
+ * body"* — with a screenshot of an arm resting on the rim, and the reading that
839
+ * settles it: *"it may collide but they should just slip, we don't have any hand
840
+ * holding feature."*
841
+ *
842
+ * So on the way *down* only the feet answer. Sideways every part still collides
843
+ * exactly as before — an arm cannot pass through a deck — and a fall simply carries
844
+ * on past whatever the upper body brushes until something is under the soles. The
845
+ * band is `UNDERFOOT_BAND`, which admits the legs immediately above the skates and
846
+ * nothing higher.
847
+ *
848
+ * Plain and single-shape bodies never reach this: `soleParts` is set only for a
849
+ * downward move by a body made of parts.
850
+ */
851
+ if (soleParts !== undefined && partFloor(part) - soleFloor > body.hy * UNDERFOOT_BAND) {
852
+ continue;
853
+ }
854
+ const px = baseX + frame.rx * part.ox + frame.ux * part.oy + frame.fx * part.oz;
855
+ const py = baseY + frame.ry * part.ox + frame.uy * part.oy + frame.fy * part.oz;
856
+ const pz = baseZ + frame.rz * part.ox + frame.uz * part.oy + frame.fz * part.oz;
857
+ // A part nowhere near this obstacle, even after the whole move,
858
+ // costs six compares instead of an axis sweep.
859
+ const pr = part.shape.boundRadius + Math.abs(delta);
860
+ if (px + pr < bMinX ||
861
+ px - pr > bMaxX ||
862
+ py + pr < bMinY ||
863
+ py - pr > bMaxY ||
864
+ pz + pr < bMinZ ||
865
+ pz - pr > bMaxZ) {
866
+ continue;
867
+ }
868
+ const r = sweepShapePair(part.shape, body, frame, px, py, pz, colShape, bMinX, bMinY, bMinZ, bMaxX, bMaxY, bMaxZ, axis, delta);
869
+ if (r < room) {
870
+ room = r;
871
+ /*
872
+ * Support needs the body's own column over the thing holding it,
873
+ * not only a walkable contact plane: a foot's corner catching the
874
+ * rim of a slab with the body's centre already past the edge is a
875
+ * tip-over, and calling it standing left characters planted in the
876
+ * air a stride past every pad and deck end. The obstacle's bounds
877
+ * stand in for its footprint — conservative for a diagonal slab,
878
+ * exact for the boxes this failure was measured on.
879
+ */
880
+ clampWalkable = winSupport;
881
+ clampShape = colShape;
882
+ clampMinX = bMinX;
883
+ clampMaxX = bMaxX;
884
+ clampMinZ = bMinZ;
885
+ clampMaxZ = bMaxZ;
886
+ clampNx = winNx;
887
+ clampNy = winNy;
888
+ clampNz = winNz;
889
+ }
890
+ }
891
+ }
892
+ else {
893
+ const r = sweepShapePair(body.shape, body, frame, baseX, baseY, baseZ, colShape, bMinX, bMinY, bMinZ, bMaxX, bMaxY, bMaxZ, axis, delta);
894
+ if (r < room) {
895
+ room = r;
896
+ clampWalkable = winSupport;
897
+ clampShape = colShape;
898
+ clampMinX = bMinX;
899
+ clampMaxX = bMaxX;
900
+ clampMinZ = bMinZ;
901
+ clampMaxZ = bMaxZ;
902
+ clampNx = winNx;
903
+ clampNy = winNy;
904
+ clampNz = winNz;
905
+ }
906
+ }
907
+ if (room < Math.abs(delta))
908
+ delta = delta < 0 ? -room : room;
909
+ continue;
910
+ }
911
+ if (axis !== AXIS_X && (maxX - SHRINK <= bMinX || minX + SHRINK >= bMaxX))
912
+ continue;
913
+ if (axis !== AXIS_Y && (maxY - SHRINK <= bMinY || minY + SHRINK >= bMaxY))
914
+ continue;
915
+ if (axis !== AXIS_Z && (maxZ - SHRINK <= bMinZ || minZ + SHRINK >= bMaxZ))
916
+ continue;
917
+ const bodyMin = axis === AXIS_X ? minX : axis === AXIS_Y ? minY : minZ;
918
+ const bodyMax = axis === AXIS_X ? maxX : axis === AXIS_Y ? maxY : maxZ;
919
+ const boxMin = axis === AXIS_X ? bMinX : axis === AXIS_Y ? bMinY : bMinZ;
920
+ const boxMax = axis === AXIS_X ? bMaxX : axis === AXIS_Y ? bMaxY : bMaxZ;
921
+ if (delta > 0 && bodyMax <= boxMin + SKIN) {
922
+ const room = boxMin - bodyMax - SKIN;
923
+ if (room < delta) {
924
+ delta = room > 0 ? room : 0;
925
+ /*
926
+ * On this path the clamp is always against the move axis's own face, so out
927
+ * is simply back the way the body came. No separating-axis search is
928
+ * involved and none is needed.
929
+ */
930
+ clampNx = axis === AXIS_X ? -1 : 0;
931
+ clampNy = axis === AXIS_Y ? -1 : 0;
932
+ clampNz = axis === AXIS_Z ? -1 : 0;
933
+ }
934
+ }
935
+ else if (delta < 0 && bodyMin >= boxMax - SKIN) {
936
+ const room = boxMax - bodyMin + SKIN;
937
+ if (room > delta) {
938
+ delta = room < 0 ? room : 0;
939
+ clampNx = axis === AXIS_X ? 1 : 0;
940
+ clampNy = axis === AXIS_Y ? 1 : 0;
941
+ clampNz = axis === AXIS_Z ? 1 : 0;
942
+ /*
943
+ * **The footprint this clamp will be judged against, which this path never
944
+ * filled in.**
945
+ *
946
+ * The downward report asks `overFootprint` whether the body's column is over
947
+ * whatever stopped it, and it reads these four regardless of which path won the
948
+ * clamp — but only the shape sweep above ever wrote them. A body with no volume
949
+ * of its own therefore had its column tested against a zero-area footprint at
950
+ * the world origin: supported at exactly (0, 0) and refused everywhere else, on
951
+ * a floor with nothing wrong with it, with a rim that was a compass bearing away
952
+ * from the origin rather than a way off anything. Measured on a single flat
953
+ * slab, 400 of 401 columns across a four-metre line.
954
+ *
955
+ * A plain collider's box *is* its footprint, so the four bounds are simply the
956
+ * box, and `clampShape` is cleared because this winner has no shape — a set that
957
+ * holds both hulls and boxes runs both paths in one move, and the shape left
958
+ * behind by a candidate that lost would otherwise be used to judge this one.
959
+ */
960
+ clampWalkable = true;
961
+ clampShape = undefined;
962
+ clampMinX = bMinX;
963
+ clampMaxX = bMaxX;
964
+ clampMinZ = bMinZ;
965
+ clampMaxZ = bMaxZ;
966
+ }
967
+ }
968
+ }
969
+ if (axis === AXIS_X)
970
+ body.x += delta;
971
+ else if (axis === AXIS_Y)
972
+ body.y += delta;
973
+ else
974
+ body.z += delta;
975
+ // Only downward moves get the report: it is the question "am I standing on
976
+ // this", and nothing else asks it. The plain-AABB path clamps against the
977
+ // move axis's own faces, so a block there is support by construction.
978
+ if (axis === AXIS_Y && delta <= 0) {
979
+ if (clampNx !== 0 || clampNy !== 0 || clampNz !== 0) {
980
+ const over = overFootprint(body.x, body.z, clampShape, clampMinX, clampMaxX, clampMinZ, clampMaxZ);
981
+ /*
982
+ * The way off, from the same call that decided the body is not on it — read out
983
+ * now, because the floor test below asks the same question of other colliders and
984
+ * each answer overwrites the last.
985
+ */
986
+ const rimX = footprintOutX;
987
+ const rimZ = footprintOutZ;
988
+ /*
989
+ * **The winner is one collider and support is a question about the floor.**
990
+ *
991
+ * Asking only the obstacle that clamped is exact for a floor made of one thing and
992
+ * wrong wherever two colliders abut to make one: the box *behind* the body can win
993
+ * the clamp while the body's centre is already over the box in front, and then the
994
+ * column is outside the winner's footprint by construction, with a rim pointing
995
+ * back the way the body came. Measured on two slabs meeting at a line, 34 of 401
996
+ * columns across the join refused support, in one band as wide as the body and all
997
+ * on the same side of the line — which is what a player feels as a wall they can
998
+ * cross one way and not the other, and can jump over, since a body in the air has
999
+ * no downward clamp to be refused.
1000
+ *
1001
+ * That is most of the floor in a real world rather than an edge case: a terrace
1002
+ * against its verge, a verge's strips against each other, every deck wedge against
1003
+ * the next.
1004
+ *
1005
+ * So the column is held if *anything* the move could have landed on is under it at
1006
+ * the height the body came to rest. The rim perch this test exists to refuse is
1007
+ * refused exactly as before, because over a hole there is nothing to find.
1008
+ */
1009
+ const held = over || columnOverFloor(body, boxes, count, body.y - bounds.hy);
1010
+ clampSupport = clampWalkable && held;
1011
+ clampRimX = clampWalkable && !held ? rimX : 0;
1012
+ clampRimZ = clampWalkable && !held ? rimZ : 0;
1013
+ }
1014
+ body.contactSupport = clampSupport;
1015
+ /*
1016
+ * Written on the same move that decides support, and cleared when support was granted, so
1017
+ * a caller cannot act on a rim it left three ticks ago. See `Body.contactRimX`.
1018
+ */
1019
+ const rimLength = Math.sqrt(clampRimX * clampRimX + clampRimZ * clampRimZ);
1020
+ body.contactRimX = rimLength > 0 ? clampRimX / rimLength : 0;
1021
+ body.contactRimZ = rimLength > 0 ? clampRimZ / rimLength : 0;
1022
+ }
1023
+ /*
1024
+ * The way out of whatever clamped this move, for every axis and both directions —
1025
+ * unlike `contactSupport`, which is a question only a downward move asks. Written
1026
+ * only when something actually clamped, so a caller that checks "was I blocked"
1027
+ * first is never handed a stale direction as though it were fresh.
1028
+ */
1029
+ if (clampNx !== 0 || clampNy !== 0 || clampNz !== 0) {
1030
+ const len = Math.sqrt(clampNx * clampNx + clampNy * clampNy + clampNz * clampNz) || 1;
1031
+ body.contactNormalX = clampNx / len;
1032
+ body.contactNormalY = clampNy / len;
1033
+ body.contactNormalZ = clampNz / len;
1034
+ }
1035
+ return delta;
1036
+ }