@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/world.ts ADDED
@@ -0,0 +1,961 @@
1
+ import { BODY_DYNAMIC, BODY_SENSOR, BODY_STATIC, BodySet } from './bodies.ts';
2
+ import type { BodyDesc } from './bodies.ts';
3
+ import { shapeBounds } from './shape.ts';
4
+ import type { Aabb } from './collide/index.ts';
5
+ import { collideShapes, createManifold } from './manifold.ts';
6
+ import { MAX_MESH_MANIFOLDS, collideMesh, isMesh } from './meshContact.ts';
7
+ import {
8
+ MAX_BODY_PARTS,
9
+ MAX_PAIR_MANIFOLDS,
10
+ collideCompound,
11
+ partsBoundRadius,
12
+ } from './compoundContact.ts';
13
+
14
+ import type { Manifold, ShapePose } from './manifold.ts';
15
+ import { MAX_BODIES, PairSet, pairA, pairB, pairKey } from './pairs.ts';
16
+ import {
17
+ ContactConstraints,
18
+ applyRestitution,
19
+ prepareContact,
20
+ solveContacts,
21
+ warmStart,
22
+ } from './solver.ts';
23
+ import type { FrictionModel } from './solver.ts';
24
+ import { DynamicTree } from './tree.ts';
25
+ import { overlapWorld, raycastWorld, shapecastWorld } from './query.ts';
26
+ import type { QueryFilter, RayHit } from './query.ts';
27
+ import type { ConvexShape } from './shape.ts';
28
+ import { ContactEvents } from './events.ts';
29
+ import { IslandSet } from './island.ts';
30
+ import { JointSet, resetJointImpulses, solveJoints } from './joints.ts';
31
+ import type { JointDesc } from './joints.ts';
32
+ import { SerialExecutor } from './executor.ts';
33
+ import type { Executor, IslandSolver } from './executor.ts';
34
+ import {
35
+ DIAL_ALLOW_SLEEP,
36
+ DIAL_ANGULAR_DAMPING,
37
+ DIAL_CONTACT_DAMPING,
38
+ DIAL_CONTACT_HERTZ,
39
+ DIAL_COUNT,
40
+ DIAL_DT,
41
+ DIAL_FRICTION_MODEL,
42
+ DIAL_GRAVITY_X,
43
+ DIAL_GRAVITY_Y,
44
+ DIAL_GRAVITY_Z,
45
+ DIAL_ITERATIONS,
46
+ DIAL_JOINT_DAMPING,
47
+ DIAL_JOINT_HERTZ,
48
+ DIAL_LINEAR_DAMPING,
49
+ DIAL_SUBSTEPS,
50
+ SLEEP_TICKS,
51
+ bodyAsleep,
52
+ frictionDial,
53
+ solveIslandInto,
54
+ } from './islandSolve.ts';
55
+ import type { IslandSolveState } from './islandSolve.ts';
56
+ import type { PoolOutcome, PoolRequest, PoolWorker } from './workerPool.ts';
57
+
58
+ /**
59
+ * A rigid body world, stepped on a fixed clock.
60
+ *
61
+ * **The tick order is the design, and every stage exists to keep the next one honest**: refresh the
62
+ * broadphase, collect pairs *sorted by body index* so nothing carries tree shape forward, build
63
+ * manifolds, then run N substeps of integrate-and-solve, one relax pass, and restitution once.
64
+ *
65
+ * **Substeps move a body inside a tick.** Only the state at the end of a tick is what a renderer's
66
+ * `alpha` interpolates against; anything sampling mid-tick sees substep state, which is why contact
67
+ * events are drained after the tick rather than raised during it.
68
+ */
69
+
70
+ /** Options a consumer sets once. Every one of them is a quality or feel dial, not a contract. */
71
+ export interface WorldOptions {
72
+ /** How many integrate-and-solve passes per tick. A quality dial: raising it changes nothing. */
73
+ substeps?: number;
74
+ /** Iterations per substep. */
75
+ iterations?: number;
76
+ gravityX?: number;
77
+ gravityY?: number;
78
+ gravityZ?: number;
79
+ /** Contact stiffness as a frequency, which is what makes `substeps` a dial. */
80
+ contactHertz?: number;
81
+ contactDamping?: number;
82
+ /** Joint stiffness, higher than a contact's because a joint is a structural relationship. */
83
+ jointHertz?: number;
84
+ jointDamping?: number;
85
+ linearDamping?: number;
86
+ angularDamping?: number;
87
+ /** How far apart two shapes may be and still get a constraint. */
88
+ speculativeMargin?: number;
89
+ /** Whether settled islands stop being solved. On by default; off for a test isolating something. */
90
+ allowSleep?: boolean;
91
+ /**
92
+ * How the two friction axes are bounded against each other. `box` by default. See `solver.ts`.
93
+ *
94
+ * **`elliptical` is the correct one and is not the default**, because every stacking result moves
95
+ * under it — `fingerprintBodies`, both baselines, and any golden fingerprint or stored replay a
96
+ * consumer has kept. A production engine does not silently invalidate those. What a
97
+ * consumer gets by asking for it is friction that is the same in every direction, which is what
98
+ * friction physically is; what a box gives instead is up to `sqrt(2)` more grip along the
99
+ * diagonal of a tangent basis built from the contact normal, which is an artefact rather than a
100
+ * fact about the world.
101
+ */
102
+ frictionModel?: FrictionModel;
103
+ /**
104
+ * How many workers solve islands. 0, the default, is the serial runner and changes nothing.
105
+ *
106
+ * **It needs a cross-origin isolated page.** Without one `SharedArrayBuffer` is undefined and the
107
+ * pool cannot start; physics stays correct and serial and `parallelism.reason` says so. See
108
+ * `workerPool.ts` for what the headers cost a consumer, which is why this is a switch and not a
109
+ * default. Clamped to `hardwareConcurrency - 1` where the runtime reports one.
110
+ */
111
+ workers?: number;
112
+ /**
113
+ * Build one worker yourself.
114
+ *
115
+ * The default is `new Worker(new URL('./islandWorker.ts', import.meta.url), { type: 'module' })`,
116
+ * which Vite, webpack 5 and Parcel resolve. Supply this when your build does not, or to point at
117
+ * an entry of your own that imports `@driftengine/physics/src/islandWorker.ts`.
118
+ */
119
+ spawn?: () => PoolWorker;
120
+ /**
121
+ * What builds the pool. `createIslandPool` is the one this package ships.
122
+ *
123
+ * **Passed in rather than imported here, and the reason is a measurement made twice.** A
124
+ * `PhysicsWorld` that reaches the pool on its own puts it in every consumer's bundle: 2,629 bytes
125
+ * gzipped charged to everyone who never asks for a worker, in a package whose promise is that you
126
+ * pay only for what you import.
127
+ *
128
+ * **That was half the problem and the other half cost 4.7 times as much.** Naming the factory at
129
+ * the call site let tree-shaking drop the pool, and a bundler had already emitted the *worker* —
130
+ * `new Worker(new URL(...))` is rewritten at transform time, before anything decides what is
131
+ * reachable. Measured on two consumers on 2026-09-05: 12,259 bytes gzipped of worker chunk that
132
+ * nothing in either bundle could fetch. So the factory left the barrel too; see `workers.ts`.
133
+ *
134
+ * ```ts
135
+ * import { PhysicsWorld } from '@driftengine/physics';
136
+ * import { createIslandPool } from '@driftengine/physics/src/workers.ts';
137
+ *
138
+ * const world = new PhysicsWorld({ workers: 4, pool: createIslandPool });
139
+ * ```
140
+ *
141
+ * Asking for workers without it is not silent: `parallelism.reason` says exactly this.
142
+ */
143
+ pool?: (request: PoolRequest) => PoolOutcome;
144
+ }
145
+
146
+ /** What a world's island solving is actually doing, and why. */
147
+ export interface Parallelism {
148
+ /** Workers the consumer asked for. */
149
+ requested: number;
150
+ /** Workers running. Zero with a non-empty `reason` is a working fallback, not a failure. */
151
+ running: number;
152
+ /** Empty when `running` matches `requested`. A whole sentence otherwise. */
153
+ reason: string;
154
+ }
155
+
156
+ const IMPULSE_I = new Float32Array(6);
157
+ const SLEEP_LINEAR = 0.05;
158
+ const SLEEP_ANGULAR = 0.05;
159
+
160
+ export class PhysicsWorld implements IslandSolver {
161
+ readonly bodies = new BodySet();
162
+ readonly joints = new JointSet();
163
+ /** What touched what during the last tick, ordered by pair key. Drain it after `step`. */
164
+ readonly events = new ContactEvents();
165
+
166
+ /**
167
+ * Pairs that never collide, whatever their layers say.
168
+ *
169
+ * **The mask says what a body is; this says who two bodies are to each other.** See `pairKey`
170
+ * for why the two are not the same question and why a ragdoll can only be expressed by the
171
+ * second one.
172
+ *
173
+ * Kept as a set of packed keys and consulted only when it holds something, so a world that
174
+ * never calls `ignorePair` pays one integer compare per candidate pair.
175
+ */
176
+ private readonly ignoredPairs = new Set<number>();
177
+ readonly substeps: number;
178
+ readonly iterations: number;
179
+ gravityX: number;
180
+ gravityY: number;
181
+ gravityZ: number;
182
+ contactHertz: number;
183
+ contactDamping: number;
184
+ jointHertz: number;
185
+ jointDamping: number;
186
+ linearDamping: number;
187
+ angularDamping: number;
188
+ speculativeMargin: number;
189
+ allowSleep: boolean;
190
+ /** Read at every substep, so a consumer may switch it between ticks. See `WorldOptions`. */
191
+ frictionModel: FrictionModel;
192
+
193
+ /** Who runs the islands. Swappable, and a gate asserts two characters agree. */
194
+ executor: Executor = new SerialExecutor();
195
+
196
+ /**
197
+ * What the island solving is actually doing.
198
+ *
199
+ * **Read it rather than assuming the `workers` option took.** A pool needs a cross-origin isolated
200
+ * page and a bundler that understands a worker entry, and when either is missing this says which
201
+ * in a sentence fit to put on a debug overlay. `createSplatSortWorker` warns once on the console
202
+ * for the same reason; this is the same fact as data, because a console warning is not something a
203
+ * consumer can show a player or assert in a test.
204
+ */
205
+ readonly parallelism: Parallelism;
206
+
207
+ /**
208
+ * The scalars an island's solve reads, in the layout `islandSolve.ts` names.
209
+ *
210
+ * Written once a tick by `step` so a consumer may change gravity or the friction model between
211
+ * ticks and have every character see it, including one reading them out of shared memory.
212
+ */
213
+ private dials = new Float64Array(DIAL_COUNT);
214
+
215
+ private tree = new DynamicTree();
216
+ private islands = new IslandSet();
217
+ private stepDt = 1 / 60;
218
+ private leafOf = new Int32Array(64);
219
+ private pairs = new PairSet();
220
+ private constraints = new ContactConstraints();
221
+ private manifold: Manifold = createManifold();
222
+ private box: Aabb = { minX: 0, minY: 0, minZ: 0, maxX: 0, maxY: 0, maxZ: 0 };
223
+ private poseA: ShapePose = { x: 0, y: 0, z: 0, qx: 0, qy: 0, qz: 0, qw: 1 };
224
+ private poseB: ShapePose = { x: 0, y: 0, z: 0, qx: 0, qy: 0, qz: 0, qw: 1 };
225
+ /** Previous tick's impulses, by pair key then feature id. */
226
+ /**
227
+ * One manifold per contact plane a mesh pair produced. Allocated once; the tick may not.
228
+ *
229
+ * `MAX_MESH_MANIFOLDS` planes is a body wedged in a corner with room to spare. A pair that fills
230
+ * it drops the rest, which for a convex body against a mesh means dropping the shallowest planes
231
+ * — the tree returns candidates in its own order, so what is kept is the first eight it met.
232
+ */
233
+ /**
234
+ * The manifold set a compound pair fills, sized so a compound against one convex shape cannot
235
+ * overflow: one plane per part, and `MAX_PAIR_MANIFOLDS` is `MAX_BODY_PARTS`.
236
+ *
237
+ * Separate from `meshManifolds` rather than one larger set shared by both, because `collideMesh`
238
+ * fills up to the length it is given — handing it thirty-two would change how many contact planes
239
+ * a shipped mesh pair produces, which is a behaviour change nobody asked for.
240
+ */
241
+ private readonly pairManifolds: readonly Manifold[] = Array.from(
242
+ { length: MAX_PAIR_MANIFOLDS },
243
+ createManifold,
244
+ );
245
+
246
+ private readonly meshManifolds: readonly Manifold[] = Array.from(
247
+ { length: MAX_MESH_MANIFOLDS },
248
+ () => createManifold(),
249
+ );
250
+ private warm = new Map<number, Float32Array>();
251
+ private nextWarm = new Map<number, Float32Array>();
252
+ private warmN = new Float32Array(4);
253
+ private warmT1 = new Float32Array(4);
254
+ private warmT2 = new Float32Array(4);
255
+
256
+ constructor(options: WorldOptions = {}) {
257
+ this.substeps = Math.max(1, options.substeps ?? 4);
258
+ this.iterations = Math.max(1, options.iterations ?? 1);
259
+ this.gravityX = options.gravityX ?? 0;
260
+ this.gravityY = options.gravityY ?? -9.81;
261
+ this.gravityZ = options.gravityZ ?? 0;
262
+ this.contactHertz = options.contactHertz ?? 30;
263
+ this.contactDamping = options.contactDamping ?? 10;
264
+ this.jointHertz = options.jointHertz ?? 60;
265
+ this.jointDamping = options.jointDamping ?? 2;
266
+ this.linearDamping = options.linearDamping ?? 0;
267
+ this.angularDamping = options.angularDamping ?? 0.05;
268
+ this.speculativeMargin = options.speculativeMargin ?? 0.04;
269
+ this.allowSleep = options.allowSleep ?? true;
270
+ this.frictionModel = options.frictionModel ?? 'box';
271
+
272
+ /*
273
+ * **What was asked for and what this machine will give are two numbers, and this used to
274
+ * report the second under the first's name.**
275
+ *
276
+ * `Parallelism.requested` is documented as "workers the consumer asked for" and `reason` as
277
+ * "empty when `running` matches `requested`" — and a world asked for four on a two-core machine
278
+ * reported `requested: 1, running: 1, reason: ''`. Every field agreed with every other and none
279
+ * of them said that three quarters of the request had been dropped. That is the silent clamp
280
+ * this whole surface exists to avoid: `parallelism`'s own note says to read it *rather than
281
+ * assuming the `workers` option took*, and on the most common shortfall there is it had nothing
282
+ * to say.
283
+ *
284
+ * Found by CI rather than here, which is the other half of it: a character has two cores and this
285
+ * machine has twenty-four, so the clamp never bit locally and the tests that assert the
286
+ * documented meaning were red on every push.
287
+ */
288
+ const asked = Math.max(0, Math.floor(options.workers ?? 0));
289
+ const granted = clampWorkers(asked);
290
+ this.parallelism = { requested: asked, running: 0, reason: '' };
291
+ if (granted > 0) {
292
+ if (options.pool === undefined) {
293
+ this.parallelism.reason =
294
+ 'workers were asked for but no pool was given. Import createIslandPool from ' +
295
+ '@driftengine/physics/src/workers.ts and pass it as the pool option; it is behind its ' +
296
+ 'own specifier so that importing PhysicsWorld does not put a worker in your bundle.';
297
+ } else {
298
+ const pool = options.pool({ workers: granted, spawn: options.spawn });
299
+ if (pool.executor !== null) this.executor = pool.executor;
300
+ this.parallelism.running = pool.running;
301
+ this.parallelism.reason = pool.reason;
302
+ }
303
+ }
304
+ /*
305
+ * The clamp, said out loud — but only where nothing else already explained the shortfall. A
306
+ * pool that could not start has a better sentence than this one and keeps it.
307
+ */
308
+ if (this.parallelism.reason === '' && this.parallelism.running < asked) {
309
+ const cores = coreCount();
310
+ this.parallelism.reason =
311
+ `${asked} workers were asked for and ${this.parallelism.running} run: this machine ` +
312
+ `reports ${cores === undefined ? 'an unknown number of' : String(cores)} cores, and one ` +
313
+ 'is kept for the thread that steps the world.';
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Stop the worker pool, if there is one. Safe to call twice, and safe on a world without one.
319
+ *
320
+ * **A world with a pool must be disposed.** Its workers hold the staging `SharedArrayBuffer`, so
321
+ * a consumer replacing a world between levels leaks both the threads and every body's state with
322
+ * them.
323
+ */
324
+ dispose(): void {
325
+ this.executor.dispose?.();
326
+ this.parallelism.running = 0;
327
+ }
328
+
329
+ /** Join two bodies. The relative pose they are in now becomes the one the joint holds. */
330
+ addJoint(desc: JointDesc): number {
331
+ return this.joints.add(this.bodies, desc);
332
+ }
333
+
334
+ addBody(desc: BodyDesc): number {
335
+ /*
336
+ * **A triangle mesh may only be static, and this is where that is enforced.** A moving one
337
+ * needs its tree refitted every tick and needs a mass tensor a triangle soup does not have;
338
+ * the usual answer to a moving concave thing is convex decomposition, which is a row of its
339
+ * own. Refused at `add` rather than left to produce a body that falls through the world, which
340
+ * is what a mesh with no volume and no inertia would do.
341
+ */
342
+ if (desc.shape?.triangles !== undefined && desc.type !== BODY_STATIC) {
343
+ throw new Error(
344
+ 'addBody: a triangle mesh shape may only be static. A moving concave body wants convex ' +
345
+ 'decomposition into hulls, which `decomposeConvex` produces and `shapes` accepts.',
346
+ );
347
+ }
348
+ /*
349
+ * **Both, or neither, is refused at the call rather than resolved in the tick.** There is no
350
+ * sensible reading of a body carrying a single shape and a list of them: the narrow phase would
351
+ * have to pick one, and either pick is a body that collides as something nobody described.
352
+ */
353
+ if ((desc.shape === undefined) === (desc.shapes === undefined)) {
354
+ throw new Error(
355
+ 'addBody: a body is built from exactly one of `shape` or `shapes`, and this one has ' +
356
+ (desc.shape === undefined ? 'neither' : 'both'),
357
+ );
358
+ }
359
+ if (desc.shapes !== undefined) {
360
+ if (desc.shapes.length === 0) throw new Error('addBody: `shapes` is empty');
361
+ /*
362
+ * **A cap is a refusal here and not a truncation in the tick.** The manifold set is sized to
363
+ * `MAX_PAIR_MANIFOLDS`, and a dropped part is a limb of the body that stops colliding — one
364
+ * leg of a chair sinking through a floor while the other three hold. Failing at construction
365
+ * is this engine's "fail fast at init, never throw in the frame loop" applied where it can be.
366
+ */
367
+ if (desc.shapes.length > MAX_BODY_PARTS) {
368
+ throw new Error(
369
+ `addBody: ${desc.shapes.length} parts exceeds MAX_BODY_PARTS of ${MAX_BODY_PARTS} — ` +
370
+ 'ask decomposeConvex for fewer hulls',
371
+ );
372
+ }
373
+ for (const part of desc.shapes) {
374
+ if (part.triangles !== undefined) {
375
+ throw new Error('addBody: a triangle mesh cannot be a part of a compound body');
376
+ }
377
+ }
378
+ }
379
+ const i = this.bodies.add(desc);
380
+ if (i >= this.leafOf.length) {
381
+ const wider = new Int32Array(this.leafOf.length * 2);
382
+ wider.set(this.leafOf);
383
+ this.leafOf = wider;
384
+ }
385
+ this.leafOf[i] = this.tree.insert(i, this.boundsOf(i));
386
+ return i;
387
+ }
388
+
389
+ /**
390
+ * Never collide these two, whichever order they are given in and whatever their layers say.
391
+ *
392
+ * Both indices are followed through `removeBody`, so an exclusion cannot outlive the body it
393
+ * was made about and cannot be inherited by whatever takes its slot.
394
+ */
395
+ ignorePair(a: number, b: number): void {
396
+ if (a === b) return;
397
+ this.ignoredPairs.add(pairKey(a, b));
398
+ }
399
+
400
+ /** Undo one `ignorePair`. Doing it to a pair that was never excluded is not an error. */
401
+ allowPair(a: number, b: number): void {
402
+ this.ignoredPairs.delete(pairKey(a, b));
403
+ }
404
+
405
+ /** Whether these two are excluded. */
406
+ pairIgnored(a: number, b: number): boolean {
407
+ return this.ignoredPairs.has(pairKey(a, b));
408
+ }
409
+
410
+ /**
411
+ * Follow the exclusions through a swap-remove.
412
+ *
413
+ * **A body index is a slot and not an identity**, which is the same fact `removeBody` clears the
414
+ * warm cache and the contact events for: removing one moves the last body into the hole, so a
415
+ * remembered index is either gone or now names somebody else. An exclusion left alone would
416
+ * quietly stop the newcomer colliding with whatever the departed was jointed to.
417
+ */
418
+ private followRemoval(index: number, moved: number): void {
419
+ if (this.ignoredPairs.size === 0) return;
420
+ const kept: number[] = [];
421
+ for (const key of this.ignoredPairs) {
422
+ const a = Math.floor(key / MAX_BODIES);
423
+ const b = key % MAX_BODIES;
424
+ /* The body that left takes its exclusions with it. */
425
+ if (a === index || b === index) continue;
426
+ if (moved >= 0 && (a === moved || b === moved)) {
427
+ kept.push(pairKey(a === moved ? index : a, b === moved ? index : b));
428
+ continue;
429
+ }
430
+ kept.push(key);
431
+ }
432
+ this.ignoredPairs.clear();
433
+ for (const key of kept) this.ignoredPairs.add(key);
434
+ }
435
+
436
+ removeBody(index: number): void {
437
+ this.tree.remove(this.leafOf[index] ?? 0);
438
+ const moved = this.bodies.remove(index);
439
+ this.followRemoval(index, moved);
440
+ if (moved >= 0) {
441
+ this.leafOf[index] = this.leafOf[moved] ?? 0;
442
+ // The tree stores the body index it was inserted with, so it has to learn the new one.
443
+ this.tree.proxy[this.leafOf[index] ?? 0] = index;
444
+ }
445
+ this.warm.clear();
446
+ // Every index past the removed one may have moved, so a remembered pair is a lie.
447
+ this.events.clear();
448
+ }
449
+
450
+ /** Advance one fixed tick. */
451
+ step(dt: number): void {
452
+ const bodies = this.bodies;
453
+ for (let i = 0; i < bodies.count; i++) {
454
+ if (bodies.type[i] === BODY_STATIC) continue;
455
+ this.tree.move(this.leafOf[i] ?? 0, this.boundsOf(i));
456
+ }
457
+
458
+ const pairCount = this.pairs.build(bodies, this.tree, this.leafOf, this.ignoredPairs);
459
+ this.constraints.clear();
460
+ this.nextWarm.clear();
461
+ this.events.begin();
462
+ let awake = 0;
463
+ for (let i = 0; i < pairCount; i++) {
464
+ const key = this.pairs.keys[i] ?? 0;
465
+ const a = pairA(key);
466
+ const b = pairB(key);
467
+ const shapeA = bodies.shape[a];
468
+ const shapeB = bodies.shape[b];
469
+ const partsA = bodies.shapes[a];
470
+ const partsB = bodies.shapes[b];
471
+ if ((!shapeA && !partsA) || (!shapeB && !partsB)) continue;
472
+ /*
473
+ * A sleeping pair still builds its constraint, and that is deliberate. Skipping it drops the
474
+ * contact, which drops the edge, which dissolves the island into singletons — so waking one
475
+ * box of a sleeping stack could no longer reach the boxes above it. The *solve* is what gets
476
+ * skipped, in `solveIsland`, which is the expensive half.
477
+ *
478
+ * **What this costs** is narrowphase for a sleeping stack, which sleeping was partly meant to
479
+ * save. **What would make it wrong** is a world of tens of thousands of sleeping bodies where
480
+ * that cost shows; the answer then is to persist island membership across sleep rather than
481
+ * to re-derive it from contacts, which is a larger change than this one.
482
+ */
483
+ this.readPose(a, this.poseA);
484
+ this.readPose(b, this.poseB);
485
+ /*
486
+ * **A mesh pair fills a set of manifolds where every other pair fills one.** A body in the
487
+ * corner of a mesh room has two or three contact planes, and reducing them to one picks a
488
+ * wall and lets the body through the others. The solver already supports it: `prepareContact`
489
+ * appends a constraint rather than replacing one, and `storeWarm` accumulates by pair.
490
+ */
491
+ /*
492
+ * **A compound pair fills a set too, for the same reason and by the same machinery.** One
493
+ * manifold per touching part, with the part pair folded into every feature id — the fold
494
+ * `collideMesh` already does with a triangle index, and for the identical reason: a body
495
+ * resting across two parts must not hand one part's accumulated impulse to the other on the
496
+ * tick a contact moves between them.
497
+ */
498
+ const compoundPair = partsA !== undefined || partsB !== undefined;
499
+ const meshPair = !compoundPair && (isMesh(shapeA) || isMesh(shapeB));
500
+ const set = compoundPair ? this.pairManifolds : this.meshManifolds;
501
+ const manifolds = compoundPair
502
+ ? collideCompound(
503
+ shapeA,
504
+ partsA,
505
+ this.poseA,
506
+ shapeB,
507
+ partsB,
508
+ this.poseB,
509
+ this.speculativeMargin,
510
+ this.pairManifolds,
511
+ )
512
+ : meshPair
513
+ ? collideMesh(
514
+ shapeA as ConvexShape,
515
+ this.poseA,
516
+ shapeB as ConvexShape,
517
+ this.poseB,
518
+ this.speculativeMargin,
519
+ this.meshManifolds,
520
+ )
521
+ : collideShapes(
522
+ shapeA as ConvexShape,
523
+ this.poseA,
524
+ shapeB as ConvexShape,
525
+ this.poseB,
526
+ this.speculativeMargin,
527
+ this.manifold,
528
+ )
529
+ ? 1
530
+ : 0;
531
+ if (manifolds === 0) continue;
532
+ /*
533
+ * Deliberately no wake here. Waking every body that has a contact resets the counter on every
534
+ * tick, so a resting stack can never accumulate one and nothing in the world ever sleeps —
535
+ * which is what this did. A sleeping body touched by a moving one is woken by its *island*
536
+ * instead: they share one, the island is not all still, and every counter in it resets.
537
+ */
538
+ awake++;
539
+ this.events.touching(key);
540
+ /*
541
+ * A sensor is reported and never resolved. Building its constraint would push it out of the
542
+ * thing it is meant to notice, which is the whole of what a sensor is not.
543
+ */
544
+ if ((bodies.flags[a] ?? 0) & BODY_SENSOR || (bodies.flags[b] ?? 0) & BODY_SENSOR) continue;
545
+ const stored = this.warm.get(key);
546
+ for (let m = 0; m < manifolds; m++) {
547
+ const manifold = compoundPair || meshPair ? (set[m] as Manifold) : this.manifold;
548
+ this.loadWarm(stored, manifold);
549
+ prepareContact(
550
+ bodies,
551
+ a,
552
+ b,
553
+ manifold,
554
+ this.constraints,
555
+ this.warmN,
556
+ this.warmT1,
557
+ this.warmT2,
558
+ );
559
+ }
560
+ }
561
+
562
+ /*
563
+ * Integrate velocities, warm start, solve, integrate positions, relax — in that order, every
564
+ * substep.
565
+ *
566
+ * **The accumulated impulse is a per-substep quantity**, so warm starting only on the first
567
+ * substep hands a resting contact a quarter of the impulse it needs to hold anything up. A
568
+ * single box on static ground survived that, because the bias alone could carry it; a stack of
569
+ * ten collapsed completely.
570
+ *
571
+ * **And positions are integrated after the biased solve, not before it**, so the solve corrects
572
+ * the positions it is about to produce rather than ones already advanced past it. The relax
573
+ * pass then runs against the new positions with the bias off, which is what takes back the
574
+ * energy the bias added.
575
+ */
576
+ /*
577
+ * Islands are built here and each is advanced through the *whole* substep loop by the executor,
578
+ * rather than integration being a global pass with only the solve split. That is the shape a
579
+ * worker pool needs: one island, one character, no shared state — and a body with no contacts is a
580
+ * singleton island so nothing falls outside it.
581
+ */
582
+ this.stepDt = dt;
583
+ this.writeDials();
584
+ resetJointImpulses(this.joints);
585
+ const islandCount = this.islands.build(bodies, this.constraints, this.joints);
586
+ this.executor.run(islandCount, this);
587
+
588
+ const failure = this.executor.failure;
589
+ if (failure !== undefined && failure !== null && this.parallelism.running > 0) {
590
+ this.parallelism.running = 0;
591
+ this.parallelism.reason = failure;
592
+ }
593
+ this.events.end(pairA, pairB);
594
+ this.storeWarm();
595
+ this.updateSleep();
596
+ void awake;
597
+ }
598
+
599
+ /**
600
+ * Advance one island through every substep. The executor calls this; nothing else should.
601
+ *
602
+ * Integration is over this island's bodies only, which is what makes the call independent of
603
+ * every other island rather than merely ordered against them.
604
+ */
605
+ solveIsland(island: number): void {
606
+ solveIslandInto(this.solveState, island);
607
+ }
608
+
609
+ /**
610
+ * The flat view of this world an island's solve needs, and the only view a worker could hold.
611
+ *
612
+ * Built fresh because `grow` replaces the arrays underneath it; the object is five references and
613
+ * one allocation, which the solve that follows dwarfs.
614
+ *
615
+ * **Public because a character off this thread needs it.** `SerialExecutor` calls `solveIsland`;
616
+ * a staged or pooled character mirrors this state into shared memory and solves that instead, which
617
+ * it cannot do through a method closed over the live arrays.
618
+ */
619
+ get solveState(): IslandSolveState {
620
+ return {
621
+ bodies: this.bodies,
622
+ constraints: this.constraints,
623
+ joints: this.joints,
624
+ islands: this.islands,
625
+ dials: this.dials,
626
+ };
627
+ }
628
+
629
+ /** Copy the dials into the block every character reads. Called once a tick, before the islands run. */
630
+ private writeDials(): void {
631
+ const d = this.dials;
632
+ d[DIAL_SUBSTEPS] = this.substeps;
633
+ d[DIAL_ITERATIONS] = this.iterations;
634
+ d[DIAL_DT] = this.stepDt;
635
+ d[DIAL_GRAVITY_X] = this.gravityX;
636
+ d[DIAL_GRAVITY_Y] = this.gravityY;
637
+ d[DIAL_GRAVITY_Z] = this.gravityZ;
638
+ d[DIAL_LINEAR_DAMPING] = this.linearDamping;
639
+ d[DIAL_ANGULAR_DAMPING] = this.angularDamping;
640
+ d[DIAL_CONTACT_HERTZ] = this.contactHertz;
641
+ d[DIAL_CONTACT_DAMPING] = this.contactDamping;
642
+ d[DIAL_JOINT_HERTZ] = this.jointHertz;
643
+ d[DIAL_JOINT_DAMPING] = this.jointDamping;
644
+ d[DIAL_ALLOW_SLEEP] = this.allowSleep ? 1 : 0;
645
+ d[DIAL_FRICTION_MODEL] = frictionDial(this.frictionModel);
646
+ }
647
+
648
+ /** A body's world bounds, grown by the speculative margin so a contact is seen before it is made. */
649
+ private boundsOf(i: number): Aabb {
650
+ const shape = this.bodies.shape[i];
651
+ const parts = this.bodies.shapes[i];
652
+ if (!shape && !parts) {
653
+ this.box.minX = this.bodies.posX[i] ?? 0;
654
+ this.box.minY = this.bodies.posY[i] ?? 0;
655
+ this.box.minZ = this.bodies.posZ[i] ?? 0;
656
+ this.box.maxX = this.box.minX;
657
+ this.box.maxY = this.box.minY;
658
+ this.box.maxZ = this.box.minZ;
659
+ return this.box;
660
+ }
661
+ /*
662
+ * The bound radius rather than the rotated hull: a sphere about the centre encloses the shape
663
+ * in every orientation, so a turning body never needs its bounds recomputed from its vertices.
664
+ * What this gives up is tightness for a long thin body; what would make it wrong is a
665
+ * measurement showing the broadphase reporting far more candidates than it should.
666
+ */
667
+ /*
668
+ * A compound's radius is the largest of its parts' and not their sum, because every part is
669
+ * already in the body's frame: one built off to a side carries that offset in its own radius.
670
+ */
671
+ const r =
672
+ (parts ? partsBoundRadius(parts) : (shape as ConvexShape).boundRadius) +
673
+ this.speculativeMargin;
674
+ const x = this.bodies.posX[i] ?? 0;
675
+ const y = this.bodies.posY[i] ?? 0;
676
+ const z = this.bodies.posZ[i] ?? 0;
677
+ this.box.minX = x - r;
678
+ this.box.minY = y - r;
679
+ this.box.minZ = z - r;
680
+ this.box.maxX = x + r;
681
+ this.box.maxY = y + r;
682
+ this.box.maxZ = z + r;
683
+ return this.box;
684
+ }
685
+
686
+ private readPose(i: number, out: ShapePose): void {
687
+ out.x = this.bodies.posX[i] ?? 0;
688
+ out.y = this.bodies.posY[i] ?? 0;
689
+ out.z = this.bodies.posZ[i] ?? 0;
690
+ out.qx = this.bodies.rotX[i] ?? 0;
691
+ out.qy = this.bodies.rotY[i] ?? 0;
692
+ out.qz = this.bodies.rotZ[i] ?? 0;
693
+ out.qw = this.bodies.rotW[i] ?? 1;
694
+ }
695
+
696
+ private loadWarm(stored: Float32Array | undefined, m: Manifold): void {
697
+ for (let i = 0; i < m.count; i++) {
698
+ this.warmN[i] = 0;
699
+ this.warmT1[i] = 0;
700
+ this.warmT2[i] = 0;
701
+ if (!stored) continue;
702
+ // Matched by feature id, never by proximity: a tolerance would be order-dependent.
703
+ for (let k = 0; k + 3 < stored.length; k += 4) {
704
+ if (stored[k] === (m.featureIds[i] ?? 0)) {
705
+ this.warmN[i] = stored[k + 1] ?? 0;
706
+ this.warmT1[i] = stored[k + 2] ?? 0;
707
+ this.warmT2[i] = stored[k + 3] ?? 0;
708
+ break;
709
+ }
710
+ }
711
+ }
712
+ }
713
+
714
+ private storeWarm(): void {
715
+ const c = this.constraints;
716
+ for (let k = 0; k < c.count; k++) {
717
+ const key = (c.bodyA[k] ?? 0) * MAX_BODIES + (c.bodyB[k] ?? 0);
718
+ const start = c.pointStart[k] ?? 0;
719
+ const n = c.pointCount[k] ?? 0;
720
+ /*
721
+ * **Appended rather than replaced, because a mesh pair produces several constraints.** Every
722
+ * other pair produces one and the two are the same code path; a `set` here would keep the
723
+ * last of a mesh pair's planes and throw the rest away, which is a floor that warm-starts
724
+ * and a wall that does not. `loadWarm` matches by feature id and `meshContact.ts` folds the
725
+ * triangle into every id, so one array per pair stays unambiguous.
726
+ */
727
+ const before = this.nextWarm.get(key);
728
+ const at = before?.length ?? 0;
729
+ const store = new Float32Array(at + n * 4);
730
+ if (before) store.set(before);
731
+ for (let i = 0; i < n; i++) {
732
+ store[at + i * 4] = c.featureId[start + i] ?? 0;
733
+ store[at + i * 4 + 1] = c.normalImpulse[start + i] ?? 0;
734
+ store[at + i * 4 + 2] = c.tangent1Impulse[start + i] ?? 0;
735
+ store[at + i * 4 + 3] = c.tangent2Impulse[start + i] ?? 0;
736
+ }
737
+ this.nextWarm.set(key, store);
738
+ }
739
+ const swap = this.warm;
740
+ this.warm = this.nextWarm;
741
+ this.nextWarm = swap;
742
+ }
743
+
744
+ /**
745
+ * Islands sleep, not bodies.
746
+ *
747
+ * A box that slept while its neighbour was still moving would be woken by that neighbour on the
748
+ * very next tick and never rest — so a body accumulates its counter only when **every** body in
749
+ * its island is below both thresholds, and the whole island resets together when one is not.
750
+ *
751
+ * Tick-counted and never time-based, because sleeping changes results and results have to replay.
752
+ */
753
+ private updateSleep(): void {
754
+ const bodies = this.bodies;
755
+ for (let island = 0; island < this.islands.count; island++) {
756
+ const from = this.islands.bodyStart[island] ?? 0;
757
+ const to = this.islands.bodyStart[island + 1] ?? from;
758
+ let allStill = true;
759
+ for (let at = from; at < to && allStill; at++) {
760
+ const i = this.islands.bodyOrder[at] ?? 0;
761
+ if (bodies.type[i] !== BODY_DYNAMIC) continue;
762
+ const vx = bodies.velX[i] ?? 0;
763
+ const vy = bodies.velY[i] ?? 0;
764
+ const vz = bodies.velZ[i] ?? 0;
765
+ const wx = bodies.angX[i] ?? 0;
766
+ const wy = bodies.angY[i] ?? 0;
767
+ const wz = bodies.angZ[i] ?? 0;
768
+ if (
769
+ vx * vx + vy * vy + vz * vz >= SLEEP_LINEAR * SLEEP_LINEAR ||
770
+ wx * wx + wy * wy + wz * wz >= SLEEP_ANGULAR * SLEEP_ANGULAR
771
+ ) {
772
+ allStill = false;
773
+ }
774
+ }
775
+ for (let at = from; at < to; at++) {
776
+ const i = this.islands.bodyOrder[at] ?? 0;
777
+ if (bodies.type[i] !== BODY_DYNAMIC) continue;
778
+ bodies.sleepTicks[i] = allStill
779
+ ? Math.min(SLEEP_TICKS, (bodies.sleepTicks[i] ?? 0) + 1)
780
+ : 0;
781
+ }
782
+ }
783
+ }
784
+
785
+ /** Wake a body and everything sharing its island, which is what an external impulse must do. */
786
+ wakeIsland(index: number): void {
787
+ const island = this.islands.islandOf[index] ?? -1;
788
+ if (island < 0) {
789
+ this.bodies.sleepTicks[index] = 0;
790
+ return;
791
+ }
792
+ const from = this.islands.bodyStart[island] ?? 0;
793
+ const to = this.islands.bodyStart[island + 1] ?? from;
794
+ for (let at = from; at < to; at++) {
795
+ this.bodies.sleepTicks[this.islands.bodyOrder[at] ?? 0] = 0;
796
+ }
797
+ }
798
+
799
+ /** Whether every body in an island has been still long enough to stop being solved. */
800
+ /** The nearest body a ray meets. Fills `out` and allocates nothing. */
801
+ raycast(
802
+ ox: number,
803
+ oy: number,
804
+ oz: number,
805
+ dx: number,
806
+ dy: number,
807
+ dz: number,
808
+ maxDistance: number,
809
+ out: RayHit,
810
+ filter?: QueryFilter,
811
+ ): boolean {
812
+ return raycastWorld(this.bodies, this.tree, ox, oy, oz, dx, dy, dz, maxDistance, out, filter);
813
+ }
814
+
815
+ /** The first body a swept shape touches. */
816
+ shapecast(
817
+ shape: ConvexShape,
818
+ pose: ShapePose,
819
+ dx: number,
820
+ dy: number,
821
+ dz: number,
822
+ out: RayHit,
823
+ filter?: QueryFilter,
824
+ ): boolean {
825
+ return shapecastWorld(this.bodies, this.tree, shape, pose, dx, dy, dz, out, filter);
826
+ }
827
+
828
+ /** Every body overlapping a shape, filling `out` to its length. Returns how many were written. */
829
+ overlap(shape: ConvexShape, pose: ShapePose, out: Int32Array, filter?: QueryFilter): number {
830
+ return overlapWorld(this.bodies, this.tree, shape, pose, out, filter);
831
+ }
832
+
833
+ /**
834
+ * Apply an impulse at a world-space point.
835
+ *
836
+ * **The point is not optional**, and `bindings/core.ts` makes it required at the language boundary
837
+ * for the reason `AGENTS.md`'s step four gives: a caller who omits it gets an impulse through the
838
+ * centre of mass, which produces no rotation at all. That is a wrong *result* rather than an
839
+ * error, and it reads as a broken impulse rather than as a missing argument.
840
+ */
841
+ applyImpulse(
842
+ body: number,
843
+ px: number,
844
+ py: number,
845
+ pz: number,
846
+ atX: number,
847
+ atY: number,
848
+ atZ: number,
849
+ ): void {
850
+ const im = this.bodies.invMass[body] ?? 0;
851
+ if (im === 0) return;
852
+ this.bodies.velX[body] = (this.bodies.velX[body] ?? 0) + px * im;
853
+ this.bodies.velY[body] = (this.bodies.velY[body] ?? 0) + py * im;
854
+ this.bodies.velZ[body] = (this.bodies.velZ[body] ?? 0) + pz * im;
855
+ const rx = atX - (this.bodies.posX[body] ?? 0);
856
+ const ry = atY - (this.bodies.posY[body] ?? 0);
857
+ const rz = atZ - (this.bodies.posZ[body] ?? 0);
858
+ this.bodies.worldInverseInertia(body, IMPULSE_I);
859
+ const tx = ry * pz - rz * py;
860
+ const ty = rz * px - rx * pz;
861
+ const tz = rx * py - ry * px;
862
+ this.bodies.angX[body] =
863
+ (this.bodies.angX[body] ?? 0) +
864
+ (IMPULSE_I[0] ?? 0) * tx +
865
+ (IMPULSE_I[3] ?? 0) * ty +
866
+ (IMPULSE_I[4] ?? 0) * tz;
867
+ this.bodies.angY[body] =
868
+ (this.bodies.angY[body] ?? 0) +
869
+ (IMPULSE_I[3] ?? 0) * tx +
870
+ (IMPULSE_I[1] ?? 0) * ty +
871
+ (IMPULSE_I[5] ?? 0) * tz;
872
+ this.bodies.angZ[body] =
873
+ (this.bodies.angZ[body] ?? 0) +
874
+ (IMPULSE_I[4] ?? 0) * tx +
875
+ (IMPULSE_I[5] ?? 0) * ty +
876
+ (IMPULSE_I[2] ?? 0) * tz;
877
+ this.wakeIsland(body);
878
+ }
879
+
880
+ /** A force applied for one tick, which is an impulse of `force · dt`. */
881
+ applyForce(
882
+ body: number,
883
+ fx: number,
884
+ fy: number,
885
+ fz: number,
886
+ dt: number,
887
+ atX: number,
888
+ atY: number,
889
+ atZ: number,
890
+ ): void {
891
+ this.applyImpulse(body, fx * dt, fy * dt, fz * dt, atX, atY, atZ);
892
+ }
893
+
894
+ /** Set a body's velocity outright, waking whatever it was resting against. */
895
+ setVelocity(body: number, vx: number, vy: number, vz: number): void {
896
+ this.bodies.velX[body] = vx;
897
+ this.bodies.velY[body] = vy;
898
+ this.bodies.velZ[body] = vz;
899
+ this.wakeIsland(body);
900
+ }
901
+
902
+ /**
903
+ * Move a body outright, which is a teleport rather than a push.
904
+ *
905
+ * The broadphase proxy moves with it, or the body would be found where it used to be until it
906
+ * next drifted out of its own fat bounds.
907
+ */
908
+ setPosition(body: number, x: number, y: number, z: number): void {
909
+ this.bodies.posX[body] = x;
910
+ this.bodies.posY[body] = y;
911
+ this.bodies.posZ[body] = z;
912
+ this.tree.move(this.leafOf[body] ?? 0, this.boundsOf(body));
913
+ this.wakeIsland(body);
914
+ }
915
+
916
+ /** A body's mass, or zero where it has none because it is static or kinematic. */
917
+ bodyMass(body: number): number {
918
+ const im = this.bodies.invMass[body] ?? 0;
919
+ return im > 0 ? 1 / im : 0;
920
+ }
921
+
922
+ /** How many islands the last step found. */
923
+ get islandCount(): number {
924
+ return this.islands.count;
925
+ }
926
+
927
+ /** Whether a body is currently asleep, for a caller deciding whether to bother. */
928
+ sleeping(index: number): boolean {
929
+ return this.asleep(index);
930
+ }
931
+
932
+ /** Whether a body has been still long enough to stop being integrated. */
933
+ private asleep(i: number): boolean {
934
+ return bodyAsleep(this.bodies, this.allowSleep, i);
935
+ }
936
+ }
937
+
938
+ /**
939
+ * Never more workers than the machine has cores to spare.
940
+ *
941
+ * One is left for the thread that asked, which is solving its own share of the islands. A runtime
942
+ * that does not report `hardwareConcurrency` gets what it asked for, because guessing low there
943
+ * would silently halve a server's pool.
944
+ */
945
+ /**
946
+ * What the host says it has, or undefined where it says nothing.
947
+ *
948
+ * Its own function because two callers need it and one of them is a sentence a consumer reads:
949
+ * a clamp that cannot name the number it clamped to is the silent kind again.
950
+ */
951
+ function coreCount(): number | undefined {
952
+ return (globalThis.navigator as { hardwareConcurrency?: number } | undefined)
953
+ ?.hardwareConcurrency;
954
+ }
955
+
956
+ function clampWorkers(wanted: number): number {
957
+ const asked = Math.max(0, Math.floor(wanted));
958
+ const cores = coreCount();
959
+ if (asked === 0 || cores === undefined) return asked;
960
+ return Math.max(1, Math.min(asked, cores - 1));
961
+ }