@series-inc/rundot-syncplay 5.23.0-beta.7 → 5.23.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 (75) hide show
  1. package/README.md +116 -15
  2. package/dist/browser.d.ts +87 -55
  3. package/dist/browser.js +47 -52
  4. package/dist/certification.d.ts +32 -3
  5. package/dist/certification.js +88 -3
  6. package/dist/cjs/browser.js +259 -87
  7. package/dist/cjs/certification.js +87 -2
  8. package/dist/cjs/collision.js +58 -0
  9. package/dist/cjs/creator.js +2 -1
  10. package/dist/cjs/engine-complete.js +213 -3
  11. package/dist/cjs/engine-parity-matrix.js +19 -4
  12. package/dist/cjs/index.js +199 -132
  13. package/dist/cjs/math.js +342 -15
  14. package/dist/cjs/movement3d.js +230 -1
  15. package/dist/cjs/node.js +5 -1
  16. package/dist/cjs/noise.js +58 -0
  17. package/dist/cjs/physics-cert.js +147 -6
  18. package/dist/cjs/physics2d.js +10 -4
  19. package/dist/cjs/physics3d-joints.js +637 -0
  20. package/dist/cjs/physics3d-shared.js +288 -0
  21. package/dist/cjs/physics3d-solver.js +1351 -0
  22. package/dist/cjs/physics3d-vehicle.js +467 -0
  23. package/dist/cjs/physics3d.js +1057 -223
  24. package/dist/cjs/random.js +49 -0
  25. package/dist/cjs/replay-bundle.js +127 -20
  26. package/dist/cjs/sample-scenes.js +3 -0
  27. package/dist/cjs/sdk-offline.js +6 -32
  28. package/dist/cjs/sdk-session.js +156 -0
  29. package/dist/cjs/session-build.js +42 -0
  30. package/dist/cjs/testing.js +107 -0
  31. package/dist/cli.js +40 -12
  32. package/dist/collision.d.ts +12 -0
  33. package/dist/collision.js +52 -0
  34. package/dist/creator.d.ts +1 -1
  35. package/dist/creator.js +1 -1
  36. package/dist/engine-complete.js +214 -4
  37. package/dist/engine-parity-matrix.js +19 -4
  38. package/dist/index.d.ts +94 -89
  39. package/dist/index.js +45 -56
  40. package/dist/math.d.ts +59 -6
  41. package/dist/math.js +342 -15
  42. package/dist/movement3d.d.ts +73 -0
  43. package/dist/movement3d.js +229 -1
  44. package/dist/node.d.ts +4 -0
  45. package/dist/node.js +2 -0
  46. package/dist/noise.d.ts +7 -0
  47. package/dist/noise.js +51 -0
  48. package/dist/physics-cert.d.ts +1 -1
  49. package/dist/physics-cert.js +147 -6
  50. package/dist/physics2d.js +10 -4
  51. package/dist/physics3d-joints.d.ts +94 -0
  52. package/dist/physics3d-joints.js +634 -0
  53. package/dist/physics3d-shared.d.ts +72 -0
  54. package/dist/physics3d-shared.js +257 -0
  55. package/dist/physics3d-solver.d.ts +197 -0
  56. package/dist/physics3d-solver.js +1346 -0
  57. package/dist/physics3d-vehicle.d.ts +84 -0
  58. package/dist/physics3d-vehicle.js +463 -0
  59. package/dist/physics3d.d.ts +108 -8
  60. package/dist/physics3d.js +1006 -177
  61. package/dist/random.d.ts +7 -0
  62. package/dist/random.js +49 -0
  63. package/dist/replay-bundle.d.ts +40 -1
  64. package/dist/replay-bundle.js +126 -20
  65. package/dist/sample-scenes.js +3 -0
  66. package/dist/sdk-offline.js +6 -32
  67. package/dist/sdk-session.d.ts +47 -0
  68. package/dist/sdk-session.js +153 -0
  69. package/dist/session-build.d.ts +21 -0
  70. package/dist/session-build.js +39 -0
  71. package/dist/testing.d.ts +52 -0
  72. package/dist/testing.js +45 -0
  73. package/dist/tools/vite-plugin.d.ts +8 -0
  74. package/dist/tools/vite-plugin.js +63 -9
  75. package/package.json +11 -3
package/dist/physics3d.js CHANGED
@@ -1,5 +1,8 @@
1
1
  import { cloneCanonical, defaultChecksum } from './canonical.js';
2
2
  import { combineDeterministicPhysicsMaterial } from './deterministic-primitives.js';
3
+ import { deterministicSqrt, mulMat3Vec, quantizeAngle, quantizeDistance, quaternionFromQuarterTurns, quatToMat3, rotatePointByQuatInverse, worldInvInertia, } from './physics3d-shared.js';
4
+ import { generateManifold, solveRigidStep, WARM_KEY_BASE, } from './physics3d-solver.js';
5
+ import { createSolverJoints, } from './physics3d-joints.js';
3
6
  export const deterministicPhysicsCallback3D = {
4
7
  collisionEnter: 1,
5
8
  collisionStay: 2,
@@ -18,32 +21,268 @@ export function createDeterministicPhysicsWorld3D(bodies) {
18
21
  bodies: normalizeBodies(bodies),
19
22
  activePairs: [],
20
23
  activePairMetadata: [],
24
+ contactCache: [],
25
+ jointCache: [],
21
26
  };
22
27
  }
28
+ function worldInverseInertiaForBody(body) {
29
+ if (body.kind !== 'dynamic' || body.orientation === undefined) {
30
+ return [0, 0, 0, 0, 0, 0, 0, 0, 0];
31
+ }
32
+ const invInertiaLocal = deriveInverseInertia(body.shape, body.mass ?? 1);
33
+ const base = worldInvInertia(quatToMat3(body.orientation), invInertiaLocal);
34
+ const lock = body.lockAngular ?? {};
35
+ const lockArr = [lock.x === true ? 0 : 1, lock.y === true ? 0 : 1, lock.z === true ? 0 : 1];
36
+ const out = new Array(9).fill(0);
37
+ for (let row = 0; row < 3; row += 1) {
38
+ for (let col = 0; col < 3; col += 1) {
39
+ out[row * 3 + col] = base[row * 3 + col] * lockArr[row] * lockArr[col];
40
+ }
41
+ }
42
+ return out;
43
+ }
44
+ /**
45
+ * Bodies in the same contact island as `bodyId` (union-find over active pairs).
46
+ * Islands chain only through dynamic bodies — a static/kinematic body is a
47
+ * boundary, so wake does not leak across the floor to unrelated piles.
48
+ */
49
+ function contactIslandFor(world, bodyId) {
50
+ const dynamicIds = new Set(world.bodies.filter((body) => body.kind === 'dynamic').map((body) => body.id));
51
+ const parent = new Map();
52
+ const find = (id) => {
53
+ if (!parent.has(id)) {
54
+ parent.set(id, id);
55
+ }
56
+ let root = id;
57
+ while (parent.get(root) !== root) {
58
+ root = parent.get(root);
59
+ }
60
+ return root;
61
+ };
62
+ const union = (a, b) => {
63
+ parent.set(find(a), find(b));
64
+ };
65
+ for (const pair of world.activePairMetadata ?? []) {
66
+ if (dynamicIds.has(pair.a) && dynamicIds.has(pair.b)) {
67
+ union(pair.a, pair.b);
68
+ }
69
+ }
70
+ const targetRoot = find(bodyId);
71
+ const island = new Set([bodyId]);
72
+ for (const id of parent.keys()) {
73
+ if (find(id) === targetRoot) {
74
+ island.add(id);
75
+ }
76
+ }
77
+ return island;
78
+ }
79
+ function assertFinitePoint(point, label) {
80
+ if (!isFiniteNumber(point.x) || !isFiniteNumber(point.y) || !isFiniteNumber(point.z)) {
81
+ throw new Error(`${label} must have finite components`);
82
+ }
83
+ }
84
+ /**
85
+ * Apply a batch of deterministic impulses in one canonical-world rebuild.
86
+ * Semantically the impulses accumulate in list order (same math as repeated
87
+ * `applyDeterministicImpulse3D`, but velocities are quantized once per body at
88
+ * the end rather than per impulse), and every touched body's contact island is
89
+ * woken. One `O(bodies)` rebuild instead of one per impulse — this is the
90
+ * fast path for per-frame controller output (vehicles, KCC pushes).
91
+ */
92
+ export function applyDeterministicImpulses3D(world, impulses) {
93
+ if (impulses.length === 0) {
94
+ return world;
95
+ }
96
+ const bodyById = new Map(world.bodies.map((body) => [body.id, body]));
97
+ const linearDelta = new Map();
98
+ const angularDelta = new Map();
99
+ const invInertiaByBody = new Map();
100
+ for (const entry of impulses) {
101
+ assertFinitePoint(entry.impulse, 'applyDeterministicImpulses3D: impulse');
102
+ if (entry.worldPoint !== undefined) {
103
+ assertFinitePoint(entry.worldPoint, 'applyDeterministicImpulses3D: worldPoint');
104
+ }
105
+ const target = bodyById.get(entry.bodyId);
106
+ if (target === undefined) {
107
+ throw new Error(`applyDeterministicImpulses3D: unknown body ${entry.bodyId}`);
108
+ }
109
+ const mass = target.mass ?? 1;
110
+ const baseInvMass = target.kind === 'dynamic' ? 1 / mass : 0;
111
+ const lockLinear = target.lockLinear ?? {};
112
+ const previousLinear = linearDelta.get(entry.bodyId) ?? { x: 0, y: 0, z: 0 };
113
+ linearDelta.set(entry.bodyId, {
114
+ x: previousLinear.x + entry.impulse.x * (lockLinear.x === true ? 0 : baseInvMass),
115
+ y: previousLinear.y + entry.impulse.y * (lockLinear.y === true ? 0 : baseInvMass),
116
+ z: previousLinear.z + entry.impulse.z * (lockLinear.z === true ? 0 : baseInvMass),
117
+ });
118
+ if (entry.worldPoint !== undefined && target.orientation !== undefined && target.kind === 'dynamic') {
119
+ let invInertia = invInertiaByBody.get(entry.bodyId);
120
+ if (invInertia === undefined) {
121
+ invInertia = worldInverseInertiaForBody(target);
122
+ invInertiaByBody.set(entry.bodyId, invInertia);
123
+ }
124
+ const lever = { x: entry.worldPoint.x - target.x, y: entry.worldPoint.y - target.y, z: entry.worldPoint.z - target.z };
125
+ const angularKick = mulMat3Vec(invInertia, cross(lever, entry.impulse));
126
+ const previousAngular = angularDelta.get(entry.bodyId) ?? { x: 0, y: 0, z: 0 };
127
+ angularDelta.set(entry.bodyId, {
128
+ x: previousAngular.x + angularKick.x,
129
+ y: previousAngular.y + angularKick.y,
130
+ z: previousAngular.z + angularKick.z,
131
+ });
132
+ }
133
+ }
134
+ const woken = new Set();
135
+ for (const bodyId of linearDelta.keys()) {
136
+ for (const member of contactIslandFor(world, bodyId)) {
137
+ woken.add(member);
138
+ }
139
+ }
140
+ const bodies = world.bodies.map((body) => {
141
+ const dv = linearDelta.get(body.id);
142
+ if (dv === undefined) {
143
+ if (woken.has(body.id) && body.kind === 'dynamic') {
144
+ return { ...body, sleeping: false, lowVelFrames: 0 };
145
+ }
146
+ return body;
147
+ }
148
+ const updated = {
149
+ ...body,
150
+ vx: quantizeDistance(body.vx + dv.x),
151
+ vy: quantizeDistance(body.vy + dv.y),
152
+ vz: quantizeDistance(body.vz + dv.z),
153
+ sleeping: body.kind === 'dynamic' ? false : body.sleeping,
154
+ lowVelFrames: 0,
155
+ };
156
+ const dw = angularDelta.get(body.id);
157
+ if (dw === undefined) {
158
+ return updated;
159
+ }
160
+ return {
161
+ ...updated,
162
+ angularVel: {
163
+ x: quantizeDistance((body.angularVel?.x ?? 0) + dw.x),
164
+ y: quantizeDistance((body.angularVel?.y ?? 0) + dw.y),
165
+ z: quantizeDistance((body.angularVel?.z ?? 0) + dw.z),
166
+ },
167
+ };
168
+ });
169
+ return { ...world, bodies: normalizeBodies(bodies, 'trusted') };
170
+ }
171
+ /**
172
+ * Apply a deterministic impulse to a body (linear + angular via lever arm),
173
+ * waking the body and its contact island. Returns a new canonical world.
174
+ */
175
+ export function applyDeterministicImpulse3D(world, bodyId, impulse, worldPoint) {
176
+ assertFinitePoint(impulse, 'applyDeterministicImpulse3D: impulse');
177
+ if (worldPoint !== undefined) {
178
+ assertFinitePoint(worldPoint, 'applyDeterministicImpulse3D: worldPoint');
179
+ }
180
+ const target = world.bodies.find((body) => body.id === bodyId);
181
+ if (target === undefined) {
182
+ throw new Error(`applyDeterministicImpulse3D: unknown body ${bodyId}`);
183
+ }
184
+ const island = contactIslandFor(world, bodyId);
185
+ const mass = target.mass ?? 1;
186
+ const baseInvMass = target.kind === 'dynamic' ? 1 / mass : 0;
187
+ const lockLinear = target.lockLinear ?? {};
188
+ const dv = {
189
+ x: impulse.x * (lockLinear.x === true ? 0 : baseInvMass),
190
+ y: impulse.y * (lockLinear.y === true ? 0 : baseInvMass),
191
+ z: impulse.z * (lockLinear.z === true ? 0 : baseInvMass),
192
+ };
193
+ let angular = target.angularVel;
194
+ if (worldPoint !== undefined && target.orientation !== undefined && target.kind === 'dynamic') {
195
+ const r = { x: worldPoint.x - target.x, y: worldPoint.y - target.y, z: worldPoint.z - target.z };
196
+ const angImpulse = cross(r, impulse);
197
+ const dw = mulMat3Vec(worldInverseInertiaForBody(target), angImpulse);
198
+ angular = {
199
+ x: (target.angularVel?.x ?? 0) + dw.x,
200
+ y: (target.angularVel?.y ?? 0) + dw.y,
201
+ z: (target.angularVel?.z ?? 0) + dw.z,
202
+ };
203
+ }
204
+ const bodies = world.bodies.map((body) => {
205
+ if (body.id === bodyId) {
206
+ const updated = {
207
+ ...body,
208
+ vx: quantizeDistance(body.vx + dv.x),
209
+ vy: quantizeDistance(body.vy + dv.y),
210
+ vz: quantizeDistance(body.vz + dv.z),
211
+ sleeping: body.kind === 'dynamic' ? false : body.sleeping,
212
+ lowVelFrames: 0,
213
+ };
214
+ return angular !== undefined
215
+ ? { ...updated, angularVel: { x: quantizeDistance(angular.x), y: quantizeDistance(angular.y), z: quantizeDistance(angular.z) } }
216
+ : updated;
217
+ }
218
+ if (island.has(body.id) && body.kind === 'dynamic') {
219
+ return { ...body, sleeping: false, lowVelFrames: 0 };
220
+ }
221
+ return body;
222
+ });
223
+ return { ...world, bodies: normalizeBodies(bodies, 'trusted') };
224
+ }
225
+ /** Set a body's linear (and optional angular) velocity deterministically, waking its island. */
226
+ export function setDeterministicBodyVelocity3D(world, bodyId, velocity, angularVelocity) {
227
+ assertFinitePoint(velocity, 'setDeterministicBodyVelocity3D: velocity');
228
+ if (angularVelocity !== undefined) {
229
+ assertFinitePoint(angularVelocity, 'setDeterministicBodyVelocity3D: angularVelocity');
230
+ }
231
+ const target = world.bodies.find((body) => body.id === bodyId);
232
+ if (target === undefined) {
233
+ throw new Error(`setDeterministicBodyVelocity3D: unknown body ${bodyId}`);
234
+ }
235
+ const island = contactIslandFor(world, bodyId);
236
+ const bodies = world.bodies.map((body) => {
237
+ if (body.id === bodyId) {
238
+ const updated = {
239
+ ...body,
240
+ vx: quantizeDistance(velocity.x),
241
+ vy: quantizeDistance(velocity.y),
242
+ vz: quantizeDistance(velocity.z),
243
+ sleeping: body.kind === 'dynamic' ? false : body.sleeping,
244
+ lowVelFrames: 0,
245
+ };
246
+ return angularVelocity !== undefined
247
+ ? { ...updated, angularVel: { x: quantizeDistance(angularVelocity.x), y: quantizeDistance(angularVelocity.y), z: quantizeDistance(angularVelocity.z) } }
248
+ : updated;
249
+ }
250
+ if (island.has(body.id) && body.kind === 'dynamic') {
251
+ return { ...body, sleeping: false, lowVelFrames: 0 };
252
+ }
253
+ return body;
254
+ });
255
+ return { ...world, bodies: normalizeBodies(bodies, 'trusted') };
256
+ }
23
257
  export function stepDeterministicPhysicsWorld3D(world, options = {}) {
24
258
  const scheduledQueries = sortScheduledQueries(options.scheduledQueries ?? []);
25
259
  const prePhysicsScheduledQueryResults = scheduledQueries
26
260
  .filter((query) => query.phase === 'pre-physics')
27
261
  .map((query) => ({ id: query.id, hits: runScheduledQuery(world, query.query) }));
262
+ if (options.dtTicks !== undefined && options.dtTicks <= 0) {
263
+ throw new Error(`physics3d step: dtTicks must be a positive finite number, received ${options.dtTicks}`);
264
+ }
265
+ const velocityIterations = options.velocityIterations ?? DEFAULT_VELOCITY_ITERATIONS;
266
+ if (!Number.isInteger(velocityIterations) || velocityIterations <= 0) {
267
+ throw new Error(`physics3d step: velocityIterations must be a positive integer, received ${velocityIterations}`);
268
+ }
28
269
  const dt = options.dtTicks ?? 1;
29
270
  const gravityY = options.gravityY ?? 0;
30
- const integrated = normalizeBodies(world.bodies.map((body) => {
31
- if (body.kind !== 'dynamic') {
32
- return body;
33
- }
34
- const vy = body.vy + gravityY * dt;
35
- return {
36
- ...body,
37
- x: body.x + body.vx * dt,
38
- y: body.y + vy * dt,
39
- z: body.z + body.vz * dt,
40
- vy,
41
- ...integrateAngularState(body, dt),
42
- };
43
- }));
44
- const constrained = solveConstraints3D(integrated, options.constraints ?? []);
45
- const solved = solvePenetrations(constrained.bodies);
46
- const contacts = detectContacts(solved.bodies);
271
+ const rigid = runRigidSolver(world, dt, gravityY, velocityIterations, options.joints ?? []);
272
+ const constrained = solveConstraints3D(rigid.bodies, options.constraints ?? []);
273
+ const solvedBodies = constrained.bodies;
274
+ // Dynamic-involved solid-collision events come from the solver's manifolds (so
275
+ // an enter event coincides with the frame the arresting impulse is applied and
276
+ // carries its normalImpulse). Trigger/sensor pairs, and solid pairs the solver
277
+ // skips (no dynamic body — e.g. static-vs-static), come from overlap detection
278
+ // on the solved positions, preserving their existing event semantics.
279
+ const solidPairs = new Set(rigid.solidContacts.map((contact) => contact.pair));
280
+ // Only trigger/sensor pairs and fully non-dynamic pairs can survive the
281
+ // event filter, so reject everything else before the exact overlap test —
282
+ // dynamic solid pairs already got their events from the solver manifolds.
283
+ const overlapContacts = detectContacts(solvedBodies, (left, right) => left.trigger === true || left.sensor === true || right.trigger === true || right.sensor === true
284
+ || (left.kind !== 'dynamic' && right.kind !== 'dynamic')).filter((contact) => !solidPairs.has(contact.pair));
285
+ const contacts = [...rigid.solidContacts, ...overlapContacts].sort((left, right) => compareStrings(left.pair, right.pair));
47
286
  const activePairs = contacts.map((contact) => contact.pair).sort(compareStrings);
48
287
  const activePairMetadata = contacts.map((contact) => ({
49
288
  pair: contact.pair,
@@ -55,9 +294,13 @@ export function stepDeterministicPhysicsWorld3D(world, options = {}) {
55
294
  const previousPairMetadata = new Map((world.activePairMetadata ?? []).map((pair) => [pair.pair, pair]));
56
295
  const previousPairs = new Set(world.activePairs);
57
296
  const currentPairs = new Set(activePairs);
58
- const bodyById = new Map(solved.bodies.map((body) => [body.id, body]));
297
+ const bodyById = new Map(solvedBodies.map((body) => [body.id, body]));
59
298
  const events = [
60
- ...contacts.map((contact) => ({ ...contact, type: previousPairs.has(contact.pair) ? 'stay' : 'enter' })),
299
+ ...contacts.map((contact) => ({
300
+ ...contact,
301
+ type: previousPairs.has(contact.pair) ? 'stay' : 'enter',
302
+ normalImpulse: rigid.impulseByPair.get(contact.pair) ?? 0,
303
+ })),
61
304
  ...world.activePairs
62
305
  .filter((pair) => !currentPairs.has(pair))
63
306
  .sort(compareStrings)
@@ -71,26 +314,39 @@ export function stepDeterministicPhysicsWorld3D(world, options = {}) {
71
314
  b: previous?.b ?? b,
72
315
  trigger: previous?.trigger ?? false,
73
316
  sensor: previous?.sensor ?? false,
317
+ normalImpulse: 0,
74
318
  };
75
319
  }),
76
320
  ]
77
321
  .filter((event) => contactEventEnabled(event, bodyById))
78
322
  .sort(compareContactEvents);
79
- const nextWorld = { frame: world.frame + 1, bodies: solved.bodies, activePairs, activePairMetadata };
323
+ const nextWorld = {
324
+ frame: world.frame + 1,
325
+ bodies: solvedBodies,
326
+ activePairs,
327
+ activePairMetadata,
328
+ contactCache: rigid.contactCache,
329
+ jointCache: rigid.jointCache,
330
+ };
80
331
  const postPhysicsScheduledQueryResults = scheduledQueries
81
332
  .filter((query) => query.phase === 'post-physics')
82
333
  .map((query) => ({ id: query.id, hits: runScheduledQuery(nextWorld, query.query) }));
83
334
  const scheduledQueryResults = [...prePhysicsScheduledQueryResults, ...postPhysicsScheduledQueryResults];
335
+ const solverCorrections = rigid.narrowphaseContacts + constrained.corrections;
84
336
  return {
85
- world: cloneCanonical(nextWorld),
337
+ // nextWorld is assembled from freshly built, already-canonical parts this
338
+ // step, so the cloneCanonical round trip it used to pass through was
339
+ // value-identical — and measured as the single largest per-step cost.
340
+ world: nextWorld,
86
341
  contacts: events,
342
+ brokenJointIds: rigid.brokenJointIds,
87
343
  scheduledQueryResults,
88
344
  prePhysicsScheduledQueryResults,
89
345
  postPhysicsScheduledQueryResults,
90
- broadphasePairs: countCandidatePairs(solved.bodies),
91
- narrowphaseContacts: contacts.length,
92
- solverCorrections: solved.corrections + constrained.corrections,
93
- checksum: options.computeChecksum === false ? '' : defaultChecksum({ nextWorld, scheduledQueryResults, events, solverCorrections: solved.corrections + constrained.corrections }),
346
+ broadphasePairs: countCandidatePairs(solvedBodies),
347
+ narrowphaseContacts: rigid.narrowphaseContacts,
348
+ solverCorrections,
349
+ checksum: options.computeChecksum === false ? '' : defaultChecksum({ nextWorld, scheduledQueryResults, events, solverCorrections }),
94
350
  };
95
351
  }
96
352
  function fastStressStepPhysicsWorld3D(world, scheduledQueries) {
@@ -114,6 +370,8 @@ function fastStressStepPhysicsWorld3D(world, scheduledQueries) {
114
370
  }).sort((left, right) => compareStrings(left.id, right.id)),
115
371
  activePairs: [],
116
372
  activePairMetadata: [],
373
+ contactCache: world.contactCache ?? [],
374
+ jointCache: world.jointCache ?? [],
117
375
  };
118
376
  const postPhysicsScheduledQueryResults = sortedScheduledQueries
119
377
  .filter((query) => query.phase === 'post-physics')
@@ -129,42 +387,279 @@ function fastStressStepPhysicsWorld3D(world, scheduledQueries) {
129
387
  solverCorrections: 0,
130
388
  };
131
389
  }
390
+ const QUERY_INDEX_LEAF_SIZE = 4;
391
+ const queryIndexCache = new WeakMap();
392
+ /**
393
+ * Conservative world-space bounds as the narrow phase sees the body: bodies
394
+ * with a quaternion `orientation` use the |R|-transformed local AABB (the
395
+ * narrow phase ignores legacy quarter turns for them), everything else uses
396
+ * the exact quarter-turn-aware bounds.
397
+ */
398
+ const queryIndexBoundsCache = new WeakMap();
399
+ function queryIndexBoundsForBody(body) {
400
+ if (body.orientation === undefined) {
401
+ return toBounds(body);
402
+ }
403
+ const cached = queryIndexBoundsCache.get(body);
404
+ if (cached !== undefined) {
405
+ return cached;
406
+ }
407
+ const local = shapePoseBounds({ x: 0, y: 0, z: 0, shape: body.shape });
408
+ const centerX = (local.minX + local.maxX) / 2;
409
+ const centerY = (local.minY + local.maxY) / 2;
410
+ const centerZ = (local.minZ + local.maxZ) / 2;
411
+ const halfX = (local.maxX - local.minX) / 2;
412
+ const halfY = (local.maxY - local.minY) / 2;
413
+ const halfZ = (local.maxZ - local.minZ) / 2;
414
+ const m = quatToMat3(body.orientation);
415
+ const worldCenterX = body.x + m[0] * centerX + m[1] * centerY + m[2] * centerZ;
416
+ const worldCenterY = body.y + m[3] * centerX + m[4] * centerY + m[5] * centerZ;
417
+ const worldCenterZ = body.z + m[6] * centerX + m[7] * centerY + m[8] * centerZ;
418
+ const extentX = Math.abs(m[0]) * halfX + Math.abs(m[1]) * halfY + Math.abs(m[2]) * halfZ;
419
+ const extentY = Math.abs(m[3]) * halfX + Math.abs(m[4]) * halfY + Math.abs(m[5]) * halfZ;
420
+ const extentZ = Math.abs(m[6]) * halfX + Math.abs(m[7]) * halfY + Math.abs(m[8]) * halfZ;
421
+ const bounds = {
422
+ minX: worldCenterX - extentX,
423
+ maxX: worldCenterX + extentX,
424
+ minY: worldCenterY - extentY,
425
+ maxY: worldCenterY + extentY,
426
+ minZ: worldCenterZ - extentZ,
427
+ maxZ: worldCenterZ + extentZ,
428
+ };
429
+ queryIndexBoundsCache.set(body, bounds);
430
+ return bounds;
431
+ }
432
+ function buildQueryIndexNode(entries, order, start, end, nodes) {
433
+ let minX = Number.POSITIVE_INFINITY;
434
+ let maxX = Number.NEGATIVE_INFINITY;
435
+ let minY = Number.POSITIVE_INFINITY;
436
+ let maxY = Number.NEGATIVE_INFINITY;
437
+ let minZ = Number.POSITIVE_INFINITY;
438
+ let maxZ = Number.NEGATIVE_INFINITY;
439
+ for (let index = start; index < end; index += 1) {
440
+ const bounds = entries[order[index]].bounds;
441
+ minX = Math.min(minX, bounds.minX);
442
+ maxX = Math.max(maxX, bounds.maxX);
443
+ minY = Math.min(minY, bounds.minY);
444
+ maxY = Math.max(maxY, bounds.maxY);
445
+ minZ = Math.min(minZ, bounds.minZ);
446
+ maxZ = Math.max(maxZ, bounds.maxZ);
447
+ }
448
+ const nodeIndex = nodes.length;
449
+ if (end - start <= QUERY_INDEX_LEAF_SIZE) {
450
+ nodes.push({ minX, maxX, minY, maxY, minZ, maxZ, left: -1, right: -1, start, count: end - start });
451
+ return nodeIndex;
452
+ }
453
+ // split on the widest centroid axis; deterministic tiebreak order x → y → z
454
+ let axisMinX = Number.POSITIVE_INFINITY;
455
+ let axisMaxX = Number.NEGATIVE_INFINITY;
456
+ let axisMinY = Number.POSITIVE_INFINITY;
457
+ let axisMaxY = Number.NEGATIVE_INFINITY;
458
+ let axisMinZ = Number.POSITIVE_INFINITY;
459
+ let axisMaxZ = Number.NEGATIVE_INFINITY;
460
+ for (let index = start; index < end; index += 1) {
461
+ const bounds = entries[order[index]].bounds;
462
+ const cx = (bounds.minX + bounds.maxX) / 2;
463
+ const cy = (bounds.minY + bounds.maxY) / 2;
464
+ const cz = (bounds.minZ + bounds.maxZ) / 2;
465
+ axisMinX = Math.min(axisMinX, cx);
466
+ axisMaxX = Math.max(axisMaxX, cx);
467
+ axisMinY = Math.min(axisMinY, cy);
468
+ axisMaxY = Math.max(axisMaxY, cy);
469
+ axisMinZ = Math.min(axisMinZ, cz);
470
+ axisMaxZ = Math.max(axisMaxZ, cz);
471
+ }
472
+ const spanX = axisMaxX - axisMinX;
473
+ const spanY = axisMaxY - axisMinY;
474
+ const spanZ = axisMaxZ - axisMinZ;
475
+ const axis = spanY > spanX
476
+ ? (spanZ > spanY ? 2 : 1)
477
+ : (spanZ > spanX ? 2 : 0);
478
+ const centroidOnAxis = (entryIndex) => {
479
+ const bounds = entries[entryIndex].bounds;
480
+ if (axis === 0) {
481
+ return (bounds.minX + bounds.maxX) / 2;
482
+ }
483
+ return axis === 1 ? (bounds.minY + bounds.maxY) / 2 : (bounds.minZ + bounds.maxZ) / 2;
484
+ };
485
+ const slice = order.slice(start, end);
486
+ // full strict order (centroid, then body id) — never relies on sort stability
487
+ slice.sort((left, right) => centroidOnAxis(left) - centroidOnAxis(right)
488
+ || compareStrings(entries[left].body.id, entries[right].body.id));
489
+ for (let index = 0; index < slice.length; index += 1) {
490
+ order[start + index] = slice[index];
491
+ }
492
+ const mid = start + ((end - start) >> 1);
493
+ // reserve this node's slot before recursing so child indexes are stable
494
+ nodes.push({ minX, maxX, minY, maxY, minZ, maxZ, left: -1, right: -1, start: 0, count: 0 });
495
+ const left = buildQueryIndexNode(entries, order, start, mid, nodes);
496
+ const right = buildQueryIndexNode(entries, order, mid, end, nodes);
497
+ nodes[nodeIndex] = { minX, maxX, minY, maxY, minZ, maxZ, left, right, start: 0, count: 0 };
498
+ return nodeIndex;
499
+ }
500
+ /** O(1) body lookup backed by the per-world query-index cache. */
501
+ export function deterministicPhysicsBodyById3D(world, bodyId) {
502
+ return queryIndexFor(world).bodyById.get(bodyId);
503
+ }
504
+ function buildQueryIndexTree(bodies) {
505
+ const entries = bodies.map((body) => ({ body, bounds: queryIndexBoundsForBody(body) }));
506
+ const order = entries.map((_, index) => index);
507
+ const nodes = [];
508
+ if (entries.length > 0) {
509
+ buildQueryIndexNode(entries, order, 0, entries.length, nodes);
510
+ }
511
+ return { entries, order, nodes };
512
+ }
513
+ /**
514
+ * Static bodies keep object identity across world snapshots (the solver and
515
+ * the trusted normalize paths pass them through by reference), so their
516
+ * subtree — usually the bulk of a level — is reused frame over frame; only
517
+ * the dynamic subtree is rebuilt. Identity comparison makes reuse exact.
518
+ */
519
+ let staticQueryTreeCache;
520
+ function sameBodyIdentities(left, right) {
521
+ if (left.length !== right.length) {
522
+ return false;
523
+ }
524
+ for (let index = 0; index < left.length; index += 1) {
525
+ if (left[index] !== right[index]) {
526
+ return false;
527
+ }
528
+ }
529
+ return true;
530
+ }
531
+ function queryIndexFor(world) {
532
+ const cached = queryIndexCache.get(world);
533
+ if (cached !== undefined) {
534
+ return cached;
535
+ }
536
+ const statics = [];
537
+ const movers = [];
538
+ for (const body of world.bodies) {
539
+ if (body.kind === 'static') {
540
+ statics.push(body);
541
+ }
542
+ else {
543
+ movers.push(body);
544
+ }
545
+ }
546
+ let staticTree;
547
+ if (staticQueryTreeCache !== undefined && sameBodyIdentities(staticQueryTreeCache.statics, statics)) {
548
+ staticTree = staticQueryTreeCache.tree;
549
+ }
550
+ else {
551
+ staticTree = buildQueryIndexTree(statics);
552
+ staticQueryTreeCache = { statics, tree: staticTree };
553
+ }
554
+ const index = {
555
+ trees: movers.length > 0 ? [staticTree, buildQueryIndexTree(movers)] : [staticTree],
556
+ bodyById: new Map(world.bodies.map((body) => [body.id, body])),
557
+ };
558
+ queryIndexCache.set(world, index);
559
+ return index;
560
+ }
561
+ /** Collect entries whose bounds pass `nodeTest`, walking each tree iteratively. */
562
+ function collectQueryCandidates(index, nodeTest) {
563
+ const candidates = [];
564
+ for (const tree of index.trees) {
565
+ if (tree.nodes.length === 0) {
566
+ continue;
567
+ }
568
+ const stack = [0];
569
+ while (stack.length > 0) {
570
+ const node = tree.nodes[stack.pop()];
571
+ if (!nodeTest(node)) {
572
+ continue;
573
+ }
574
+ if (node.left === -1) {
575
+ for (let slot = node.start; slot < node.start + node.count; slot += 1) {
576
+ const entry = tree.entries[tree.order[slot]];
577
+ if (nodeTest(entry.bounds)) {
578
+ candidates.push(entry);
579
+ }
580
+ }
581
+ continue;
582
+ }
583
+ stack.push(node.right);
584
+ stack.push(node.left);
585
+ }
586
+ }
587
+ return candidates;
588
+ }
589
+ function rayQueryCandidates(index, query, expandBy) {
590
+ return collectQueryCandidates(index, (bounds) => {
591
+ const target = expandBy === undefined ? bounds : expandBoundsByBounds(bounds, expandBy);
592
+ return rayAabbDistanceExact(target, query.x, query.y, query.z, query.dx, query.dy, query.dz, query.maxDistance) !== undefined;
593
+ });
594
+ }
132
595
  export function raycastDeterministicPhysics3D(world, query) {
133
596
  const normalizedQuery = normalizeRaycastQuery3D(query);
134
- return world.bodies
135
- .filter((body) => queryBodyAllowed(body, normalizedQuery))
136
- .map((body) => {
137
- const hit = rayBodyDistance(body, normalizedQuery);
138
- return hit === undefined ? undefined : { bodyId: body.id, distance: hit };
597
+ return rayQueryCandidates(queryIndexFor(world), normalizedQuery)
598
+ .filter((entry) => queryBodyAllowed(entry.body, normalizedQuery))
599
+ .map((entry) => {
600
+ const hit = rayBodyDistance(entry.body, normalizedQuery);
601
+ return hit === undefined ? undefined : { bodyId: entry.body.id, distance: hit };
139
602
  })
140
603
  .filter((hit) => hit !== undefined)
141
604
  .sort(compareHits);
142
605
  }
143
606
  export function raycastDeterministicPhysics3DDetailed(world, query) {
144
607
  const normalizedQuery = normalizeRaycastQuery3D(query);
608
+ const bodyById = queryIndexFor(world).bodyById;
145
609
  return raycastDeterministicPhysics3D(world, query)
146
610
  .map((hit) => {
147
- const body = world.bodies.find((candidate) => candidate.id === hit.bodyId);
611
+ const body = bodyById.get(hit.bodyId);
148
612
  return detailedHitForBody(body, normalizedQuery, hit.distance, 0);
149
613
  })
150
614
  .sort(compareHits);
151
615
  }
616
+ /**
617
+ * Exact body-vs-query-shape overlap (the narrow phase of `overlapDeterministicPhysics3D`).
618
+ * Bodies with a full quaternion orientation use the exact solver geometry
619
+ * (SAT/closed-form) so overlap against tumbled props is correct; the legacy
620
+ * quarter-turn path is preserved for bodies without `orientation`.
621
+ */
622
+ function bodyOverlapsQueryShape(body, shape, x, y, z, queryBounds) {
623
+ if (body.orientation !== undefined) {
624
+ return orientedBodyOverlapsShape(body, shape, x, y, z);
625
+ }
626
+ return boundsOverlap(toBounds(body), queryBounds)
627
+ && shapesOverlap3D({ x, y, z, shape }, bodyPose(body));
628
+ }
152
629
  export function overlapDeterministicPhysics3D(world, query) {
153
630
  const queryBounds = shapeBounds(query.x, query.y, query.z, query.shape);
154
- return world.bodies
155
- .filter((body) => queryBodyAllowed(body, query))
156
- .filter((body) => boundsOverlap(toBounds(body), queryBounds))
157
- .filter((body) => shapesOverlap3D({ x: query.x, y: query.y, z: query.z, shape: query.shape }, bodyPose(body)))
158
- .map((body) => body.id)
631
+ return collectQueryCandidates(queryIndexFor(world), (bounds) => boundsOverlap(bounds, queryBounds))
632
+ .filter((entry) => queryBodyAllowed(entry.body, query))
633
+ .filter((entry) => bodyOverlapsQueryShape(entry.body, query.shape, query.x, query.y, query.z, queryBounds))
634
+ .map((entry) => entry.body.id)
159
635
  .sort(compareStrings);
160
636
  }
637
+ /** Exact overlap between an oriented body and a query shape via the solver manifold geometry. */
638
+ function orientedBodyOverlapsShape(body, queryShape, qx, qy, qz) {
639
+ const bodyGeom = {
640
+ index: 0,
641
+ p: { x: body.x, y: body.y, z: body.z },
642
+ q: body.orientation ?? { x: 0, y: 0, z: 0, w: 1 },
643
+ shape: toSolverShape(body.shape),
644
+ };
645
+ const queryGeom = {
646
+ index: 1,
647
+ p: { x: qx, y: qy, z: qz },
648
+ q: { x: 0, y: 0, z: 0, w: 1 },
649
+ shape: toSolverShape(queryShape),
650
+ };
651
+ return generateManifold(bodyGeom, queryGeom) !== undefined;
652
+ }
161
653
  export function shapeCastDeterministicPhysics3D(world, query) {
162
654
  const normalizedQuery = normalizeShapeCastQuery3D(query);
163
- return world.bodies
164
- .filter((body) => queryBodyAllowed(body, normalizedQuery))
165
- .map((body) => {
166
- const distance = shapeCastBodyDistance(body, normalizedQuery);
167
- return distance === undefined ? undefined : { bodyId: body.id, distance };
655
+ // prune with body bounds Minkowski-expanded by the cast shape's local bounds,
656
+ // so the cast reduces to a segment-vs-AABB test against the tree
657
+ const castExtents = shapeBounds(0, 0, 0, query.shape);
658
+ return rayQueryCandidates(queryIndexFor(world), normalizedQuery, castExtents)
659
+ .filter((entry) => queryBodyAllowed(entry.body, normalizedQuery))
660
+ .map((entry) => {
661
+ const distance = shapeCastBodyDistance(entry.body, normalizedQuery);
662
+ return distance === undefined ? undefined : { bodyId: entry.body.id, distance };
168
663
  })
169
664
  .filter((hit) => hit !== undefined)
170
665
  .sort(compareHits);
@@ -172,9 +667,10 @@ export function shapeCastDeterministicPhysics3D(world, query) {
172
667
  export function shapeCastDeterministicPhysics3DDetailed(world, query) {
173
668
  const normalizedQuery = normalizeShapeCastQuery3D(query);
174
669
  const castRadius = query.shape.type === 'sphere' ? query.shape.radius : 0;
670
+ const bodyById = queryIndexFor(world).bodyById;
175
671
  return shapeCastDeterministicPhysics3D(world, query)
176
672
  .map((hit) => {
177
- const body = world.bodies.find((candidate) => candidate.id === hit.bodyId);
673
+ const body = bodyById.get(hit.bodyId);
178
674
  return detailedHitForBody(body, normalizedQuery, hit.distance, castRadius);
179
675
  })
180
676
  .sort(compareHits);
@@ -784,11 +1280,269 @@ function callbackFixtureBody(id, x, trigger, callbackMask) {
784
1280
  callbackMask,
785
1281
  };
786
1282
  }
787
- function detectContacts(bodies) {
1283
+ const DEFAULT_LINEAR_DAMPING = 0.995;
1284
+ const DEFAULT_ANGULAR_DAMPING = 0.98;
1285
+ const DEFAULT_VELOCITY_ITERATIONS = 10;
1286
+ function toSolverPrimitiveShape(shape) {
1287
+ if (shape.type === 'box') {
1288
+ return { kind: 'box', half: { x: shape.halfX, y: shape.halfY, z: shape.halfZ } };
1289
+ }
1290
+ if (shape.type === 'sphere') {
1291
+ return { kind: 'sphere', radius: shape.radius };
1292
+ }
1293
+ return { kind: 'capsule', radius: shape.radius, halfHeight: shape.halfHeight };
1294
+ }
1295
+ function toSolverShape(shape) {
1296
+ if (shape.type === 'box' || shape.type === 'sphere' || shape.type === 'capsule') {
1297
+ return toSolverPrimitiveShape(shape);
1298
+ }
1299
+ if (shape.type === 'compound') {
1300
+ return {
1301
+ kind: 'compound',
1302
+ children: shape.children.map((child) => ({
1303
+ shape: toSolverPrimitiveShape(child.shape),
1304
+ offset: child.offset,
1305
+ q: quaternionFromQuarterTurns(child.quarterTurns ?? { x: 0, y: 0, z: 0 }),
1306
+ })),
1307
+ };
1308
+ }
1309
+ // mesh: local triangle-list, grouped into triples (static geometry).
1310
+ const triangles = [];
1311
+ for (let index = 0; index + 2 < shape.vertices.length; index += 3) {
1312
+ triangles.push([shape.vertices[index], shape.vertices[index + 1], shape.vertices[index + 2]]);
1313
+ }
1314
+ return { kind: 'mesh', triangles };
1315
+ }
1316
+ export function deriveInverseInertia(shape, mass) {
1317
+ if (shape.type === 'box') {
1318
+ const sx = 2 * shape.halfX;
1319
+ const sy = 2 * shape.halfY;
1320
+ const sz = 2 * shape.halfZ;
1321
+ const ix = (mass / 12) * (sy * sy + sz * sz);
1322
+ const iy = (mass / 12) * (sx * sx + sz * sz);
1323
+ const iz = (mass / 12) * (sx * sx + sy * sy);
1324
+ return { x: 1 / ix, y: 1 / iy, z: 1 / iz };
1325
+ }
1326
+ if (shape.type === 'sphere') {
1327
+ const i = (2 / 5) * mass * shape.radius * shape.radius;
1328
+ return { x: 1 / i, y: 1 / i, z: 1 / i };
1329
+ }
1330
+ if (shape.type === 'capsule') {
1331
+ const r = shape.radius;
1332
+ const h = 2 * shape.halfHeight;
1333
+ const iAxial = 0.5 * mass * r * r;
1334
+ const iLateral = (mass / 12) * (3 * r * r + h * h);
1335
+ return { x: 1 / iLateral, y: 1 / iAxial, z: 1 / iLateral };
1336
+ }
1337
+ if (shape.type === 'compound') {
1338
+ // union-AABB box approximation of the compound inertia (v1).
1339
+ const bounds = shapePoseBounds({ x: 0, y: 0, z: 0, shape });
1340
+ const half = { x: (bounds.maxX - bounds.minX) / 2, y: (bounds.maxY - bounds.minY) / 2, z: (bounds.maxZ - bounds.minZ) / 2 };
1341
+ return deriveInverseInertia({ type: 'box', halfX: Math.max(half.x, 1e-3), halfY: Math.max(half.y, 1e-3), halfZ: Math.max(half.z, 1e-3) }, mass);
1342
+ }
1343
+ // mesh: static, never rotates.
1344
+ return { x: 0, y: 0, z: 0 };
1345
+ }
1346
+ function buildSolverBody(body, index) {
1347
+ const isDynamic = body.kind === 'dynamic';
1348
+ const mass = body.mass ?? 1;
1349
+ const baseInvMass = isDynamic ? 1 / mass : 0;
1350
+ const lockLinear = body.lockLinear ?? {};
1351
+ const lockAngular = body.lockAngular ?? {};
1352
+ const hasOrientation = body.orientation !== undefined;
1353
+ return {
1354
+ index,
1355
+ id: body.id,
1356
+ isDynamic,
1357
+ sleeping: body.sleeping === true,
1358
+ p: { x: body.x, y: body.y, z: body.z },
1359
+ q: body.orientation ?? { x: 0, y: 0, z: 0, w: 1 },
1360
+ v: { x: body.vx, y: body.vy, z: body.vz },
1361
+ w: body.angularVel ?? { x: 0, y: 0, z: 0 },
1362
+ vb: { x: 0, y: 0, z: 0 },
1363
+ wb: { x: 0, y: 0, z: 0 },
1364
+ invMassVec: {
1365
+ x: lockLinear.x === true ? 0 : baseInvMass,
1366
+ y: lockLinear.y === true ? 0 : baseInvMass,
1367
+ z: lockLinear.z === true ? 0 : baseInvMass,
1368
+ },
1369
+ invInertiaLocal: isDynamic ? deriveInverseInertia(body.shape, mass) : { x: 0, y: 0, z: 0 },
1370
+ angularLock: hasOrientation
1371
+ ? { x: lockAngular.x === true ? 0 : 1, y: lockAngular.y === true ? 0 : 1, z: lockAngular.z === true ? 0 : 1 }
1372
+ : { x: 0, y: 0, z: 0 },
1373
+ matFriction: (body.material?.friction ?? 0) / 10,
1374
+ matRestitution: (body.material?.restitution ?? 0) / 10,
1375
+ linearDamping: body.linearDamping ?? DEFAULT_LINEAR_DAMPING,
1376
+ angularDamping: body.angularDamping ?? DEFAULT_ANGULAR_DAMPING,
1377
+ gravityScale: body.gravityScale ?? 1,
1378
+ hasOrientation,
1379
+ shape: toSolverShape(body.shape),
1380
+ lowVelFrames: body.lowVelFrames ?? 0,
1381
+ wakeForced: false,
1382
+ ccd: body.ccd === true,
1383
+ layer: body.layer,
1384
+ mask: body.mask,
1385
+ noCollide: body.trigger === true || body.sensor === true,
1386
+ };
1387
+ }
1388
+ function warmCacheToMap(cache, indexById) {
1389
+ const warm = new Map();
1390
+ // Cache entries are sorted by pair, so resolve ids to indices once per run
1391
+ // of equal pair strings instead of splitting every entry.
1392
+ let runPair;
1393
+ let a;
1394
+ let b;
1395
+ for (const entry of cache) {
1396
+ if (entry.pair !== runPair) {
1397
+ runPair = entry.pair;
1398
+ const [aId, bId] = entry.pair.split('|');
1399
+ a = indexById.get(aId);
1400
+ b = indexById.get(bId);
1401
+ }
1402
+ if (a === undefined || b === undefined) {
1403
+ continue;
1404
+ }
1405
+ const key = a * WARM_KEY_BASE + b;
1406
+ let list = warm.get(key);
1407
+ if (list === undefined) {
1408
+ list = [];
1409
+ warm.set(key, list);
1410
+ }
1411
+ list.push({
1412
+ anchor: { x: entry.anchorX, y: entry.anchorY, z: entry.anchorZ },
1413
+ normalImpulse: entry.normalImpulse,
1414
+ tangentImpulse1: entry.tangentImpulse1,
1415
+ tangentImpulse2: entry.tangentImpulse2,
1416
+ });
1417
+ }
1418
+ return warm;
1419
+ }
1420
+ function runRigidSolver(world, dt, gravityY, velocityIterations, joints) {
1421
+ const source = world.bodies;
1422
+ const indexById = new Map(source.map((body, index) => [body.id, index]));
1423
+ const solverBodies = source.map((body, index) => buildSolverBody(body, index));
1424
+ const warm = warmCacheToMap(world.contactCache ?? [], indexById);
1425
+ const previouslyBroken = new Set((world.jointCache ?? []).filter((entry) => entry.broken === true).map((entry) => entry.jointId));
1426
+ const solverJoints = createSolverJoints(joints, indexById, world.jointCache ?? []);
1427
+ // Suppress contacts for intact jointed pairs flagged disableCollision. A joint
1428
+ // that breaks this step suppresses one final step (it is not yet in
1429
+ // previouslyBroken), resuming collision next step.
1430
+ const noCollideKeys = joints
1431
+ .filter((joint) => joint.type !== 'grab' && 'disableCollision' in joint && joint.disableCollision === true && !previouslyBroken.has(joint.id))
1432
+ .map((joint) => {
1433
+ const jointBodies = joint;
1434
+ const aIndex = indexById.get(jointBodies.a);
1435
+ const bIndex = indexById.get(jointBodies.b);
1436
+ if (aIndex === undefined || bIndex === undefined) {
1437
+ return undefined; // createSolverJoints throws the canonical unknown-body error
1438
+ }
1439
+ return Math.min(aIndex, bIndex) * WARM_KEY_BASE + Math.max(aIndex, bIndex);
1440
+ })
1441
+ .filter((key) => key !== undefined);
1442
+ const noCollidePairs = new Set(noCollideKeys);
1443
+ const result = solveRigidStep(solverBodies, {
1444
+ dt,
1445
+ gravityY,
1446
+ velocityIterations,
1447
+ noCollidePairs: noCollidePairs.size > 0 ? noCollidePairs : undefined,
1448
+ warm,
1449
+ joints: solverJoints,
1450
+ });
1451
+ const bodies = source.map((body, index) => {
1452
+ if (body.kind !== 'dynamic') {
1453
+ return body;
1454
+ }
1455
+ const sb = solverBodies[index];
1456
+ const mapped = {
1457
+ ...body,
1458
+ x: quantizeDistance(sb.p.x),
1459
+ y: quantizeDistance(sb.p.y),
1460
+ z: quantizeDistance(sb.p.z),
1461
+ vx: quantizeDistance(sb.v.x),
1462
+ vy: quantizeDistance(sb.v.y),
1463
+ vz: quantizeDistance(sb.v.z),
1464
+ sleeping: sb.sleeping,
1465
+ lowVelFrames: sb.lowVelFrames,
1466
+ };
1467
+ if (body.orientation !== undefined) {
1468
+ return {
1469
+ ...mapped,
1470
+ orientation: {
1471
+ x: quantizeDistance(sb.q.x),
1472
+ y: quantizeDistance(sb.q.y),
1473
+ z: quantizeDistance(sb.q.z),
1474
+ w: quantizeDistance(sb.q.w),
1475
+ },
1476
+ angularVel: {
1477
+ x: quantizeDistance(sb.w.x),
1478
+ y: quantizeDistance(sb.w.y),
1479
+ z: quantizeDistance(sb.w.z),
1480
+ },
1481
+ };
1482
+ }
1483
+ // legacy quarter-turn decoration for bodies without a quaternion orientation
1484
+ return { ...mapped, ...integrateAngularState(body, dt) };
1485
+ });
1486
+ const contactCache = warmMapToCache(result.warm, solverBodies);
1487
+ const jointCache = result.jointCache
1488
+ .map((entry) => ({
1489
+ jointId: entry.jointId,
1490
+ impulseX: quantizeDistance(entry.impulseX),
1491
+ impulseY: quantizeDistance(entry.impulseY),
1492
+ impulseZ: quantizeDistance(entry.impulseZ),
1493
+ axialImpulse: quantizeDistance(entry.axialImpulse),
1494
+ hingeAngle: quantizeDistance(entry.hingeAngle),
1495
+ ...(entry.broken === true ? { broken: true } : {}),
1496
+ }))
1497
+ .sort((left, right) => compareStrings(left.jointId, right.jointId));
1498
+ const brokenJointIds = jointCache
1499
+ .filter((entry) => entry.broken === true && !previouslyBroken.has(entry.jointId))
1500
+ .map((entry) => entry.jointId);
1501
+ const impulseByPair = new Map();
1502
+ const solidContacts = [];
1503
+ for (const impulse of result.contactImpulses) {
1504
+ impulseByPair.set(impulse.pair, quantizeDistance(impulse.normalImpulse));
1505
+ solidContacts.push({
1506
+ pair: impulse.pair,
1507
+ a: solverBodies[impulse.a].id,
1508
+ b: solverBodies[impulse.b].id,
1509
+ trigger: false,
1510
+ sensor: false,
1511
+ });
1512
+ }
1513
+ return { bodies, contactCache, jointCache, impulseByPair, solidContacts, narrowphaseContacts: result.manifolds.length, brokenJointIds };
1514
+ }
1515
+ function warmMapToCache(warm, bodies) {
1516
+ const entries = [];
1517
+ for (const [key, points] of warm.entries()) {
1518
+ const aIndex = Math.floor(key / WARM_KEY_BASE);
1519
+ const bIndex = key % WARM_KEY_BASE;
1520
+ const pair = `${bodies[aIndex].id}|${bodies[bIndex].id}`;
1521
+ for (const point of points) {
1522
+ entries.push({
1523
+ pair,
1524
+ anchorX: quantizeDistance(point.anchor.x),
1525
+ anchorY: quantizeDistance(point.anchor.y),
1526
+ anchorZ: quantizeDistance(point.anchor.z),
1527
+ normalImpulse: quantizeDistance(point.normalImpulse),
1528
+ tangentImpulse1: quantizeDistance(point.tangentImpulse1),
1529
+ tangentImpulse2: quantizeDistance(point.tangentImpulse2),
1530
+ });
1531
+ }
1532
+ }
1533
+ return entries.sort((left, right) => compareStrings(left.pair, right.pair)
1534
+ || left.anchorX - right.anchorX
1535
+ || left.anchorY - right.anchorY
1536
+ || left.anchorZ - right.anchorZ);
1537
+ }
1538
+ function detectContacts(bodies, keepPair) {
788
1539
  const contacts = [];
789
1540
  for (const pair of broadphaseCandidatePairs(bodies)) {
790
1541
  const left = pair.left;
791
1542
  const right = pair.right;
1543
+ if (keepPair !== undefined && !keepPair(left, right)) {
1544
+ continue;
1545
+ }
792
1546
  if ((left.mask & right.layer) === 0 ||
793
1547
  (right.mask & left.layer) === 0 ||
794
1548
  !boundsOverlap(pair.leftBounds, pair.rightBounds) ||
@@ -800,99 +1554,6 @@ function detectContacts(bodies) {
800
1554
  }
801
1555
  return contacts.sort((left, right) => compareStrings(left.pair, right.pair));
802
1556
  }
803
- function solvePenetrations(bodies) {
804
- const next = bodies.map((body) => ({ ...body }));
805
- let corrections = 0;
806
- for (const pair of broadphaseCandidatePairs(next)) {
807
- if (pair.left.trigger === true ||
808
- pair.right.trigger === true ||
809
- pair.left.sensor === true ||
810
- pair.right.sensor === true ||
811
- (pair.left.mask & pair.right.layer) === 0 ||
812
- (pair.right.mask & pair.left.layer) === 0) {
813
- continue;
814
- }
815
- const leftIndex = next.findIndex((body) => body.id === pair.left.id);
816
- const rightIndex = next.findIndex((body) => body.id === pair.right.id);
817
- const resolved = resolvePair(next[leftIndex], next[rightIndex]);
818
- if (resolved.left !== next[leftIndex] || resolved.right !== next[rightIndex]) {
819
- next[leftIndex] = resolved.left;
820
- next[rightIndex] = resolved.right;
821
- corrections += resolved.corrections;
822
- }
823
- }
824
- return { bodies: normalizeBodies(next), corrections };
825
- }
826
- function resolvePair(leftBody, rightBody) {
827
- if (leftBody.kind !== 'dynamic' && rightBody.kind !== 'dynamic') {
828
- return { left: leftBody, right: rightBody, corrections: 0 };
829
- }
830
- const left = toBounds(leftBody);
831
- const right = toBounds(rightBody);
832
- if (!boundsOverlap(left, right) ||
833
- !shapesOverlap3D(bodyPose(leftBody), bodyPose(rightBody))) {
834
- return { left: leftBody, right: rightBody, corrections: 0 };
835
- }
836
- const pushLeft = right.minX - left.maxX;
837
- const pushRight = right.maxX - left.minX;
838
- const pushDown = right.minY - left.maxY;
839
- const pushUp = right.maxY - left.minY;
840
- const pushBack = right.minZ - left.maxZ;
841
- const pushForward = right.maxZ - left.minZ;
842
- const correction = [
843
- { axis: 'x', delta: nearestAxisDelta(pushLeft, pushRight) },
844
- { axis: 'y', delta: nearestAxisDelta(pushDown, pushUp) },
845
- { axis: 'z', delta: nearestAxisDelta(pushBack, pushForward) },
846
- ].sort((leftCandidate, rightCandidate) => Math.abs(leftCandidate.delta) - Math.abs(rightCandidate.delta) ||
847
- compareStrings(leftCandidate.axis, rightCandidate.axis))[0];
848
- const leftDynamic = leftBody.kind === 'dynamic';
849
- const rightDynamic = rightBody.kind === 'dynamic';
850
- const leftShare = leftDynamic && rightDynamic ? 0.5 : leftDynamic ? 1 : 0;
851
- const rightShare = leftDynamic && rightDynamic ? -0.5 : rightDynamic ? -1 : 0;
852
- const material = combineDeterministicPhysicsMaterial(leftBody.material ?? defaultPhysicsMaterial3D, rightBody.material ?? defaultPhysicsMaterial3D);
853
- const leftResolved = applyAxisCorrection(leftBody, correction.axis, correction.delta * leftShare, material);
854
- const rightResolved = applyAxisCorrection(rightBody, correction.axis, correction.delta * rightShare, material);
855
- return { left: leftResolved, right: rightResolved, corrections: 1 };
856
- }
857
- function applyAxisCorrection(body, axis, delta, material) {
858
- if (delta === 0) {
859
- return body;
860
- }
861
- const quantized = quantizeDistance(delta);
862
- const normalVelocity = (velocity) => Math.trunc((-velocity * material.restitution) / 10);
863
- const tangentVelocity = (velocity) => Math.trunc((velocity * Math.max(0, 10 - material.friction)) / 10);
864
- if (axis === 'x') {
865
- return {
866
- ...body,
867
- x: quantizeDistance(body.x + quantized),
868
- vx: normalVelocity(body.vx),
869
- vy: tangentVelocity(body.vy),
870
- vz: tangentVelocity(body.vz),
871
- };
872
- }
873
- if (axis === 'y') {
874
- return {
875
- ...body,
876
- y: quantizeDistance(body.y + quantized),
877
- vx: tangentVelocity(body.vx),
878
- vy: normalVelocity(body.vy),
879
- vz: tangentVelocity(body.vz),
880
- };
881
- }
882
- return {
883
- ...body,
884
- x: body.x,
885
- y: body.y,
886
- z: quantizeDistance(body.z + quantized),
887
- vx: tangentVelocity(body.vx),
888
- vy: tangentVelocity(body.vy),
889
- vz: normalVelocity(body.vz),
890
- };
891
- }
892
- function nearestAxisDelta(negativeDelta, positiveDelta) {
893
- const useNegative = Number(Math.abs(negativeDelta) <= Math.abs(positiveDelta));
894
- return negativeDelta * useNegative + positiveDelta * (1 - useNegative);
895
- }
896
1557
  function solveConstraints3D(bodies, constraints) {
897
1558
  const next = bodies.map((body) => ({ ...body }));
898
1559
  let corrections = 0;
@@ -941,7 +1602,7 @@ function solveConstraints3D(bodies, constraints) {
941
1602
  corrections += 1;
942
1603
  }
943
1604
  }
944
- return { bodies: normalizeBodies(next), corrections };
1605
+ return { bodies: normalizeBodies(next, 'trusted'), corrections };
945
1606
  }
946
1607
  function moveBodyBy(body, dx, dy, dz) {
947
1608
  if (body.kind !== 'dynamic') {
@@ -992,14 +1653,109 @@ function broadphaseCandidatePairs(bodies) {
992
1653
  }
993
1654
  return pairs.sort((left, right) => compareStrings(left.left.id, right.left.id) || compareStrings(left.right.id, right.right.id));
994
1655
  }
995
- function normalizeBodies(bodies) {
996
- const sorted = bodies.map((body) => cloneCanonical(body)).sort((left, right) => compareStrings(left.id, right.id));
1656
+ /**
1657
+ * Canonicalize a body list. `strict` (the user-input boundary) deep-clones and
1658
+ * validates every body; `trusted` skips both for bodies the engine itself
1659
+ * built — they are already validated, already canonical values (the clone
1660
+ * round-trip preserves -0/NaN/undefined exactly, so skipping it is
1661
+ * value-identical), and immutable by repo-wide discipline, so sharing their
1662
+ * identity across world snapshots is safe (and lets derived-data caches keyed
1663
+ * on body identity persist across frames).
1664
+ */
1665
+ function normalizeBodies(bodies, mode = 'strict') {
1666
+ const source = mode === 'strict' ? bodies.map((body) => cloneCanonical(body)) : [...bodies];
1667
+ const sorted = source
1668
+ .map((body) => (body.kind === 'dynamic' && body.sleeping === undefined ? { ...body, sleeping: false } : body))
1669
+ .sort((left, right) => compareStrings(left.id, right.id));
997
1670
  const duplicate = sorted.find((body, index) => index > 0 && body.id === sorted[index - 1].id);
998
1671
  if (duplicate !== undefined) {
999
1672
  throw new Error(`Duplicate deterministic physics body id ${duplicate.id}`);
1000
1673
  }
1674
+ if (mode === 'strict') {
1675
+ for (const body of sorted) {
1676
+ validatePhysicsBody3D(body);
1677
+ }
1678
+ }
1001
1679
  return sorted;
1002
1680
  }
1681
+ function isFiniteNumber(value) {
1682
+ return typeof value === 'number' && Number.isFinite(value);
1683
+ }
1684
+ function validatePhysicsBody3D(body) {
1685
+ const id = body.id;
1686
+ for (const [name, value] of [['x', body.x], ['y', body.y], ['z', body.z], ['vx', body.vx], ['vy', body.vy], ['vz', body.vz]]) {
1687
+ if (!isFiniteNumber(value)) {
1688
+ throw new Error(`physics3d body ${id}: ${name} must be a finite number`);
1689
+ }
1690
+ }
1691
+ if (body.mass !== undefined && (!isFiniteNumber(body.mass) || body.mass <= 0)) {
1692
+ throw new Error(`physics3d body ${id}: mass must be a positive finite number`);
1693
+ }
1694
+ if (body.angularVel !== undefined) {
1695
+ for (const [name, value] of [['angularVel.x', body.angularVel.x], ['angularVel.y', body.angularVel.y], ['angularVel.z', body.angularVel.z]]) {
1696
+ if (!isFiniteNumber(value)) {
1697
+ throw new Error(`physics3d body ${id}: ${name} must be a finite number`);
1698
+ }
1699
+ }
1700
+ }
1701
+ for (const [name, value] of [['linearDamping', body.linearDamping], ['angularDamping', body.angularDamping], ['gravityScale', body.gravityScale]]) {
1702
+ if (value !== undefined && !isFiniteNumber(value)) {
1703
+ throw new Error(`physics3d body ${id}: ${name} must be a finite number`);
1704
+ }
1705
+ }
1706
+ if (body.orientation !== undefined) {
1707
+ const q = body.orientation;
1708
+ for (const [name, value] of [['orientation.x', q.x], ['orientation.y', q.y], ['orientation.z', q.z], ['orientation.w', q.w]]) {
1709
+ if (!isFiniteNumber(value)) {
1710
+ throw new Error(`physics3d body ${id}: ${name} must be a finite number`);
1711
+ }
1712
+ }
1713
+ if (q.x * q.x + q.y * q.y + q.z * q.z + q.w * q.w === 0) {
1714
+ throw new Error(`physics3d body ${id}: orientation quaternion must have non-zero length`);
1715
+ }
1716
+ }
1717
+ validatePhysicsShape3D(id, body.shape);
1718
+ }
1719
+ function validatePhysicsShape3D(id, shape) {
1720
+ if (shape.type === 'box') {
1721
+ for (const [name, value] of [['halfX', shape.halfX], ['halfY', shape.halfY], ['halfZ', shape.halfZ]]) {
1722
+ if (!isFiniteNumber(value) || value <= 0) {
1723
+ throw new Error(`physics3d body ${id}: box ${name} must be a positive finite number`);
1724
+ }
1725
+ }
1726
+ return;
1727
+ }
1728
+ if (shape.type === 'sphere') {
1729
+ if (!isFiniteNumber(shape.radius) || shape.radius <= 0) {
1730
+ throw new Error(`physics3d body ${id}: sphere radius must be a positive finite number`);
1731
+ }
1732
+ return;
1733
+ }
1734
+ if (shape.type === 'capsule') {
1735
+ if (!isFiniteNumber(shape.radius) || shape.radius <= 0) {
1736
+ throw new Error(`physics3d body ${id}: capsule radius must be a positive finite number`);
1737
+ }
1738
+ if (!isFiniteNumber(shape.halfHeight) || shape.halfHeight <= 0) {
1739
+ throw new Error(`physics3d body ${id}: capsule halfHeight must be a positive finite number`);
1740
+ }
1741
+ return;
1742
+ }
1743
+ if (shape.type === 'compound') {
1744
+ if (shape.children.length === 0) {
1745
+ throw new Error(`physics3d body ${id}: compound collider must have at least one child`);
1746
+ }
1747
+ for (const child of shape.children) {
1748
+ for (const [name, value] of [['offset.x', child.offset.x], ['offset.y', child.offset.y], ['offset.z', child.offset.z]]) {
1749
+ if (!isFiniteNumber(value)) {
1750
+ throw new Error(`physics3d body ${id}: compound child ${name} must be a finite number`);
1751
+ }
1752
+ }
1753
+ validatePhysicsShape3D(id, child.shape);
1754
+ }
1755
+ return;
1756
+ }
1757
+ // mesh: triangle-list validation happens in the geometry paths (meshTriangles).
1758
+ }
1003
1759
  function queryBodyAllowed(body, query) {
1004
1760
  if (query.layerMask !== undefined && (body.layer & query.layerMask) === 0) {
1005
1761
  return false;
@@ -1031,8 +1787,20 @@ function queryKindAllowsBody3D(body, query) {
1031
1787
  }
1032
1788
  return query.hitKinematics === true;
1033
1789
  }
1790
+ /**
1791
+ * Per-body bounds memo. Bodies are immutable and, on trusted normalize paths,
1792
+ * keep their identity across world snapshots while untouched (static props,
1793
+ * sleeping bodies) — so their bounds are derived once, not once per frame.
1794
+ */
1795
+ const bodyBoundsCache = new WeakMap();
1034
1796
  function toBounds(body) {
1035
- return shapePoseBounds(bodyPose(body));
1797
+ const cached = bodyBoundsCache.get(body);
1798
+ if (cached !== undefined) {
1799
+ return cached;
1800
+ }
1801
+ const bounds = shapePoseBounds(bodyPose(body));
1802
+ bodyBoundsCache.set(body, bounds);
1803
+ return bounds;
1036
1804
  }
1037
1805
  function shapeBounds(x, y, z, shape) {
1038
1806
  return shapePoseBounds({ x, y, z, shape });
@@ -1043,6 +1811,18 @@ function bodyPose(body) {
1043
1811
  function shapePoseBounds(pose) {
1044
1812
  const { x, y, z, shape } = pose;
1045
1813
  if (shape.type === 'box') {
1814
+ // unrotated boxes (the overwhelmingly common case) skip the 8-corner
1815
+ // expansion; x + (-half) === x - half in IEEE, so values are identical
1816
+ if ((pose.rx ?? 0) === 0 && (pose.ry ?? 0) === 0 && (pose.rz ?? 0) === 0) {
1817
+ return {
1818
+ minX: x - shape.halfX,
1819
+ maxX: x + shape.halfX,
1820
+ minY: y - shape.halfY,
1821
+ maxY: y + shape.halfY,
1822
+ minZ: z - shape.halfZ,
1823
+ maxZ: z + shape.halfZ,
1824
+ };
1825
+ }
1046
1826
  return boundsFromPoints(boxLocalCorners(shape).map((point) => translateRotatedPoint(point, pose)));
1047
1827
  }
1048
1828
  if (shape.type === 'mesh') {
@@ -1051,6 +1831,21 @@ function shapePoseBounds(pose) {
1051
1831
  }
1052
1832
  return boundsFromPoints(shape.vertices.map((vertex) => translateRotatedPoint(vertex, pose)));
1053
1833
  }
1834
+ if (shape.type === 'compound') {
1835
+ const childBounds = shape.children.map((child) => {
1836
+ const center = translateRotatedPoint(child.offset, pose);
1837
+ return shapePoseBounds({
1838
+ x: center.x,
1839
+ y: center.y,
1840
+ z: center.z,
1841
+ rx: child.quarterTurns?.x,
1842
+ ry: child.quarterTurns?.y,
1843
+ rz: child.quarterTurns?.z,
1844
+ shape: child.shape,
1845
+ });
1846
+ });
1847
+ return unionBounds3D(childBounds);
1848
+ }
1054
1849
  const radius = shape.type === 'sphere' ? shape.radius : shape.radius;
1055
1850
  const halfY = shape.type === 'capsule' ? shape.halfHeight + radius : radius;
1056
1851
  return { minX: x - radius, maxX: x + radius, minY: y - halfY, maxY: y + halfY, minZ: z - radius, maxZ: z + radius };
@@ -1156,6 +1951,12 @@ function angularGeometryProof() {
1156
1951
  && overlap.includes('rotated-box'));
1157
1952
  }
1158
1953
  function shapesOverlap3D(left, right) {
1954
+ if (left.shape.type === 'compound') {
1955
+ return compoundChildPoses(left).some((childPose) => shapesOverlap3D(childPose, right));
1956
+ }
1957
+ if (right.shape.type === 'compound') {
1958
+ return compoundChildPoses(right).some((childPose) => shapesOverlap3D(left, childPose));
1959
+ }
1159
1960
  if (left.shape.type === 'mesh') {
1160
1961
  return meshOverlapsShape(left, right);
1161
1962
  }
@@ -1222,7 +2023,29 @@ function meshOverlapsShape(mesh, other) {
1222
2023
  const bounds = shapeBounds(other.x, other.y, other.z, otherShape);
1223
2024
  return triangles.some((triangle) => triangleBoxOverlap(triangle, bounds));
1224
2025
  }
1225
- return triangles.some((left) => meshTriangles({ ...other, shape: otherShape }).some((right) => trianglesOverlap(left, right)));
2026
+ if (otherShape.type === 'mesh') {
2027
+ return triangles.some((left) => meshTriangles({ ...other, shape: otherShape }).some((right) => trianglesOverlap(left, right)));
2028
+ }
2029
+ // compound-vs-mesh: compound children are dynamic-only primitives; mesh colliders are static-only.
2030
+ return compoundChildPoses(other).some((childPose) => meshOverlapsShape(mesh, childPose));
2031
+ }
2032
+ function compoundChildPoses(pose) {
2033
+ const compound = pose.shape;
2034
+ if (compound.type !== 'compound') {
2035
+ return [];
2036
+ }
2037
+ return compound.children.map((child) => {
2038
+ const center = translateRotatedPoint(child.offset, pose);
2039
+ return {
2040
+ x: center.x,
2041
+ y: center.y,
2042
+ z: center.z,
2043
+ rx: (pose.rx ?? 0) + (child.quarterTurns?.x ?? 0),
2044
+ ry: (pose.ry ?? 0) + (child.quarterTurns?.y ?? 0),
2045
+ rz: (pose.rz ?? 0) + (child.quarterTurns?.z ?? 0),
2046
+ shape: child.shape,
2047
+ };
2048
+ });
1226
2049
  }
1227
2050
  function meshTriangles(pose) {
1228
2051
  const shape = pose.shape;
@@ -1487,6 +2310,19 @@ function pointInBounds(point, bounds) {
1487
2310
  && point.y >= bounds.minY && point.y <= bounds.maxY
1488
2311
  && point.z >= bounds.minZ && point.z <= bounds.maxZ;
1489
2312
  }
2313
+ function unionBounds3D(boundsList) {
2314
+ if (boundsList.length === 0) {
2315
+ throw new Error('unionBounds3D requires at least one bounds');
2316
+ }
2317
+ return boundsList.reduce((accumulator, bounds) => ({
2318
+ minX: Math.min(accumulator.minX, bounds.minX),
2319
+ maxX: Math.max(accumulator.maxX, bounds.maxX),
2320
+ minY: Math.min(accumulator.minY, bounds.minY),
2321
+ maxY: Math.max(accumulator.maxY, bounds.maxY),
2322
+ minZ: Math.min(accumulator.minZ, bounds.minZ),
2323
+ maxZ: Math.max(accumulator.maxZ, bounds.maxZ),
2324
+ }));
2325
+ }
1490
2326
  function subtractPoint(left, right) {
1491
2327
  return { x: left.x - right.x, y: left.y - right.y, z: left.z - right.z };
1492
2328
  }
@@ -1531,6 +2367,19 @@ function rayAabbDistance(bounds, query) {
1531
2367
  return distance === undefined ? undefined : quantizeDistance(distance);
1532
2368
  }
1533
2369
  function rayBodyDistance(body, query) {
2370
+ if (body.orientation !== undefined) {
2371
+ // transform the ray into the body's local frame (isometry preserves the hit
2372
+ // distance for a unit-direction ray), then raycast the axis-aligned shape.
2373
+ const localOrigin = rotatePointByQuatInverse(body.orientation, { x: query.x - body.x, y: query.y - body.y, z: query.z - body.z });
2374
+ const localDir = rotatePointByQuatInverse(body.orientation, { x: query.dx, y: query.dy, z: query.dz });
2375
+ const localQuery = {
2376
+ ...query,
2377
+ x: localOrigin.x, y: localOrigin.y, z: localOrigin.z,
2378
+ dx: localDir.x, dy: localDir.y, dz: localDir.z,
2379
+ };
2380
+ const localBody = { ...body, x: 0, y: 0, z: 0, orientation: undefined, rx: undefined, ry: undefined, rz: undefined };
2381
+ return rayBodyDistance(localBody, localQuery);
2382
+ }
1534
2383
  if (body.shape.type === 'sphere') {
1535
2384
  return raySphereDistance(query.x, query.y, query.z, query.dx, query.dy, query.dz, body.x, body.y, body.z, body.shape.radius, query.maxDistance);
1536
2385
  }
@@ -1543,7 +2392,22 @@ function rayBodyDistance(body, query) {
1543
2392
  return rayAabbDistance(toBounds(body), query);
1544
2393
  }
1545
2394
  function shapeCastBodyDistance(body, query) {
1546
- if (overlapDeterministicPhysics3D(createDeterministicPhysicsWorld3D([body]), query).includes(body.id)) {
2395
+ if (body.orientation !== undefined) {
2396
+ // Transform the cast into the body's local frame (isometry preserves the hit
2397
+ // distance for a unit-direction cast), then cast against the axis-aligned shape.
2398
+ const localOrigin = rotatePointByQuatInverse(body.orientation, { x: query.x - body.x, y: query.y - body.y, z: query.z - body.z });
2399
+ const localDir = rotatePointByQuatInverse(body.orientation, { x: query.dx, y: query.dy, z: query.dz });
2400
+ const localQuery = {
2401
+ ...query,
2402
+ x: localOrigin.x, y: localOrigin.y, z: localOrigin.z,
2403
+ dx: localDir.x, dy: localDir.y, dz: localDir.z,
2404
+ };
2405
+ const localBody = { ...body, x: 0, y: 0, z: 0, orientation: undefined, rx: undefined, ry: undefined, rz: undefined };
2406
+ return shapeCastBodyDistance(localBody, localQuery);
2407
+ }
2408
+ // exact initial-overlap check without constructing a throwaway single-body
2409
+ // world (normalizeBodies clones + validates per call — a measured hot spot)
2410
+ if (bodyOverlapsQueryShape(body, query.shape, query.x, query.y, query.z, shapeBounds(query.x, query.y, query.z, query.shape))) {
1547
2411
  return 0;
1548
2412
  }
1549
2413
  if (query.shape.type === 'sphere') {
@@ -1790,41 +2654,6 @@ function expandBoundsByBounds(bounds, extents) {
1790
2654
  maxZ: bounds.maxZ - extents.minZ,
1791
2655
  };
1792
2656
  }
1793
- function quantizeDistance(distance) {
1794
- // Pure integer rounding, not Number.prototype.toFixed — see physics2d
1795
- // quantizeDistance (cross-engine half-way divergence on checksummed state).
1796
- const quantized = Math.round(distance * 1e6) / 1e6;
1797
- return quantized === 0 ? 0 : quantized;
1798
- }
1799
- function quantizeAngle(value) {
1800
- const wrapped = value - Math.floor(value);
1801
- const quantizedAngle = Math.round(wrapped * 1e6) / 1e6;
1802
- return quantizedAngle === 0 ? 0 : quantizedAngle;
1803
- }
1804
- function deterministicSqrt(value) {
1805
- if (value <= 0) {
1806
- return 0;
1807
- }
1808
- // Seed the Newton iteration from the argument's magnitude (2^ceil(bits/2)):
1809
- // starting from `value` itself needs ~0.5*log2(value) halving steps before
1810
- // the quadratic regime, so 8 iterations only converged for tiny inputs —
1811
- // at realistic fixed-point magnitudes (dx^2+dy^2 ~ 1e10) the old seed
1812
- // returned results off by orders of magnitude. Every operation here is
1813
- // exact integer/IEEE-deterministic arithmetic, so all peers still agree.
1814
- let estimate = 1;
1815
- let magnitude = value;
1816
- while (magnitude >= 4) {
1817
- magnitude /= 4;
1818
- estimate *= 2;
1819
- }
1820
- if (magnitude > 1) {
1821
- estimate *= 2;
1822
- }
1823
- for (let iteration = 0; iteration < 8; iteration += 1) {
1824
- estimate = (estimate + value / estimate) / 2;
1825
- }
1826
- return quantizeDistance(estimate);
1827
- }
1828
2657
  function countCandidatePairs(bodies) {
1829
2658
  return broadphaseCandidatePairs(bodies).length;
1830
2659
  }