@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,28 @@
1
+ /** An axis-aligned box, and the one operation that builds one. */
2
+
3
+ export interface Aabb {
4
+ minX: number;
5
+ minY: number;
6
+ minZ: number;
7
+ maxX: number;
8
+ maxY: number;
9
+ maxZ: number;
10
+ }
11
+
12
+ export function aabbFromCenter(
13
+ cx: number,
14
+ cy: number,
15
+ cz: number,
16
+ hx: number,
17
+ hy: number,
18
+ hz: number,
19
+ ): Aabb {
20
+ return {
21
+ minX: cx - hx,
22
+ minY: cy - hy,
23
+ minZ: cz - hz,
24
+ maxX: cx + hx,
25
+ maxY: cy + hy,
26
+ maxZ: cz + hz,
27
+ };
28
+ }
@@ -0,0 +1,336 @@
1
+ import type { ConvexShape } from '../shape.ts';
2
+
3
+ /**
4
+ * A body: what it is shaped like, what bounds it presents, and the frame it stands in.
5
+ *
6
+ * Separate from the sweep because these describe a body at rest. `bodyFrame` is here rather
7
+ * than with the sweep, despite being sweep machinery in spirit, because `bodyBounds` calls
8
+ * it — putting it there would make the two files import each other.
9
+ */
10
+
11
+ /** Moving body: center position + half extents. Mutated in place by moveAxis. */
12
+ export interface Body {
13
+ x: number;
14
+ y: number;
15
+ z: number;
16
+ hx: number;
17
+ hy: number;
18
+ hz: number;
19
+ /**
20
+ * Which way is *up* for this body, as a unit vector. Omitted means world up.
21
+ *
22
+ * A body is a box, and a box has an orientation whether or not anything models one.
23
+ * Nothing here did: every volume in this module was world-axis, so a body tilted to
24
+ * lie along a banked deck — which is how a route is drawn and how a character is drawn
25
+ * standing on it — collided as though it were still standing bolt upright. The
26
+ * drawn body then reaches outside its own collision volume by roughly
27
+ * `hy * sin(tilt)`, which on the 41-degree deck where every measured failure sits is
28
+ * about half a metre of body with nothing solid around it: it passes into geometry
29
+ * that looks solid, and geometry that looks clear stops it.
30
+ *
31
+ * Only the up axis, deliberately. It is what a surface has to give (`SurfaceHit`
32
+ * carries a normal, not a frame) and what a body resting on one needs; roll about
33
+ * that axis cannot change a box's world-axis bounds by more than its own
34
+ * cross-section, and inventing a full frame here would mean inventing a heading for
35
+ * bodies that have none. `bodyBounds` treats the remaining freedom conservatively.
36
+ *
37
+ * Two invariants for anything that sets it: it must be **unit length**, because
38
+ * `bodyBounds` trusts that rather than paying a square root per query in the tick;
39
+ * and it must be *set*, not accumulated, so a fixed-timestep replay cannot drift.
40
+ */
41
+ upX?: number;
42
+ upY?: number;
43
+ upZ?: number;
44
+ /**
45
+ * Which way this body *faces*, as a hint. Omitted means +Z, and it is only a hint:
46
+ * `bodyFrame` orthogonalises it against `up`, so a caller with a yaw and a surface
47
+ * normal does not have to reconcile them itself.
48
+ *
49
+ * Matters only for a body whose cross-section is not square — a box that is wider than
50
+ * it is deep collides differently depending on which way it is turned, and that is
51
+ * exactly the "crossing objects with different orientation" case.
52
+ */
53
+ fwdX?: number;
54
+ fwdY?: number;
55
+ fwdZ?: number;
56
+ /**
57
+ * The volume this body really is, in its own frame. Unset means the
58
+ * `hx/hy/hz` box, which is also what the shape's absence has always meant.
59
+ */
60
+ shape?: ConvexShape;
61
+ /**
62
+ * A body made of parts — a character is a torso, a head, limbs and skates, and
63
+ * with parts set it collides as exactly those, wherever its frame tilts
64
+ * them. Takes precedence over `shape`. Set, not accumulated, like `up`.
65
+ */
66
+ parts?: readonly BodyPart[];
67
+ /**
68
+ * Where the frame rotates, as an offset from the centre along local Y.
69
+ * Unset means the centre; a standing character wants its *feet* (`-hy`),
70
+ * because that is where the drawn body pivots when it leans onto a bank —
71
+ * a centre-pivoted tilt swings the feet through the floor and buries a
72
+ * ground-snapped body up to 0.4 m into the very deck it stands on, while a
73
+ * feet-pivoted one keeps them planted and swings the head, exactly like the
74
+ * avatar it collides for. Upright bodies are unaffected bit for bit.
75
+ */
76
+ pivotY?: number;
77
+ /**
78
+ * Whether the contact that stopped the last blocked *downward* `moveAxis`
79
+ * was support — the body arriving on top of a face whose plane is
80
+ * walkably horizontal — as opposed to a graze against a side, an edge or an
81
+ * underside. Written by `moveAxis` on downward Y moves only; `true` when the
82
+ * move was not blocked at all.
83
+ *
84
+ * Exists because "the fall was stopped" and "the body is standing on
85
+ * something" are different facts, and every rest rule that conflated them
86
+ * let a body hang in mid-air by a skate corner kissing a slab's flank —
87
+ * measured live as a character standing over the void beside a drawn pad,
88
+ * half a metre below its top. A caller that never reads it loses nothing:
89
+ * the resolution itself is unchanged, bit for bit.
90
+ */
91
+ contactSupport?: boolean;
92
+ /**
93
+ * The way **off** a face this body is perched on the rim of, as a horizontal unit vector.
94
+ * Zero when the last downward move was supported, was not blocked, or was refused for any
95
+ * other reason.
96
+ *
97
+ * **A rim perch's contact normal points straight up and that is not a mistake** — up is
98
+ * genuinely the way out of the clamp. It is also of no use to a caller trying to get a body
99
+ * off something it has been told it is not standing on: the only direction that helps is
100
+ * sideways, and which sideways is a fact about the obstacle's footprint that nothing outside
101
+ * the sweep can see.
102
+ *
103
+ * Without it a body could be held in clear air indefinitely. `contactSupport` correctly
104
+ * refuses a foot's corner on a nine-centimetre sliver of a slab, so nothing grounds; the
105
+ * blocked fall re-clamps the vertical speed every tick, so gravity never accumulates; and a
106
+ * consumer sliding along the contact normal finds no horizontal component to slide along.
107
+ * Measured on a courtyard pillar: five seconds of held input, zero metres of descent, and the
108
+ * body still exactly where it started.
109
+ */
110
+ contactRimX?: number;
111
+ contactRimZ?: number;
112
+ /**
113
+ * Which way is *out* of whatever clamped the last blocked `moveAxis` — a unit
114
+ * vector pointing from the obstacle toward the body.
115
+ *
116
+ * Only meaningful on a move that was actually blocked (compare the returned
117
+ * distance with the requested one); left as it was otherwise, so a caller reading
118
+ * it after an unobstructed move is reading history.
119
+ *
120
+ * The sweep has always known this and always discarded it. `foldAxis` picks the
121
+ * separating axis that decides the contact and already judges *which side of it*
122
+ * the body is on — that is exactly `contactSupport`, reduced to a boolean about
123
+ * one special case. Keeping the direction instead of the boolean is what lets a
124
+ * caller **slide along** a face rather than stop dead against it, and that
125
+ * distinction turns out to be the difference between a wall and a trap: a body
126
+ * whose every axis is refused has nowhere to go, and axis-separated movement
127
+ * refuses both axes at any corner. Measured on two separate reports as a character
128
+ * held motionless and unable to jump for as long as anyone waited.
129
+ *
130
+ * Written, not accumulated, exactly like `up` — a fixed-timestep replay cannot
131
+ * afford a field that drifts.
132
+ */
133
+ contactNormalX?: number;
134
+ contactNormalY?: number;
135
+ contactNormalZ?: number;
136
+ }
137
+
138
+ /** One piece of a compound body, offset from the body's centre in its own frame. */
139
+ export interface BodyPart {
140
+ shape: ConvexShape;
141
+ ox: number;
142
+ oy: number;
143
+ oz: number;
144
+ }
145
+
146
+ /**
147
+ * World-axis half-extents of a body, accounting for the tilt of its own up axis.
148
+ *
149
+ * The whole point of the exercise: a tilted box needs a bigger axis-aligned box to hold
150
+ * it, and every query in this module is axis-aligned. For an upright body this returns
151
+ * exactly `hx, hy, hz`, so nothing that has not asked for an orientation changes by a
152
+ * floating-point bit.
153
+ *
154
+ * Conservative about the one freedom `Body` does not model. With only an up axis given,
155
+ * the body may be rolled arbitrarily about it, so the two cross-axes are bounded by the
156
+ * larger of the two half-widths — a box is at worst its own diagonal wide, and being
157
+ * generous here costs a little forgiveness at a corner while being mean would cost the
158
+ * penetration this exists to stop.
159
+ *
160
+ * Allocation-free by contract: writes into `out` and returns it (the tick calls this and
161
+ * the tick may not allocate).
162
+ */
163
+ export interface BodyBounds {
164
+ hx: number;
165
+ hy: number;
166
+ hz: number;
167
+ }
168
+
169
+ export function createBodyBounds(): BodyBounds {
170
+ return { hx: 0, hy: 0, hz: 0 };
171
+ }
172
+
173
+ export function bodyBounds(body: Body, out: BodyBounds): BodyBounds {
174
+ /*
175
+ * A shaped body is bounded by its enclosing radius, whichever way it turns.
176
+ * Broad phase only — over-asking costs a candidate test, under-asking costs
177
+ * a body through a wall — and it saves re-deriving oriented extents for a
178
+ * volume the narrow phase is about to resolve exactly anyway.
179
+ */
180
+ if (body.parts !== undefined) {
181
+ /*
182
+ * The union of each part's ball, placed where the frame and pivot really
183
+ * put it. A single enclosing sphere was measured 2.5 m across for a
184
+ * feet-pivoted character — every query dragged in half the junction — while
185
+ * this stays within centimetres of the true extents.
186
+ */
187
+ const f = bodyFrame(body, boundsFrame);
188
+ const pivotY = body.pivotY ?? 0;
189
+ out.hx = 0;
190
+ out.hy = 0;
191
+ out.hz = 0;
192
+ for (let i = 0; i < body.parts.length; i++) {
193
+ const part = body.parts[i];
194
+ if (part === undefined) continue;
195
+ const oy = part.oy - pivotY;
196
+ const cx = f.rx * part.ox + f.ux * oy + f.fx * part.oz;
197
+ const cy = pivotY + f.ry * part.ox + f.uy * oy + f.fy * part.oz;
198
+ const cz = f.rz * part.ox + f.uz * oy + f.fz * part.oz;
199
+ const r = part.shape.boundRadius;
200
+ if (Math.abs(cx) + r > out.hx) out.hx = Math.abs(cx) + r;
201
+ if (Math.abs(cy) + r > out.hy) out.hy = Math.abs(cy) + r;
202
+ if (Math.abs(cz) + r > out.hz) out.hz = Math.abs(cz) + r;
203
+ }
204
+ return out;
205
+ }
206
+ if (body.shape !== undefined) {
207
+ const radius = body.shape.boundRadius + Math.abs(body.pivotY ?? 0) * 2;
208
+ out.hx = radius;
209
+ out.hy = radius;
210
+ out.hz = radius;
211
+ return out;
212
+ }
213
+ const ux = body.upX ?? 0;
214
+ const uy = body.upY ?? 1;
215
+ const uz = body.upZ ?? 0;
216
+ // Upright, which is almost every body almost always: the exact old numbers, no work.
217
+ if (ux === 0 && uz === 0 && uy > 0) {
218
+ out.hx = body.hx;
219
+ out.hy = body.hy;
220
+ out.hz = body.hz;
221
+ return out;
222
+ }
223
+ /*
224
+ * The up axis contributes `hy * |component|` to each world axis. The two cross-axes
225
+ * span the plane perpendicular to it, and their contribution to a world axis is the
226
+ * length of that axis's projection onto that plane — `sqrt(1 - component^2)` — times
227
+ * the wider of the two half-widths, since roll about `up` is unmodelled and may point
228
+ * either of them anywhere in the plane.
229
+ */
230
+ const across = Math.max(body.hx, body.hz);
231
+ out.hx = Math.abs(ux) * body.hy + Math.sqrt(Math.max(0, 1 - ux * ux)) * across;
232
+ out.hy = Math.abs(uy) * body.hy + Math.sqrt(Math.max(0, 1 - uy * uy)) * across;
233
+ out.hz = Math.abs(uz) * body.hy + Math.sqrt(Math.max(0, 1 - uz * uz)) * across;
234
+ return out;
235
+ }
236
+
237
+ export const AXIS_X = 0;
238
+ export const AXIS_Y = 1;
239
+ export const AXIS_Z = 2;
240
+ export type Axis = typeof AXIS_X | typeof AXIS_Y | typeof AXIS_Z;
241
+
242
+ /**
243
+ * A body's orientation as three orthonormal axes, written out so the tick allocates nothing.
244
+ *
245
+ * Built from the body's `up` and, where it has one, its `forward`. Roll about `up` is what
246
+ * `forward` supplies; without it any perpendicular will do, because a box whose cross-section
247
+ * is square (`hx === hz`) is unchanged by roll and one that is not has no heading to honour.
248
+ */
249
+ export interface BodyFrame {
250
+ /** Across the body, to its right. */
251
+ rx: number;
252
+ ry: number;
253
+ rz: number;
254
+ /** The body's own up. */
255
+ ux: number;
256
+ uy: number;
257
+ uz: number;
258
+ /** The body's own forward. */
259
+ fx: number;
260
+ fy: number;
261
+ fz: number;
262
+ /** False where the frame is the world's, so callers can take the cheap path. */
263
+ tilted: boolean;
264
+ }
265
+
266
+ export function createBodyFrame(): BodyFrame {
267
+ return { rx: 1, ry: 0, rz: 0, ux: 0, uy: 1, uz: 0, fx: 0, fy: 0, fz: 1, tilted: false };
268
+ }
269
+
270
+ /**
271
+ * Resolve a body's orientation into an orthonormal frame.
272
+ *
273
+ * Gram-Schmidt against `up`, so `forward` is a *hint* about heading rather than a constraint
274
+ * that has to arrive already perpendicular — a caller with a yaw and a surface normal has no
275
+ * cheap way to make those two agree, and demanding it would push the arithmetic into every
276
+ * call site.
277
+ */
278
+ export function bodyFrame(body: Body, out: BodyFrame): BodyFrame {
279
+ const ux = body.upX ?? 0;
280
+ const uy = body.upY ?? 1;
281
+ const uz = body.upZ ?? 0;
282
+ if (ux === 0 && uz === 0 && uy > 0 && body.fwdX === undefined) {
283
+ out.rx = 1;
284
+ out.ry = 0;
285
+ out.rz = 0;
286
+ out.ux = 0;
287
+ out.uy = 1;
288
+ out.uz = 0;
289
+ out.fx = 0;
290
+ out.fy = 0;
291
+ out.fz = 1;
292
+ out.tilted = false;
293
+ return out;
294
+ }
295
+ const ulen = Math.sqrt(ux * ux + uy * uy + uz * uz) || 1;
296
+ out.ux = ux / ulen;
297
+ out.uy = uy / ulen;
298
+ out.uz = uz / ulen;
299
+
300
+ // A heading hint, or any direction not parallel to up.
301
+ let fx = body.fwdX ?? 0;
302
+ let fy = body.fwdY ?? 0;
303
+ let fz = body.fwdZ ?? 1;
304
+ // Remove the part along up, which is what makes the result orthonormal.
305
+ const along = fx * out.ux + fy * out.uy + fz * out.uz;
306
+ fx -= out.ux * along;
307
+ fy -= out.uy * along;
308
+ fz -= out.uz * along;
309
+ let flen = Math.sqrt(fx * fx + fy * fy + fz * fz);
310
+ if (flen < 1e-6) {
311
+ // The hint was parallel to up: any perpendicular will do, so take the world axis
312
+ // that up leans on least.
313
+ if (Math.abs(out.ux) < 0.9) {
314
+ fx = 1 - out.ux * out.ux;
315
+ fy = -out.uy * out.ux;
316
+ fz = -out.uz * out.ux;
317
+ } else {
318
+ fx = -out.ux * out.uz;
319
+ fy = -out.uy * out.uz;
320
+ fz = 1 - out.uz * out.uz;
321
+ }
322
+ flen = Math.sqrt(fx * fx + fy * fy + fz * fz) || 1;
323
+ }
324
+ out.fx = fx / flen;
325
+ out.fy = fy / flen;
326
+ out.fz = fz / flen;
327
+ // right = forward x up, so the three form a consistent hand.
328
+ out.rx = out.fy * out.uz - out.fz * out.uy;
329
+ out.ry = out.fz * out.ux - out.fx * out.uz;
330
+ out.rz = out.fx * out.uy - out.fy * out.ux;
331
+ out.tilted = true;
332
+ return out;
333
+ }
334
+
335
+ /** Scratch for `bodyBounds`; the tick may not allocate. */
336
+ const boundsFrame = createBodyFrame();
@@ -0,0 +1,156 @@
1
+ import type { Aabb } from './aabb.ts';
2
+ import type { ColliderSet } from '../colliderSet.ts';
3
+ import type { Body } from './body.ts';
4
+
5
+ /**
6
+ * Push a body out of solid geometry it has ended up **inside**.
7
+ *
8
+ * **This is not collision, and that distinction is the whole of why it exists.** `moveAxis`
9
+ * resolves a move against what is in the way, and it is a *swept* test: it asks how far a body
10
+ * may travel before it touches something. A body that is already interpenetrating has nothing
11
+ * left to sweep against — both of the plain path's clamps test a face the body is already past,
12
+ * so neither fires, and an overlapping body moves **freely**. That is the mechanism behind the
13
+ * complaint that a character "sometimes passes through" a surface: one frame inside a column and
14
+ * the column has stopped existing for as long as the body remains in it.
15
+ *
16
+ * It is also why such a body can stand in mid-air inside a slab and be told it is grounded:
17
+ * nothing clamped the downward move, so `contactSupport` keeps the `true` it starts with, and
18
+ * every backstop a consumer has — a wedge counter, a refused-move flag — is waiting for the
19
+ * world to push back, which it has stopped doing.
20
+ *
21
+ * **Gated on the body's centre, not on overlap.** Overlap on its own is ordinary and expected:
22
+ * a deck's hull sits a give below the surface a character rides, so a grounded body's feet are
23
+ * inside it by design, and a pad's box is sunk under the pad. What none of those produce is a
24
+ * *centre* inside the box — a standing body's centre is half its height above its feet. So the
25
+ * centre test separates riding from burial exactly, and this can be run every tick without ever
26
+ * firing on a body that is merely resting on something.
27
+ *
28
+ * **Hulls are skipped, deliberately.** A hull collider's box is its broad phase and its real
29
+ * volume is the convex shape inside it, so a centre inside the box says nothing about whether
30
+ * the body is inside the *hull*. Pushing on that evidence would move bodies that are simply
31
+ * near a deck. What this covers is every plain collider — the columns, the props, the pads,
32
+ * the arch feet — which is where the reported pass-throughs were captured.
33
+ *
34
+ * The push is the shortest way out and it is applied whole: half a resolution leaves the body
35
+ * inside, which is the state this exists to end. Returns true when something moved, so a caller
36
+ * can treat it as the event it is rather than polling.
37
+ *
38
+ * Allocation-free: the caller owns `scratch` and `probe`, as everywhere else in this module.
39
+ */
40
+ export function ejectFromSolid(
41
+ body: Body,
42
+ colliders: ColliderSet,
43
+ scratch: Int32Array,
44
+ probe: Aabb,
45
+ ): boolean {
46
+ const found = colliders.query(
47
+ body.x - body.hx,
48
+ body.y - body.hy,
49
+ body.z - body.hz,
50
+ body.x + body.hx,
51
+ body.y + body.hy,
52
+ body.z + body.hz,
53
+ scratch,
54
+ );
55
+
56
+ /*
57
+ * How far each way out has to go to clear **everything** at once, which is the whole reason
58
+ * this is two passes rather than a push per collider.
59
+ *
60
+ * Resolved one at a time it oscillates, and the case that shows it is the one this was
61
+ * measured on: a body 0.70 m wide between two columns 0.41 m apart overlaps both, and each
62
+ * one's own shortest exit is sideways into the other. It would be shoved back and forth for
63
+ * ever and never leave. Asking every candidate direction how far it would have to go to clear
64
+ * the lot, and then taking the cheapest, picks the way the body came in — the only way out
65
+ * there actually is.
66
+ */
67
+ let outXPlus = 0;
68
+ let outXMinus = 0;
69
+ let outZPlus = 0;
70
+ let outZMinus = 0;
71
+ let outYPlus = 0;
72
+ let buried = false;
73
+
74
+ for (let k = 0; k < found; k++) {
75
+ const index = scratch[k] as number;
76
+ /* A hull's box is broad phase only. See the note above. */
77
+ if (colliders.shapeAt(index) !== undefined) continue;
78
+ colliders.bounds(index, probe);
79
+
80
+ /*
81
+ * Vertically inside, and horizontally overlapping. The vertical half is what separates
82
+ * burial from the give every deck and pad is authored with: a collider is sunk under the
83
+ * surface it draws, so a character standing on one has its feet in the box by design and its
84
+ * centre half a body above the box's lid. A centre *between* the lid and the floor is a
85
+ * body in the solid rather than on it.
86
+ */
87
+ if (body.y <= probe.minY || body.y >= probe.maxY) continue;
88
+ if (body.x + body.hx <= probe.minX || body.x - body.hx >= probe.maxX) continue;
89
+ if (body.z + body.hz <= probe.minZ || body.z - body.hz >= probe.maxZ) continue;
90
+
91
+ /*
92
+ * **Overlapping is not the same as being in it, and only the second arms this.**
93
+ *
94
+ * A skate corner clips the flank of a slab in passing, and a body falling past a ledge
95
+ * shares a couple of centimetres with it for a tick. Ejecting on that fights gravity: the
96
+ * upward preference below lifts the body back over whatever it was falling past, which is a
97
+ * hover rather than a rescue. Measured on the rim capture as 1.77 s of it, against 0.20 s
98
+ * with this gate in place.
99
+ *
100
+ * So a collider has to hold more than a quarter of the body's width on **both** horizontal
101
+ * axes before it counts as one the body is inside. Expressed against the body's own extents
102
+ * rather than in metres, because what "barely touching" means is a fact about the body.
103
+ */
104
+ const heldX = Math.min(body.x + body.hx, probe.maxX) - Math.max(body.x - body.hx, probe.minX);
105
+ const heldZ = Math.min(body.z + body.hz, probe.maxZ) - Math.max(body.z - body.hz, probe.minZ);
106
+ if (heldX > body.hx * 0.5 && heldZ > body.hz * 0.5) buried = true;
107
+
108
+ outXPlus = Math.max(outXPlus, probe.maxX - (body.x - body.hx));
109
+ outXMinus = Math.max(outXMinus, body.x + body.hx - probe.minX);
110
+ outZPlus = Math.max(outZPlus, probe.maxZ - (body.z - body.hz));
111
+ outZMinus = Math.max(outZMinus, body.z + body.hz - probe.minZ);
112
+ outYPlus = Math.max(outYPlus, probe.maxY - (body.y - body.hy));
113
+ }
114
+ if (!buried) return false;
115
+
116
+ /*
117
+ * Up wins a tie and wins a near-tie, which is a decision about characters rather than about
118
+ * geometry: a body pushed sideways out of a column arrives beside it in mid-air, and a body
119
+ * pushed up arrives on top of it. Only when up is genuinely the long way round does a
120
+ * sideways exit win.
121
+ */
122
+ let bestAxis = 1;
123
+ let bestPush = outYPlus / UP_PREFERENCE;
124
+ if (outXPlus < bestPush) {
125
+ bestPush = outXPlus;
126
+ bestAxis = 2;
127
+ }
128
+ if (outXMinus < bestPush) {
129
+ bestPush = outXMinus;
130
+ bestAxis = 3;
131
+ }
132
+ if (outZPlus < bestPush) {
133
+ bestPush = outZPlus;
134
+ bestAxis = 4;
135
+ }
136
+ if (outZMinus < bestPush) {
137
+ bestPush = outZMinus;
138
+ bestAxis = 5;
139
+ }
140
+
141
+ if (bestAxis === 1) body.y += outYPlus;
142
+ else if (bestAxis === 2) body.x += outXPlus;
143
+ else if (bestAxis === 3) body.x -= outXMinus;
144
+ else if (bestAxis === 4) body.z += outZPlus;
145
+ else body.z -= outZMinus;
146
+ return true;
147
+ }
148
+
149
+ /**
150
+ * How much longer the vertical exit may be than the shortest horizontal one and still be taken.
151
+ *
152
+ * A body ejected sideways out of a column lands beside it with nothing under it, which reads as
153
+ * being spat into the void; ejected upward it lands on top, which reads as climbing out. Worth
154
+ * up to twice the distance to get the second.
155
+ */
156
+ const UP_PREFERENCE = 2;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Axis-separated collision, in four parts.
3
+ *
4
+ * Split from one 1,064-line file along the concerns its sixteen exports already described:
5
+ * an axis-aligned box and the arithmetic on it, a body and the bounds it presents, the sweep
6
+ * that resolves movement one axis at a time, and the segment test that answers what a ray
7
+ * hits.
8
+ *
9
+ * Everything the single file exported is re-exported here, so `from './collide'` resolves
10
+ * exactly as it did and no caller moved.
11
+ */
12
+ export type { Aabb } from './aabb.ts';
13
+ export { aabbFromCenter } from './aabb.ts';
14
+ export type { Axis, Body, BodyBounds, BodyFrame, BodyPart } from './body.ts';
15
+ export {
16
+ AXIS_X,
17
+ AXIS_Y,
18
+ AXIS_Z,
19
+ bodyBounds,
20
+ bodyFrame,
21
+ createBodyBounds,
22
+ createBodyFrame,
23
+ } from './body.ts';
24
+ export { ejectFromSolid } from './eject.ts';
25
+ export { moveAxis } from './sweep.ts';
26
+ export { segmentHit } from './segment.ts';
@@ -0,0 +1,10 @@
1
+ import { ColliderSet } from '../colliderSet.ts';
2
+
3
+ /**
4
+ * The index buffer a `ColliderSet` query writes into.
5
+ *
6
+ * Shared by the sweep and the segment test rather than duplicated, because it was one buffer
7
+ * in the file these were split from and neither caller is re-entrant. Giving each its own
8
+ * would be a behaviour change smuggled into a move.
9
+ */
10
+ export const hits = new Int32Array(ColliderSet.MAX_HITS);
@@ -0,0 +1,127 @@
1
+ import { ColliderSet } from '../colliderSet.ts';
2
+ import type { Aabb } from './aabb.ts';
3
+ import { hits } from './scratch.ts';
4
+
5
+ /** What a ray hits: a segment tested against a collider set. */
6
+
7
+ /** Below this, a slab is treated as parallel to the segment. */
8
+ const PARALLEL_EPSILON = 1e-8;
9
+
10
+ /**
11
+ * Nearest obstruction along the segment `origin → origin + delta`, returned as
12
+ * a fraction of the segment in [0, 1] — 1 meaning unobstructed.
13
+ *
14
+ * Boxes are expanded by `radius`, which approximates sweeping a sphere: the
15
+ * caller (the third-person camera boom) then stops clear of surfaces instead of
16
+ * grazing them. Allocation-free; slab method, one pass over the boxes.
17
+ *
18
+ * Render-side only. Nothing here may be called from `simulate()`.
19
+ */
20
+ export function segmentHit(
21
+ ox: number,
22
+ oy: number,
23
+ oz: number,
24
+ dx: number,
25
+ dy: number,
26
+ dz: number,
27
+ boxes: ColliderSet,
28
+ radius: number,
29
+ ): number {
30
+ let nearest = 1;
31
+
32
+ const count = boxes.query(
33
+ Math.min(ox, ox + dx) - radius,
34
+ Math.min(oy, oy + dy) - radius,
35
+ Math.min(oz, oz + dz) - radius,
36
+ Math.max(ox, ox + dx) + radius,
37
+ Math.max(oy, oy + dy) + radius,
38
+ Math.max(oz, oz + dz) + radius,
39
+ hits,
40
+ );
41
+ const data = boxes.data;
42
+ // Support of the contact that finally clamps the move; true when unblocked.
43
+ let clampSupport = true;
44
+
45
+ for (let h = 0; h < count; h++) {
46
+ const index = hits[h] ?? 0;
47
+ const o = index * 6;
48
+ let tmin = 0;
49
+ let tmax = nearest;
50
+ let hit = true;
51
+
52
+ /*
53
+ * A shaped collider is clipped against its own face planes instead of its
54
+ * box's. The box is only the broad phase, and treating it as solid here is
55
+ * what made the camera pull in over the empty corner of every banked
56
+ * stretch, and a grapple anchor on drawn air. Each face direction bounds
57
+ * the shape as a slab of its vertex projections; a hull with fewer than
58
+ * three face lines cannot be bounded that way and keeps the box answer.
59
+ */
60
+ const shape = boxes.shapeAt(index);
61
+ if (shape !== undefined && shape.faceNormals.length >= 9) {
62
+ const fn = shape.faceNormals;
63
+ const v = shape.vertices;
64
+ for (let f = 0; f < fn.length && hit; f += 3) {
65
+ const nx = fn[f] ?? 0;
66
+ const ny = fn[f + 1] ?? 0;
67
+ const nz = fn[f + 2] ?? 0;
68
+ let lo = Infinity;
69
+ let hi = -Infinity;
70
+ for (let i = 0; i < v.length; i += 3) {
71
+ const p = (v[i] ?? 0) * nx + (v[i + 1] ?? 0) * ny + (v[i + 2] ?? 0) * nz;
72
+ if (p < lo) lo = p;
73
+ if (p > hi) hi = p;
74
+ }
75
+ lo -= shape.radius + radius;
76
+ hi += shape.radius + radius;
77
+ const at = ox * nx + oy * ny + oz * nz;
78
+ const rate = dx * nx + dy * ny + dz * nz;
79
+
80
+ if (rate > -PARALLEL_EPSILON && rate < PARALLEL_EPSILON) {
81
+ if (at < lo || at > hi) hit = false;
82
+ continue;
83
+ }
84
+ let t1 = (lo - at) / rate;
85
+ let t2 = (hi - at) / rate;
86
+ if (t1 > t2) {
87
+ const swap = t1;
88
+ t1 = t2;
89
+ t2 = swap;
90
+ }
91
+ if (t1 > tmin) tmin = t1;
92
+ if (t2 < tmax) tmax = t2;
93
+ if (tmin > tmax) hit = false;
94
+ }
95
+ if (hit && tmin < nearest) nearest = tmin;
96
+ continue;
97
+ }
98
+
99
+ for (let axis = 0; axis < 3 && hit; axis++) {
100
+ const at = axis === 0 ? ox : axis === 1 ? oy : oz;
101
+ const d = axis === 0 ? dx : axis === 1 ? dy : dz;
102
+ const lo = (data[o + axis] ?? 0) - radius;
103
+ const hi = (data[o + 3 + axis] ?? 0) + radius;
104
+
105
+ if (d > -PARALLEL_EPSILON && d < PARALLEL_EPSILON) {
106
+ // Parallel to this slab: a miss unless the origin already lies inside.
107
+ if (at < lo || at > hi) hit = false;
108
+ continue;
109
+ }
110
+
111
+ let t1 = (lo - at) / d;
112
+ let t2 = (hi - at) / d;
113
+ if (t1 > t2) {
114
+ const swap = t1;
115
+ t1 = t2;
116
+ t2 = swap;
117
+ }
118
+ if (t1 > tmin) tmin = t1;
119
+ if (t2 < tmax) tmax = t2;
120
+ if (tmin > tmax) hit = false;
121
+ }
122
+
123
+ if (hit && tmin < nearest) nearest = tmin;
124
+ }
125
+
126
+ return nearest;
127
+ }