@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
package/src/shape.ts ADDED
@@ -0,0 +1,656 @@
1
+ import type { Aabb } from './collide/index.ts';
2
+ import type { TriangleMesh } from './meshShape.ts';
3
+
4
+ /**
5
+ * Convex collision shapes: the volume an element actually occupies, rather than
6
+ * a world-axis box around it.
7
+ *
8
+ * One representation covers every convex solid — a point cloud plus two kinds of
9
+ * rounding. A box is its eight corners; a sphere is one point and a radius; a
10
+ * stretch of banked track is its own drawn cross-sections, so the geometry a
11
+ * player sees is the geometry that stops them. Face normals and edge directions
12
+ * are enumerated once, at build time, because they are the separating-axis
13
+ * candidates the sweep in `collide.ts` tests — a fixed, data-driven axis list
14
+ * per shape pair is what lets two machines replay the same run bit for bit,
15
+ * where an iterate-to-tolerance method would not.
16
+ *
17
+ * **Two kinds of rounding rather than one, as of 2026-08-27.** `radius` grows a
18
+ * shape by a ball and is what makes a sphere and a capsule; `sideRadius` grows
19
+ * it by a disc perpendicular to the segment through its first two points, and is
20
+ * what makes a cylinder. The second exists because the first cannot express a
21
+ * cylinder at all: a uniform radius rounds the rim along with the side, and the
22
+ * rim is what a wheel rolls on. Everything that consumes a shape reads both, and
23
+ * a shape carrying neither is a plain polytope, which is most of them.
24
+ *
25
+ * Build-time only: nothing here may be called per frame or per tick. The hull
26
+ * builder is O(n^4) in the point count — trivial for the intended n (a slab is
27
+ * 8–16 points, a body part 8), wrong for a render mesh, hence the cap. Shapes
28
+ * come from the few points that *define* an element, not from its triangles.
29
+ */
30
+
31
+ export interface ConvexShape {
32
+ /**
33
+ * xyz-packed points whose convex hull is the shape. Local space for a body's
34
+ * shape, world space for a static collider's — the sweep never needs to know
35
+ * which, because bodies carry a frame and colliders are baked.
36
+ */
37
+ readonly vertices: Float32Array;
38
+ /** xyz-packed unit face normals, one per direction (a line: n and −n once). */
39
+ readonly faceNormals: Float32Array;
40
+ /** xyz-packed unit edge directions, one per direction. */
41
+ readonly edgeDirs: Float32Array;
42
+ /** Rounding grown around the points — a sphere is one point and this. */
43
+ readonly radius: number;
44
+ /**
45
+ * Rounding grown around the points **only perpendicular to the segment through them**, in
46
+ * metres. Zero for everything but a cylinder, which is what it exists for.
47
+ *
48
+ * `radius` grows a shape uniformly, which is why it cannot express a cylinder: it would round
49
+ * the rim as well as the side. This one grows a shape by a disc rather than by a ball, so the
50
+ * two points of `cylinderShape` become two flat cap discs joined by a curved side, with a sharp
51
+ * rim between them. The two are independent and compose — a shape with both is a cylinder with
52
+ * a filleted rim — and every constructor but `cylinderShape` leaves this at zero.
53
+ *
54
+ * The axis is the shape's own first two points, not a stored vector, so a cylinder baked into
55
+ * world space by a collider carries its orientation the same way its position is carried.
56
+ */
57
+ readonly sideRadius: number;
58
+ /** Distance from the local origin enclosing the whole shape, for broad bounds. */
59
+ readonly boundRadius: number;
60
+ /**
61
+ * xyzw-packed outward face planes, four floats per face: normal then offset, so a point `p` lies
62
+ * on the face when `dot(n, p) === d`.
63
+ *
64
+ * **One entry per face, where `faceNormals` has one per direction.** A box has six here and three
65
+ * there, and they are separate arrays because conflating them is what left the gap: SAT wants
66
+ * directions, while clipping and an inertia tensor want faces. Empty for a shape with no faces.
67
+ */
68
+ readonly facePlanes: Float32Array;
69
+ /** CSR offsets into `faceVertexIndices`, length `faceCount + 1`. Empty when there are no faces. */
70
+ readonly faceVertexStart: Uint16Array;
71
+ /** Vertex indices, each face's loop wound counter-clockwise seen from outside. */
72
+ readonly faceVertexIndices: Uint16Array;
73
+ /**
74
+ * A **static triangle mesh**, where this shape is one, or absent where it is a convex solid.
75
+ *
76
+ * **The one thing in this file that is not convex, and it is a field rather than a second type
77
+ * on purpose.** A union would put a narrowing at every call site that takes a shape — the broad
78
+ * phase, the bounds, the collider set, the sweep, the mass, the queries, the cloth — for a shape
79
+ * only the narrow phase and the raycast can act on, and every one of those narrowings would be a
80
+ * place to forget one. A discriminating field is what `sideRadius` already is, one line up, and
81
+ * this reads the same way: the shape carries what it is and the two places that care ask.
82
+ *
83
+ * A mesh's `vertices` are the eight corners of its bounding box, so everything that measures a
84
+ * shape measures the right box; its `faceNormals` and `edgeDirs` are **empty**, so nothing
85
+ * mistakes a hollow level for a solid brick. See `meshShape`.
86
+ */
87
+ readonly triangles?: TriangleMesh;
88
+ }
89
+
90
+ /** What a shape with no faces carries: a sphere, a capsule, or any cloud too flat to bound one. */
91
+ const EMPTY_FACES = {
92
+ facePlanes: new Float32Array(0),
93
+ faceVertexStart: new Uint16Array(0),
94
+ faceVertexIndices: new Uint16Array(0),
95
+ };
96
+
97
+ /**
98
+ * Hard cap on hull input size. The builder is O(n^4); at 64 points that is a
99
+ * few million build-time operations, and anything larger is a sign the caller
100
+ * is feeding it a render mesh instead of an element's defining points.
101
+ */
102
+ const MAX_POINTS = 64;
103
+ /** Points closer than this are the same point. */
104
+ const POINT_EPS = 1e-6;
105
+ /** Distance within which a point counts as lying on a plane, metres. */
106
+ const COPLANAR_M = 1e-4;
107
+ /** Dot product above which two directions are the same line. */
108
+ const DIR_DEDUP = 1 - 1e-7;
109
+
110
+ /** The exact shape `MeshBuilder.addBox` draws: eight corners, three axes. */
111
+ export function boxShape(hx: number, hy: number, hz: number): ConvexShape {
112
+ const vertices = new Float32Array(24);
113
+ let v = 0;
114
+ for (let i = 0; i < 8; i++) {
115
+ vertices[v++] = i & 1 ? hx : -hx;
116
+ vertices[v++] = i & 2 ? hy : -hy;
117
+ vertices[v++] = i & 4 ? hz : -hz;
118
+ }
119
+ const axes = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]);
120
+ /*
121
+ * Six faces, where `axes` above is three directions. Corner `i` has bit 0 for +x, bit 1 for +y
122
+ * and bit 2 for +z, so each face is the four corners sharing one bit, listed counter-clockwise
123
+ * seen from outside. Written out rather than derived because the loop that derives it is longer
124
+ * than the table and this is build-time code that runs once per shape.
125
+ */
126
+ const facePlanes = new Float32Array([
127
+ 1,
128
+ 0,
129
+ 0,
130
+ hx,
131
+ -1,
132
+ 0,
133
+ 0,
134
+ hx,
135
+ 0,
136
+ 1,
137
+ 0,
138
+ hy,
139
+ 0,
140
+ -1,
141
+ 0,
142
+ hy,
143
+ 0,
144
+ 0,
145
+ 1,
146
+ hz,
147
+ 0,
148
+ 0,
149
+ -1,
150
+ hz,
151
+ ]);
152
+ const faceVertexIndices = new Uint16Array([
153
+ 1, 3, 7, 5, 4, 6, 2, 0, 2, 6, 7, 3, 0, 1, 5, 4, 4, 5, 7, 6, 0, 2, 3, 1,
154
+ ]);
155
+ const faceVertexStart = new Uint16Array([0, 4, 8, 12, 16, 20, 24]);
156
+ return {
157
+ vertices,
158
+ faceNormals: axes,
159
+ edgeDirs: axes,
160
+ radius: 0,
161
+ sideRadius: 0,
162
+ boundRadius: Math.sqrt(hx * hx + hy * hy + hz * hz),
163
+ facePlanes,
164
+ faceVertexStart,
165
+ faceVertexIndices,
166
+ };
167
+ }
168
+
169
+ /** A ball: one point, all radius. It separates on other shapes' features. */
170
+ export function sphereShape(radius: number): ConvexShape {
171
+ return {
172
+ vertices: new Float32Array(3),
173
+ faceNormals: new Float32Array(0),
174
+ edgeDirs: new Float32Array(0),
175
+ radius,
176
+ sideRadius: 0,
177
+ boundRadius: radius,
178
+ ...EMPTY_FACES,
179
+ };
180
+ }
181
+
182
+ /**
183
+ * A capsule: a segment along y with a rounding radius.
184
+ *
185
+ * **No new representation.** Point-cloud-plus-radius already expresses a capsule exactly, so this
186
+ * is a named constructor over `hullShape` rather than a shape type. A named thing is discoverable
187
+ * and an idiom is not. It stands along y because that is the axis a character capsule uses, and a
188
+ * rotation nobody has to apply is a rotation nobody gets wrong.
189
+ *
190
+ * At `halfHeight` zero the two points dedupe to one and the result *is* a sphere, which is the
191
+ * right degenerate case rather than an error.
192
+ *
193
+ * **A cylinder is a different shape and has its own constructor**, `cylinderShape` below. The
194
+ * rounding radius here grows uniformly around every feature, so applying it to a segment can only
195
+ * ever give a capsule: it would round a cylinder's rim as well as its side. `sideRadius` is the
196
+ * field that expresses the difference, and it was added on 2026-08-27 with the second condition
197
+ * this file had been recording — a wheel rim, a rolling barrel — as its reason.
198
+ *
199
+ * The advice until then was "build one as an n-gon prism with `hullShape` and accept the facets",
200
+ * and it had a measured ceiling: `hullShape` refuses more than 64 points, a prism spends two a
201
+ * side, so thirty-two sides was the wall, and a thirty-two-sided wheel of radius 1 rolling at
202
+ * 6 m/s bobbed 16 mm that no side count could reduce. `prismRoll.test.ts` still measures that,
203
+ * beside the same wheel built as a cylinder, because the comparison is the reason the shape kind
204
+ * exists.
205
+ */
206
+ export function capsuleShape(radius: number, halfHeight: number): ConvexShape {
207
+ if (!(radius > 0)) throw new Error(`capsuleShape: radius must be positive, got ${radius}`);
208
+ if (!(halfHeight >= 0)) {
209
+ throw new Error(`capsuleShape: halfHeight must not be negative, got ${halfHeight}`);
210
+ }
211
+ return hullShape([0, -halfHeight, 0, 0, halfHeight, 0], radius);
212
+ }
213
+
214
+ /**
215
+ * A cylinder: a segment along y, grown by a disc rather than by a ball.
216
+ *
217
+ * **The one shape a point cloud plus a rounding radius cannot express**, and the reason it needed
218
+ * a second kind of rounding rather than a second shape type. `radius` grows a shape uniformly, so
219
+ * applying it to a segment gives a capsule; `sideRadius` grows it only perpendicular to that
220
+ * segment, which gives two flat cap discs, a curved side, and a sharp rim between them. That rim
221
+ * is the whole difference, and it is what a wheel rolls on.
222
+ *
223
+ * **What this replaces is an n-gon prism with a measured floor.** `prismRoll.test.ts` had the
224
+ * numbers: `hullShape` refuses more than 64 points, a prism spends two a side, so thirty-two sides
225
+ * was the wall, and a thirty-two-sided wheel of radius 1 rolling at 6 m/s bobbed **16 mm** — which
226
+ * no side count could reduce. A cylinder's support along any direction perpendicular to its axis
227
+ * is exactly `sideRadius`, at every angle, so the same wheel bobs by nothing at all. The same test
228
+ * file now measures both and compares them.
229
+ *
230
+ * It stands along y for `capsuleShape`'s reason: a rotation nobody has to apply is a rotation
231
+ * nobody gets wrong. A wheel wants its axis across the vehicle, and the body carries a quaternion
232
+ * for exactly that. A static collider baked into world space carries its axis in its own two
233
+ * points, so nothing stores a direction that could disagree with them.
234
+ *
235
+ * **`halfHeight` must be positive**, unlike `capsuleShape`, whose zero case degenerates into a
236
+ * sphere and is right. A cylinder of zero height is a disc: a shape with no interior, whose mass
237
+ * properties are zero and whose contact normal is undefined on its rim. Refused rather than
238
+ * silently produced.
239
+ */
240
+ export function cylinderShape(radius: number, halfHeight: number): ConvexShape {
241
+ if (!(radius > 0)) throw new Error(`cylinderShape: radius must be positive, got ${radius}`);
242
+ if (!(halfHeight > 0)) {
243
+ throw new Error(`cylinderShape: halfHeight must be positive, got ${halfHeight}`);
244
+ }
245
+ /*
246
+ * One axis in `faceNormals` and in `edgeDirs`, which is the honest answer for both: the caps are
247
+ * the only planar faces a cylinder has, and they share one direction, while the axis is the only
248
+ * straight edge direction on it. The curved side has neither, and the contact code reaches it
249
+ * through `sideRadius` rather than through an enumerated axis — which is the point of the field.
250
+ */
251
+ const axis = new Float32Array([0, 1, 0]);
252
+ return {
253
+ vertices: new Float32Array([0, -halfHeight, 0, 0, halfHeight, 0]),
254
+ faceNormals: axis,
255
+ edgeDirs: axis,
256
+ radius: 0,
257
+ sideRadius: radius,
258
+ boundRadius: Math.sqrt(halfHeight * halfHeight + radius * radius),
259
+ ...EMPTY_FACES,
260
+ };
261
+ }
262
+
263
+ /**
264
+ * The convex hull of a point cloud, with its separating features enumerated.
265
+ *
266
+ * Brute force by design: every point triple proposes a plane, and a plane with
267
+ * every point on one side is a face. Edges are point pairs lying on two
268
+ * distinct faces. No incremental hull, no floating-point pivoting — the same
269
+ * input yields the same features in the same order on every machine, which the
270
+ * replay contract needs from anything that feeds collision.
271
+ *
272
+ * **Every length here is `Math.sqrt` of a sum of squares rather than `Math.hypot`**, and that is
273
+ * what makes the sentence above true rather than merely intended. ECMAScript declines to specify
274
+ * `hypot` precisely, so two engines may differ by an ulp — and an ulp here is not a slightly
275
+ * different axis. `proposePlane`'s normal classifies every point against `COPLANAR_M`, so it
276
+ * decides whether a plane becomes a face at all; `appendDirection`'s is compared against
277
+ * `DIR_DEDUP`, so it decides how many separating axes exist and in what order. SAT then takes a
278
+ * minimum over that list.
279
+ *
280
+ * `hypot` is the more accurate of the two, guarding against intermediate overflow at magnitudes a
281
+ * collision shape does not reach. **What would make this wrong** is a shape built at astronomical
282
+ * or atomic scale, where that guard is worth more than the reproducibility; the answer then is an
283
+ * explicit rescale rather than the call back. Asserted by `scripts/determinism.test.mjs`.
284
+ */
285
+ export function hullShape(points: ArrayLike<number>, radius = 0): ConvexShape {
286
+ const pts = dedupePoints(points);
287
+ const n = pts.length / 3;
288
+ if (n === 0) throw new Error('hullShape: no points');
289
+ if (n > MAX_POINTS) {
290
+ throw new Error(
291
+ `hullShape: ${n} points exceeds ${MAX_POINTS} — derive shapes from defining points, not meshes`,
292
+ );
293
+ }
294
+
295
+ let boundRadius = 0;
296
+ for (let i = 0; i < n; i++) {
297
+ const px = pts[i * 3] ?? 0;
298
+ const py = pts[i * 3 + 1] ?? 0;
299
+ const pz = pts[i * 3 + 2] ?? 0;
300
+ const d = Math.sqrt(px * px + py * py + pz * pz);
301
+ if (d > boundRadius) boundRadius = d;
302
+ }
303
+ boundRadius += radius;
304
+
305
+ // Every plane that supports the hull: (nx, ny, nz, offset), outward.
306
+ const planes: number[] = [];
307
+ for (let i = 0; i < n - 2; i++) {
308
+ for (let j = i + 1; j < n - 1; j++) {
309
+ for (let k = j + 1; k < n; k++) {
310
+ proposePlane(pts, n, i, j, k, planes);
311
+ }
312
+ }
313
+ }
314
+
315
+ const faceNormals = dedupeDirections(planes, 4);
316
+ const edgeDirs =
317
+ planes.length >= 8
318
+ ? hullEdges(pts, n, planes)
319
+ : // Flat or degenerate cloud (one plane or none): there are no two faces
320
+ // to intersect, so every pairwise direction stands in for the rim.
321
+ pairwiseDirections(pts, n);
322
+
323
+ /*
324
+ * The planes are what `proposePlane` already found; only their offsets were being discarded. A
325
+ * face is one plane plus the points lying on it, ordered around the normal — which is what
326
+ * clipping and an inertia tensor both need, and what `faceNormals` cannot give, being deduped
327
+ * directions rather than faces.
328
+ */
329
+ const faces = planes.length >= 4 ? buildFaces(pts, n, planes) : EMPTY_FACES;
330
+
331
+ return { vertices: pts, faceNormals, edgeDirs, radius, sideRadius: 0, boundRadius, ...faces };
332
+ }
333
+
334
+ /**
335
+ * World-axis bounds of a shape as placed (its points are its placement).
336
+ *
337
+ * **`radius` and `sideRadius` grow the box differently, and the second one is not `sideRadius` on
338
+ * every axis.** A ball grows a box by its radius along all three; a disc perpendicular to a unit
339
+ * axis `a` grows it by `sideRadius * sqrt(1 - a[i]^2)` along axis `i` — the disc's own extent seen
340
+ * down that axis, which is the full radius across the axis and exactly nothing along it. Using
341
+ * `sideRadius` on all three instead would bound a lying cylinder as if it were a sphere, which is
342
+ * conservative and therefore not wrong, and would cost the broad phase the difference on every
343
+ * cylinder in the world.
344
+ */
345
+ export function shapeBounds(shape: ConvexShape, out: Aabb): Aabb {
346
+ const v = shape.vertices;
347
+ let minX = Infinity;
348
+ let minY = Infinity;
349
+ let minZ = Infinity;
350
+ let maxX = -Infinity;
351
+ let maxY = -Infinity;
352
+ let maxZ = -Infinity;
353
+ for (let i = 0; i < v.length; i += 3) {
354
+ const x = v[i] ?? 0;
355
+ const y = v[i + 1] ?? 0;
356
+ const z = v[i + 2] ?? 0;
357
+ if (x < minX) minX = x;
358
+ if (x > maxX) maxX = x;
359
+ if (y < minY) minY = y;
360
+ if (y > maxY) maxY = y;
361
+ if (z < minZ) minZ = z;
362
+ if (z > maxZ) maxZ = z;
363
+ }
364
+ let growX = shape.radius;
365
+ let growY = shape.radius;
366
+ let growZ = shape.radius;
367
+ if (shape.sideRadius > 0) {
368
+ const ax = (v[3] ?? 0) - (v[0] ?? 0);
369
+ const ay = (v[4] ?? 0) - (v[1] ?? 0);
370
+ const az = (v[5] ?? 0) - (v[2] ?? 0);
371
+ const length = Math.sqrt(ax * ax + ay * ay + az * az) || 1;
372
+ const ux = ax / length;
373
+ const uy = ay / length;
374
+ const uz = az / length;
375
+ growX += shape.sideRadius * Math.sqrt(Math.max(0, 1 - ux * ux));
376
+ growY += shape.sideRadius * Math.sqrt(Math.max(0, 1 - uy * uy));
377
+ growZ += shape.sideRadius * Math.sqrt(Math.max(0, 1 - uz * uz));
378
+ }
379
+ out.minX = minX - growX;
380
+ out.minY = minY - growY;
381
+ out.minZ = minZ - growZ;
382
+ out.maxX = maxX + growX;
383
+ out.maxY = maxY + growY;
384
+ out.maxZ = maxZ + growZ;
385
+ return out;
386
+ }
387
+
388
+ function dedupePoints(points: ArrayLike<number>): Float32Array {
389
+ const kept: number[] = [];
390
+ for (let i = 0; i + 2 < points.length; i += 3) {
391
+ const x = points[i] ?? 0;
392
+ const y = points[i + 1] ?? 0;
393
+ const z = points[i + 2] ?? 0;
394
+ let duplicate = false;
395
+ for (let k = 0; k < kept.length; k += 3) {
396
+ if (
397
+ Math.abs((kept[k] ?? 0) - x) < POINT_EPS &&
398
+ Math.abs((kept[k + 1] ?? 0) - y) < POINT_EPS &&
399
+ Math.abs((kept[k + 2] ?? 0) - z) < POINT_EPS
400
+ ) {
401
+ duplicate = true;
402
+ break;
403
+ }
404
+ }
405
+ if (!duplicate) kept.push(x, y, z);
406
+ }
407
+ return new Float32Array(kept);
408
+ }
409
+
410
+ /** If the triple (i, j, k) spans a supporting plane, record it outward in `planes`. */
411
+ function proposePlane(
412
+ pts: Float32Array,
413
+ n: number,
414
+ i: number,
415
+ j: number,
416
+ k: number,
417
+ planes: number[],
418
+ ): void {
419
+ const ax = pts[i * 3] ?? 0;
420
+ const ay = pts[i * 3 + 1] ?? 0;
421
+ const az = pts[i * 3 + 2] ?? 0;
422
+ const ux = (pts[j * 3] ?? 0) - ax;
423
+ const uy = (pts[j * 3 + 1] ?? 0) - ay;
424
+ const uz = (pts[j * 3 + 2] ?? 0) - az;
425
+ const vx = (pts[k * 3] ?? 0) - ax;
426
+ const vy = (pts[k * 3 + 1] ?? 0) - ay;
427
+ const vz = (pts[k * 3 + 2] ?? 0) - az;
428
+ let nx = uy * vz - uz * vy;
429
+ let ny = uz * vx - ux * vz;
430
+ let nz = ux * vy - uy * vx;
431
+ const len = Math.sqrt(nx * nx + ny * ny + nz * nz);
432
+ if (len < 1e-9) return;
433
+ nx /= len;
434
+ ny /= len;
435
+ nz /= len;
436
+
437
+ let above = 0;
438
+ let below = 0;
439
+ for (let p = 0; p < n; p++) {
440
+ const d =
441
+ ((pts[p * 3] ?? 0) - ax) * nx +
442
+ ((pts[p * 3 + 1] ?? 0) - ay) * ny +
443
+ ((pts[p * 3 + 2] ?? 0) - az) * nz;
444
+ if (d > COPLANAR_M) above++;
445
+ else if (d < -COPLANAR_M) below++;
446
+ if (above > 0 && below > 0) return;
447
+ }
448
+ // Outward means every other point sits behind the plane.
449
+ if (above === 0) recordPlane(planes, nx, ny, nz, nx * ax + ny * ay + nz * az);
450
+ if (below === 0) recordPlane(planes, -nx, -ny, -nz, -(nx * ax + ny * ay + nz * az));
451
+ }
452
+
453
+ function recordPlane(planes: number[], nx: number, ny: number, nz: number, d: number): void {
454
+ for (let p = 0; p < planes.length; p += 4) {
455
+ const dot = (planes[p] ?? 0) * nx + (planes[p + 1] ?? 0) * ny + (planes[p + 2] ?? 0) * nz;
456
+ if (dot > DIR_DEDUP && Math.abs((planes[p + 3] ?? 0) - d) < COPLANAR_M) return;
457
+ }
458
+ planes.push(nx, ny, nz, d);
459
+ }
460
+
461
+ /** Unique directions (as lines) from a packed list with the given stride. */
462
+ function dedupeDirections(packed: number[], stride: number): Float32Array {
463
+ const dirs: number[] = [];
464
+ for (let p = 0; p < packed.length; p += stride) {
465
+ const x = packed[p] ?? 0;
466
+ const y = packed[p + 1] ?? 0;
467
+ const z = packed[p + 2] ?? 0;
468
+ appendDirection(dirs, x, y, z);
469
+ }
470
+ return new Float32Array(dirs);
471
+ }
472
+
473
+ function appendDirection(dirs: number[], x: number, y: number, z: number): void {
474
+ const len = Math.sqrt(x * x + y * y + z * z);
475
+ if (len < 1e-9) return;
476
+ const nx = x / len;
477
+ const ny = y / len;
478
+ const nz = z / len;
479
+ for (let d = 0; d < dirs.length; d += 3) {
480
+ const dot = (dirs[d] ?? 0) * nx + (dirs[d + 1] ?? 0) * ny + (dirs[d + 2] ?? 0) * nz;
481
+ if (Math.abs(dot) > DIR_DEDUP) return;
482
+ }
483
+ dirs.push(nx, ny, nz);
484
+ }
485
+
486
+ /** Edge directions: point pairs lying on at least two distinct supporting planes. */
487
+ function hullEdges(pts: Float32Array, n: number, planes: number[]): Float32Array {
488
+ const dirs: number[] = [];
489
+ for (let i = 0; i < n - 1; i++) {
490
+ for (let j = i + 1; j < n; j++) {
491
+ let shared = 0;
492
+ for (let p = 0; p < planes.length && shared < 2; p += 4) {
493
+ const nx = planes[p] ?? 0;
494
+ const ny = planes[p + 1] ?? 0;
495
+ const nz = planes[p + 2] ?? 0;
496
+ const d = planes[p + 3] ?? 0;
497
+ const di =
498
+ (pts[i * 3] ?? 0) * nx + (pts[i * 3 + 1] ?? 0) * ny + (pts[i * 3 + 2] ?? 0) * nz - d;
499
+ if (Math.abs(di) > COPLANAR_M) continue;
500
+ const dj =
501
+ (pts[j * 3] ?? 0) * nx + (pts[j * 3 + 1] ?? 0) * ny + (pts[j * 3 + 2] ?? 0) * nz - d;
502
+ if (Math.abs(dj) > COPLANAR_M) continue;
503
+ shared++;
504
+ }
505
+ if (shared < 2) continue;
506
+ appendDirection(
507
+ dirs,
508
+ (pts[j * 3] ?? 0) - (pts[i * 3] ?? 0),
509
+ (pts[j * 3 + 1] ?? 0) - (pts[i * 3 + 1] ?? 0),
510
+ (pts[j * 3 + 2] ?? 0) - (pts[i * 3 + 2] ?? 0),
511
+ );
512
+ }
513
+ }
514
+ return new Float32Array(dirs);
515
+ }
516
+
517
+ function pairwiseDirections(pts: Float32Array, n: number): Float32Array {
518
+ const dirs: number[] = [];
519
+ for (let i = 0; i < n - 1; i++) {
520
+ for (let j = i + 1; j < n; j++) {
521
+ appendDirection(
522
+ dirs,
523
+ (pts[j * 3] ?? 0) - (pts[i * 3] ?? 0),
524
+ (pts[j * 3 + 1] ?? 0) - (pts[i * 3 + 1] ?? 0),
525
+ (pts[j * 3 + 2] ?? 0) - (pts[i * 3 + 2] ?? 0),
526
+ );
527
+ }
528
+ }
529
+ return new Float32Array(dirs);
530
+ }
531
+
532
+ /**
533
+ * Group the supporting planes into faces and order each face's points around its normal.
534
+ *
535
+ * `proposePlane` records one plane per point triple, so a square face arrives four times over. The
536
+ * dedupe here is by *direction and offset together*, unlike `appendDirection`, which folds a plane
537
+ * into its opposite because a separating axis has no sign. A face does.
538
+ *
539
+ * **What this gives up:** a face with fewer than three points on it is dropped, so a shape flat
540
+ * enough to have no interior yields no faces and falls back to the empty set. **What would make it
541
+ * wrong** is a caller expecting a degenerate cloud to clip, which it cannot: there is no volume to
542
+ * clip against.
543
+ */
544
+ function buildFaces(pts: Float32Array, n: number, planes: readonly number[]): typeof EMPTY_FACES {
545
+ const kept: number[] = [];
546
+ const loops: number[][] = [];
547
+
548
+ for (let p = 0; p + 3 < planes.length; p += 4) {
549
+ const nx = planes[p] ?? 0;
550
+ const ny = planes[p + 1] ?? 0;
551
+ const nz = planes[p + 2] ?? 0;
552
+ const d = planes[p + 3] ?? 0;
553
+
554
+ let seen = false;
555
+ for (let k = 0; k + 3 < kept.length; k += 4) {
556
+ const dot = (kept[k] ?? 0) * nx + (kept[k + 1] ?? 0) * ny + (kept[k + 2] ?? 0) * nz;
557
+ if (dot > DIR_DEDUP && Math.abs((kept[k + 3] ?? 0) - d) < COPLANAR_M) {
558
+ seen = true;
559
+ break;
560
+ }
561
+ }
562
+ if (seen) continue;
563
+
564
+ const on: number[] = [];
565
+ for (let i = 0; i < n; i++) {
566
+ const x = pts[i * 3] ?? 0;
567
+ const y = pts[i * 3 + 1] ?? 0;
568
+ const z = pts[i * 3 + 2] ?? 0;
569
+ if (Math.abs(nx * x + ny * y + nz * z - d) < COPLANAR_M) on.push(i);
570
+ }
571
+ if (on.length < 3) continue;
572
+
573
+ kept.push(nx, ny, nz, d);
574
+ loops.push(orderAroundNormal(pts, on, nx, ny, nz));
575
+ }
576
+
577
+ if (loops.length === 0) return EMPTY_FACES;
578
+
579
+ const starts = new Uint16Array(loops.length + 1);
580
+ let total = 0;
581
+ for (let i = 0; i < loops.length; i++) {
582
+ starts[i] = total;
583
+ total += loops[i]?.length ?? 0;
584
+ }
585
+ starts[loops.length] = total;
586
+
587
+ const indices = new Uint16Array(total);
588
+ let at = 0;
589
+ for (const loop of loops) for (const index of loop) indices[at++] = index;
590
+
591
+ return {
592
+ facePlanes: new Float32Array(kept),
593
+ faceVertexStart: starts,
594
+ faceVertexIndices: indices,
595
+ };
596
+ }
597
+
598
+ /**
599
+ * Sort a face's points counter-clockwise about its outward normal.
600
+ *
601
+ * A comparison sort on a key computed from the two in-plane axes, **not `Math.atan2`** — the
602
+ * determinism gate bans that outright, and the ordering only has to be *consistent*, which a
603
+ * pseudo-angle gives for free. `Array.prototype.sort` is stable by specification, and `on` is built
604
+ * in ascending index order, so two coincident points keep a defined order.
605
+ */
606
+ function orderAroundNormal(
607
+ pts: Float32Array,
608
+ on: readonly number[],
609
+ nx: number,
610
+ ny: number,
611
+ nz: number,
612
+ ): number[] {
613
+ // Any axis not parallel to the normal gives an in-plane basis.
614
+ const ax = Math.abs(nx) < 0.5 ? 1 : 0;
615
+ const ay = Math.abs(nx) < 0.5 ? 0 : 1;
616
+ let ux = ay * nz;
617
+ let uy = -ax * nz;
618
+ let uz = ax * ny - ay * nx;
619
+ const ulen = Math.sqrt(ux * ux + uy * uy + uz * uz) || 1;
620
+ ux /= ulen;
621
+ uy /= ulen;
622
+ uz /= ulen;
623
+ const vx = ny * uz - nz * uy;
624
+ const vy = nz * ux - nx * uz;
625
+ const vz = nx * uy - ny * ux;
626
+
627
+ let cx = 0;
628
+ let cy = 0;
629
+ let cz = 0;
630
+ for (const i of on) {
631
+ cx += pts[i * 3] ?? 0;
632
+ cy += pts[i * 3 + 1] ?? 0;
633
+ cz += pts[i * 3 + 2] ?? 0;
634
+ }
635
+ cx /= on.length;
636
+ cy /= on.length;
637
+ cz /= on.length;
638
+
639
+ const key = new Map<number, number>();
640
+ for (const i of on) {
641
+ const dx = (pts[i * 3] ?? 0) - cx;
642
+ const dy = (pts[i * 3 + 1] ?? 0) - cy;
643
+ const dz = (pts[i * 3 + 2] ?? 0) - cz;
644
+ key.set(i, pseudoAngle(dx * ux + dy * uy + dz * uz, dx * vx + dy * vy + dz * vz));
645
+ }
646
+ // A copy, because `on` is the caller's and sorting in place would surprise it.
647
+ return [...on].sort((p, q) => (key.get(p) ?? 0) - (key.get(q) ?? 0));
648
+ }
649
+
650
+ /** A monotonic stand-in for `atan2`, in [0, 4), costing one divide and no transcendental. */
651
+ function pseudoAngle(x: number, y: number): number {
652
+ const sum = Math.abs(x) + Math.abs(y);
653
+ if (sum === 0) return 0;
654
+ const t = y / sum;
655
+ return x < 0 ? 2 - t : t < 0 ? 4 + t : t;
656
+ }