@series-inc/rundot-syncplay 5.23.0 → 5.24.0-beta.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 (72) hide show
  1. package/README.md +20 -0
  2. package/dist/browser.d.ts +4 -1
  3. package/dist/browser.js +1 -0
  4. package/dist/checksum-basis.d.ts +4 -0
  5. package/dist/checksum-basis.js +17 -0
  6. package/dist/cjs/browser.js +4 -2
  7. package/dist/cjs/checksum-basis.js +22 -0
  8. package/dist/cjs/collider-cooking.js +834 -0
  9. package/dist/cjs/deterministic-session.js +2 -0
  10. package/dist/cjs/engine-parity-matrix.js +32 -9
  11. package/dist/cjs/engine-parity.js +1 -1
  12. package/dist/cjs/index.js +8 -2
  13. package/dist/cjs/instant-replay.js +4 -9
  14. package/dist/cjs/movement3d.js +17 -1
  15. package/dist/cjs/networked-client.js +11 -9
  16. package/dist/cjs/node.js +5 -1
  17. package/dist/cjs/offline-session.js +52 -24
  18. package/dist/cjs/physics-cert.js +7 -2
  19. package/dist/cjs/physics3d-hull.js +381 -0
  20. package/dist/cjs/physics3d-joints.js +163 -31
  21. package/dist/cjs/physics3d-recipes.js +197 -0
  22. package/dist/cjs/physics3d-shared.js +6 -0
  23. package/dist/cjs/physics3d-solver.js +746 -89
  24. package/dist/cjs/physics3d-stacking.js +154 -0
  25. package/dist/cjs/physics3d-toi.js +251 -0
  26. package/dist/cjs/physics3d-vehicle.js +35 -10
  27. package/dist/cjs/physics3d.js +401 -20
  28. package/dist/cjs/runtime-support.js +71 -0
  29. package/dist/cjs/session-promotion.js +5 -5
  30. package/dist/cjs/synctest.js +49 -0
  31. package/dist/cjs/testing.js +4 -1
  32. package/dist/cli.js +163 -41
  33. package/dist/collider-cooking.d.ts +141 -0
  34. package/dist/collider-cooking.js +829 -0
  35. package/dist/deterministic-session.js +2 -0
  36. package/dist/engine-parity-matrix.js +32 -9
  37. package/dist/engine-parity.js +1 -1
  38. package/dist/index.d.ts +3 -1
  39. package/dist/index.js +1 -0
  40. package/dist/instant-replay.d.ts +2 -2
  41. package/dist/instant-replay.js +4 -9
  42. package/dist/movement3d.js +18 -2
  43. package/dist/networked-client.js +11 -9
  44. package/dist/node.d.ts +2 -0
  45. package/dist/node.js +2 -0
  46. package/dist/offline-session.js +53 -25
  47. package/dist/physics-cert.js +7 -2
  48. package/dist/physics3d-hull.d.ts +53 -0
  49. package/dist/physics3d-hull.js +376 -0
  50. package/dist/physics3d-joints.d.ts +7 -0
  51. package/dist/physics3d-joints.js +163 -31
  52. package/dist/physics3d-recipes.d.ts +29 -0
  53. package/dist/physics3d-recipes.js +193 -0
  54. package/dist/physics3d-shared.js +6 -0
  55. package/dist/physics3d-solver.d.ts +65 -1
  56. package/dist/physics3d-solver.js +744 -89
  57. package/dist/physics3d-stacking.d.ts +26 -0
  58. package/dist/physics3d-stacking.js +147 -0
  59. package/dist/physics3d-toi.d.ts +70 -0
  60. package/dist/physics3d-toi.js +245 -0
  61. package/dist/physics3d-vehicle.js +35 -10
  62. package/dist/physics3d.d.ts +20 -2
  63. package/dist/physics3d.js +400 -21
  64. package/dist/runtime-support.d.ts +60 -0
  65. package/dist/runtime-support.js +66 -0
  66. package/dist/session-promotion.js +5 -5
  67. package/dist/synctest.d.ts +1 -1
  68. package/dist/synctest.js +49 -0
  69. package/dist/testing.d.ts +2 -0
  70. package/dist/testing.js +1 -0
  71. package/dist/types.d.ts +21 -2
  72. package/package.json +8 -3
@@ -7,6 +7,7 @@ exports.applyDeterministicImpulse3D = applyDeterministicImpulse3D;
7
7
  exports.setDeterministicBodyVelocity3D = setDeterministicBodyVelocity3D;
8
8
  exports.stepDeterministicPhysicsWorld3D = stepDeterministicPhysicsWorld3D;
9
9
  exports.deterministicPhysicsBodyById3D = deterministicPhysicsBodyById3D;
10
+ exports.__staticTreeBuildCount = __staticTreeBuildCount;
10
11
  exports.raycastDeterministicPhysics3D = raycastDeterministicPhysics3D;
11
12
  exports.raycastDeterministicPhysics3DDetailed = raycastDeterministicPhysics3DDetailed;
12
13
  exports.overlapDeterministicPhysics3D = overlapDeterministicPhysics3D;
@@ -20,11 +21,14 @@ exports.runDeterministicPhysics3DDetailFixture = runDeterministicPhysics3DDetail
20
21
  exports.runDeterministicPhysics3DStress = runDeterministicPhysics3DStress;
21
22
  exports.runDeterministicPhysics3DStackFixture = runDeterministicPhysics3DStackFixture;
22
23
  exports.deriveInverseInertia = deriveInverseInertia;
24
+ exports.__countCandidatePairsCalls = __countCandidatePairsCalls;
23
25
  const canonical_js_1 = require("./canonical.js");
24
26
  const deterministic_primitives_js_1 = require("./deterministic-primitives.js");
25
27
  const physics3d_shared_js_1 = require("./physics3d-shared.js");
26
28
  const physics3d_solver_js_1 = require("./physics3d-solver.js");
27
29
  const physics3d_joints_js_1 = require("./physics3d-joints.js");
30
+ const physics3d_hull_js_1 = require("./physics3d-hull.js");
31
+ const physics3d_toi_js_1 = require("./physics3d-toi.js");
28
32
  exports.deterministicPhysicsCallback3D = {
29
33
  collisionEnter: 1,
30
34
  collisionStay: 2,
@@ -288,9 +292,13 @@ function stepDeterministicPhysicsWorld3D(world, options = {}) {
288
292
  if (!Number.isInteger(velocityIterations) || velocityIterations <= 0) {
289
293
  throw new Error(`physics3d step: velocityIterations must be a positive integer, received ${velocityIterations}`);
290
294
  }
295
+ const substeps = options.substeps ?? physics3d_solver_js_1.SOLVER_CONSTANTS.SUBSTEPS;
296
+ if (!Number.isInteger(substeps) || substeps <= 0) {
297
+ throw new Error(`physics3d step: substeps must be a positive integer, received ${substeps}`);
298
+ }
291
299
  const dt = options.dtTicks ?? 1;
292
300
  const gravityY = options.gravityY ?? 0;
293
- const rigid = runRigidSolver(world, dt, gravityY, velocityIterations, options.joints ?? []);
301
+ const rigid = runRigidSolver(world, dt, gravityY, velocityIterations, substeps, options.joints ?? [], options.pairs);
294
302
  const constrained = solveConstraints3D(rigid.bodies, options.constraints ?? []);
295
303
  const solvedBodies = constrained.bodies;
296
304
  // Dynamic-involved solid-collision events come from the solver's manifolds (so
@@ -365,7 +373,9 @@ function stepDeterministicPhysicsWorld3D(world, options = {}) {
365
373
  scheduledQueryResults,
366
374
  prePhysicsScheduledQueryResults,
367
375
  postPhysicsScheduledQueryResults,
368
- broadphasePairs: countCandidatePairs(solvedBodies),
376
+ broadphasePairs: options.computeBroadphasePairs === true
377
+ ? countCandidatePairs(solvedBodies)
378
+ : 0,
369
379
  narrowphaseContacts: rigid.narrowphaseContacts,
370
380
  solverCorrections,
371
381
  checksum: options.computeChecksum === false ? '' : (0, canonical_js_1.defaultChecksum)({ nextWorld, scheduledQueryResults, events, solverCorrections }),
@@ -379,7 +389,9 @@ function fastStressStepPhysicsWorld3D(world, scheduledQueries) {
379
389
  const nextWorld = {
380
390
  frame: world.frame + 1,
381
391
  bodies: world.bodies.map((body) => {
382
- if (body.kind !== 'dynamic') {
392
+ // Kinematic bodies advance on their authored velocity here too — the
393
+ // extrapolated path skips the solver, not the author's driving motion.
394
+ if (body.kind === 'static') {
383
395
  return body;
384
396
  }
385
397
  return {
@@ -538,7 +550,12 @@ function buildQueryIndexTree(bodies) {
538
550
  * subtree — usually the bulk of a level — is reused frame over frame; only
539
551
  * the dynamic subtree is rebuilt. Identity comparison makes reuse exact.
540
552
  */
541
- let staticQueryTreeCache;
553
+ const STATIC_TREE_CACHE_SLOTS = 4;
554
+ const staticQueryTreeCache = [];
555
+ let staticTreeBuildCount = 0;
556
+ function __staticTreeBuildCount() {
557
+ return staticTreeBuildCount;
558
+ }
542
559
  function sameBodyIdentities(left, right) {
543
560
  if (left.length !== right.length) {
544
561
  return false;
@@ -550,6 +567,21 @@ function sameBodyIdentities(left, right) {
550
567
  }
551
568
  return true;
552
569
  }
570
+ function staticTreeFor(statics) {
571
+ const hit = staticQueryTreeCache.findIndex((entry) => sameBodyIdentities(entry.statics, statics));
572
+ if (hit !== -1) {
573
+ const [entry] = staticQueryTreeCache.splice(hit, 1);
574
+ staticQueryTreeCache.unshift(entry);
575
+ return entry.tree;
576
+ }
577
+ const tree = buildQueryIndexTree(statics);
578
+ staticTreeBuildCount += 1;
579
+ staticQueryTreeCache.unshift({ statics, tree });
580
+ if (staticQueryTreeCache.length > STATIC_TREE_CACHE_SLOTS) {
581
+ staticQueryTreeCache.pop();
582
+ }
583
+ return tree;
584
+ }
553
585
  function queryIndexFor(world) {
554
586
  const cached = queryIndexCache.get(world);
555
587
  if (cached !== undefined) {
@@ -565,14 +597,7 @@ function queryIndexFor(world) {
565
597
  movers.push(body);
566
598
  }
567
599
  }
568
- let staticTree;
569
- if (staticQueryTreeCache !== undefined && sameBodyIdentities(staticQueryTreeCache.statics, statics)) {
570
- staticTree = staticQueryTreeCache.tree;
571
- }
572
- else {
573
- staticTree = buildQueryIndexTree(statics);
574
- staticQueryTreeCache = { statics, tree: staticTree };
575
- }
600
+ const staticTree = staticTreeFor(statics);
576
601
  const index = {
577
602
  trees: movers.length > 0 ? [staticTree, buildQueryIndexTree(movers)] : [staticTree],
578
603
  bodyById: new Map(world.bodies.map((body) => [body.id, body])),
@@ -1084,7 +1109,11 @@ function runDeterministicPhysics3DStress(bodies, frames, options = {}) {
1084
1109
  },
1085
1110
  ];
1086
1111
  const result = shouldRunFullStep
1087
- ? stepDeterministicPhysicsWorld3D(world, { computeChecksum: false, scheduledQueries })
1112
+ ? stepDeterministicPhysicsWorld3D(world, {
1113
+ computeChecksum: false,
1114
+ computeBroadphasePairs: true,
1115
+ scheduledQueries,
1116
+ })
1088
1117
  : fastStressStepPhysicsWorld3D(world, scheduledQueries);
1089
1118
  const shapeCastHits = frame % 64 === 0
1090
1119
  ? shapeCastDeterministicPhysics3D(world, {
@@ -1231,7 +1260,11 @@ function runPhysics3DStackWorld(initialWorld, frames) {
1231
1260
  if (frame === snapshotFrame) {
1232
1261
  snapshot = (0, canonical_js_1.cloneCanonical)(world);
1233
1262
  }
1234
- const result = stepDeterministicPhysicsWorld3D(world, { gravityY: -0.001, computeChecksum: false });
1263
+ const result = stepDeterministicPhysicsWorld3D(world, {
1264
+ gravityY: -0.001,
1265
+ computeChecksum: false,
1266
+ computeBroadphasePairs: true,
1267
+ });
1235
1268
  world = result.world;
1236
1269
  contactCount += result.narrowphaseContacts;
1237
1270
  solverCorrections += result.solverCorrections;
@@ -1304,7 +1337,9 @@ function callbackFixtureBody(id, x, trigger, callbackMask) {
1304
1337
  }
1305
1338
  const DEFAULT_LINEAR_DAMPING = 0.995;
1306
1339
  const DEFAULT_ANGULAR_DAMPING = 0.98;
1307
- const DEFAULT_VELOCITY_ITERATIONS = 10;
1340
+ // Per-substep iterations under the soft-step (TGS-Soft) scheme; the total solve
1341
+ // passes are substeps × (velocityIterations + 1 relax). Was 10 pre-soft-step.
1342
+ const DEFAULT_VELOCITY_ITERATIONS = 3;
1308
1343
  function toSolverPrimitiveShape(shape) {
1309
1344
  if (shape.type === 'box') {
1310
1345
  return { kind: 'box', half: { x: shape.halfX, y: shape.halfY, z: shape.halfZ } };
@@ -1312,12 +1347,18 @@ function toSolverPrimitiveShape(shape) {
1312
1347
  if (shape.type === 'sphere') {
1313
1348
  return { kind: 'sphere', radius: shape.radius };
1314
1349
  }
1350
+ if (shape.type === 'hull') {
1351
+ return { kind: 'hull', data: (0, physics3d_hull_js_1.buildSolverHull)(shape.vertices) };
1352
+ }
1315
1353
  return { kind: 'capsule', radius: shape.radius, halfHeight: shape.halfHeight };
1316
1354
  }
1317
1355
  function toSolverShape(shape) {
1318
1356
  if (shape.type === 'box' || shape.type === 'sphere' || shape.type === 'capsule') {
1319
1357
  return toSolverPrimitiveShape(shape);
1320
1358
  }
1359
+ if (shape.type === 'hull') {
1360
+ return { kind: 'hull', data: (0, physics3d_hull_js_1.buildSolverHull)(shape.vertices) };
1361
+ }
1321
1362
  if (shape.type === 'compound') {
1322
1363
  return {
1323
1364
  kind: 'compound',
@@ -1356,8 +1397,8 @@ function deriveInverseInertia(shape, mass) {
1356
1397
  const iLateral = (mass / 12) * (3 * r * r + h * h);
1357
1398
  return { x: 1 / iLateral, y: 1 / iAxial, z: 1 / iLateral };
1358
1399
  }
1359
- if (shape.type === 'compound') {
1360
- // union-AABB box approximation of the compound inertia (v1).
1400
+ if (shape.type === 'compound' || shape.type === 'hull') {
1401
+ // union-AABB box approximation of the inertia (v1 — same as compound).
1361
1402
  const bounds = shapePoseBounds({ x: 0, y: 0, z: 0, shape });
1362
1403
  const half = { x: (bounds.maxX - bounds.minX) / 2, y: (bounds.maxY - bounds.minY) / 2, z: (bounds.maxZ - bounds.minZ) / 2 };
1363
1404
  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);
@@ -1367,6 +1408,7 @@ function deriveInverseInertia(shape, mass) {
1367
1408
  }
1368
1409
  function buildSolverBody(body, index) {
1369
1410
  const isDynamic = body.kind === 'dynamic';
1411
+ const isKinematic = body.kind === 'kinematic';
1370
1412
  const mass = body.mass ?? 1;
1371
1413
  const baseInvMass = isDynamic ? 1 / mass : 0;
1372
1414
  const lockLinear = body.lockLinear ?? {};
@@ -1376,6 +1418,7 @@ function buildSolverBody(body, index) {
1376
1418
  index,
1377
1419
  id: body.id,
1378
1420
  isDynamic,
1421
+ isKinematic,
1379
1422
  sleeping: body.sleeping === true,
1380
1423
  p: { x: body.x, y: body.y, z: body.z },
1381
1424
  q: body.orientation ?? { x: 0, y: 0, z: 0, w: 1 },
@@ -1439,7 +1482,236 @@ function warmCacheToMap(cache, indexById) {
1439
1482
  }
1440
1483
  return warm;
1441
1484
  }
1442
- function runRigidSolver(world, dt, gravityY, velocityIterations, joints) {
1485
+ /**
1486
+ * Per-step fast-ccd set (box3d b3_isFast) with each fast body's exact first
1487
+ * time-of-impact. A body qualifies only when it is dynamic, awake, `ccd`, collidable
1488
+ * (not a trigger/sensor), has a positive minExtent and a GJK-supported shape, and its
1489
+ * axis-lock-masked intended motion exceeds `CCD_FAST_MOTION_FRACTION · minExtent`.
1490
+ *
1491
+ * Candidate targets are found with a cheap conservative bounding-sphere cast through
1492
+ * the shared query index, then the EXACT surface TOI against each eligible candidate is
1493
+ * computed by GJK conservative advancement (`conservativeToi`); the nearest is kept. A
1494
+ * grazing/separating approach yields no TOI (the exact sweep never touches), so nothing
1495
+ * to clamp — the body slides by. Derived fresh every step (never persisted) so it is
1496
+ * rollback-safe; iterated in body-index order.
1497
+ */
1498
+ function computeFastCcdClamps(world, source, solverBodies, dt, indexById, noCollidePairs) {
1499
+ const clamps = new Map();
1500
+ for (let index = 0; index < solverBodies.length; index += 1) {
1501
+ const sb = solverBodies[index];
1502
+ if (!sb.isDynamic || !sb.ccd || sb.sleeping || sb.noCollide) {
1503
+ continue;
1504
+ }
1505
+ const minExtent = (0, physics3d_solver_js_1.minExtentOf)(sb.shape, sb.q);
1506
+ if (!(minExtent > 0)) {
1507
+ continue; // degenerate (flat/zero-extent) shape ⇒ never fast (no divide)
1508
+ }
1509
+ // Intended per-step motion is the solver-effective velocity × dt; a solver-locked
1510
+ // axis (invMassVec component 0) contributes no motion, so a locked axis is ignored.
1511
+ const dx = sb.invMassVec.x === 0 ? 0 : sb.v.x * dt;
1512
+ const dy = sb.invMassVec.y === 0 ? 0 : sb.v.y * dt;
1513
+ const dz = sb.invMassVec.z === 0 ? 0 : sb.v.z * dt;
1514
+ const motion = (0, physics3d_shared_js_1.deterministicSqrt)(dx * dx + dy * dy + dz * dz);
1515
+ if (motion <= physics3d_solver_js_1.SOLVER_CONSTANTS.CCD_FAST_MOTION_FRACTION * minExtent) {
1516
+ continue; // slow enough that ordinary discrete contact suffices
1517
+ }
1518
+ const fastProxy = (0, physics3d_toi_js_1.toiProxyOf)(sb.shape, sb.p, sb.q);
1519
+ if (fastProxy === null) {
1520
+ continue; // mesh/compound ⇒ leave to the discrete solver
1521
+ }
1522
+ const invMotion = 1 / motion;
1523
+ const dirX = dx * invMotion;
1524
+ const dirY = dy * invMotion;
1525
+ const dirZ = dz * invMotion;
1526
+ const hit = firstExactToi(world, source, solverBodies, source[index], index, fastProxy, dirX, dirY, dirZ, motion, indexById, noCollidePairs);
1527
+ if (hit === undefined) {
1528
+ continue; // nothing eligible actually in the swept path ⇒ leave to the solver
1529
+ }
1530
+ clamps.set(index, {
1531
+ prePos: { x: sb.p.x, y: sb.p.y, z: sb.p.z },
1532
+ dirX,
1533
+ dirY,
1534
+ dirZ,
1535
+ maxAdvance: hit.toi,
1536
+ normalX: hit.normal.x,
1537
+ normalY: hit.normal.y,
1538
+ normalZ: hit.normal.z,
1539
+ targetIndices: hit.targetIndices,
1540
+ });
1541
+ }
1542
+ return clamps;
1543
+ }
1544
+ /**
1545
+ * Exact first surface TOI for a fast body swept along (dir) over `motion`. A conservative
1546
+ * bounding-sphere cast prunes candidates through the shared query index (a superset of
1547
+ * real hits); the exact TOI against each eligible candidate is then found by GJK
1548
+ * conservative advancement, and the nearest is returned. Eligibility mirrors the narrow
1549
+ * phase (self, trigger/sensor, two-way layer/mask, and `disableCollision`-suppressed
1550
+ * pairs excluded). Returns undefined when nothing eligible is actually struck within the
1551
+ * sweep (including a grazing pass that the fat bounding sphere flags but the true shapes
1552
+ * miss).
1553
+ */
1554
+ function firstExactToi(world, source, solverBodies, fastBody, fastIndex, fastProxy, dirX, dirY, dirZ, motion, indexById, noCollidePairs) {
1555
+ const sb = solverBodies[fastIndex];
1556
+ const hits = shapeCastDeterministicPhysics3D(world, {
1557
+ shape: { type: 'sphere', radius: (0, physics3d_solver_js_1.boundingRadiusOf)(sb.shape) },
1558
+ x: sb.p.x,
1559
+ y: sb.p.y,
1560
+ z: sb.p.z,
1561
+ dx: dirX,
1562
+ dy: dirY,
1563
+ dz: dirZ,
1564
+ maxDistance: motion,
1565
+ hitTriggers: false,
1566
+ hitSensors: false,
1567
+ });
1568
+ // Collect every eligible body struck at (or within slop of) the first TOI — a body
1569
+ // driven into the seam between two stacked boxes contacts both, and sharing the impact
1570
+ // impulse across them keeps each one's momentum below the coupled relax's stability
1571
+ // limit (a single concentrated impulse diverges the stack).
1572
+ let bestToi = Infinity;
1573
+ let bestNormal = { x: 0, y: 0, z: 0 };
1574
+ const targetIndices = [];
1575
+ for (const hit of hits) {
1576
+ if (hit.bodyId === fastBody.id) {
1577
+ continue;
1578
+ }
1579
+ const targetIndex = indexById.get(hit.bodyId);
1580
+ if (targetIndex === undefined) {
1581
+ continue;
1582
+ }
1583
+ const target = source[targetIndex];
1584
+ if ((fastBody.mask & target.layer) === 0 || (target.mask & fastBody.layer) === 0) {
1585
+ continue;
1586
+ }
1587
+ const key = Math.min(fastIndex, targetIndex) * physics3d_solver_js_1.WARM_KEY_BASE + Math.max(fastIndex, targetIndex);
1588
+ if (noCollidePairs.has(key)) {
1589
+ continue;
1590
+ }
1591
+ const tb = solverBodies[targetIndex];
1592
+ const targetProxy = (0, physics3d_toi_js_1.toiProxyOf)(tb.shape, tb.p, tb.q);
1593
+ if (targetProxy === null) {
1594
+ continue; // mesh/compound target has no convex proxy — CCD unsupported (Gap 2), discrete owns it
1595
+ }
1596
+ const toiHit = (0, physics3d_toi_js_1.conservativeToi)(fastProxy, dirX, dirY, dirZ, motion, targetProxy, physics3d_solver_js_1.SOLVER_CONSTANTS.SLOP);
1597
+ if (toiHit === undefined) {
1598
+ continue; // clean graze — not actually struck, look past it
1599
+ }
1600
+ if ('overlapping' in toiHit) {
1601
+ // The fast body already touches this body at the step start — a zero-distance
1602
+ // contact. Resolve the impact HERE (advance 0, arrest along the motion axis)
1603
+ // instead of skipping it to bind a farther target's TOI (which would sweep the
1604
+ // fast body through this one). Overlaps take priority over any positive TOI.
1605
+ if (bestToi > 0) {
1606
+ bestToi = 0;
1607
+ bestNormal = { x: -dirX, y: -dirY, z: -dirZ };
1608
+ targetIndices.length = 0;
1609
+ targetIndices.push(targetIndex);
1610
+ }
1611
+ else {
1612
+ targetIndices.push(targetIndex); // another overlapping body at zero distance
1613
+ }
1614
+ continue;
1615
+ }
1616
+ if (bestToi === 0) {
1617
+ continue; // a zero-distance overlap already owns this step; ignore positive TOIs
1618
+ }
1619
+ if (toiHit.distance < bestToi - physics3d_solver_js_1.SOLVER_CONSTANTS.SLOP) {
1620
+ bestToi = toiHit.distance;
1621
+ bestNormal = toiHit.normal;
1622
+ targetIndices.length = 0;
1623
+ targetIndices.push(targetIndex);
1624
+ }
1625
+ else if (toiHit.distance <= bestToi + physics3d_solver_js_1.SOLVER_CONSTANTS.SLOP) {
1626
+ // A tie at the first contact (e.g. both boxes of a seam).
1627
+ if (toiHit.distance < bestToi) {
1628
+ bestToi = toiHit.distance;
1629
+ bestNormal = toiHit.normal;
1630
+ }
1631
+ targetIndices.push(targetIndex);
1632
+ }
1633
+ }
1634
+ return targetIndices.length === 0 ? undefined : { toi: bestToi, normal: bestNormal, targetIndices };
1635
+ }
1636
+ /**
1637
+ * Advance a fast body to its exact TOI contact and resolve the impact as a single
1638
+ * perfectly-inelastic (restitution 0) collision between it and every body struck at that
1639
+ * first contact: all of them are set to their common momentum-conserving normal velocity.
1640
+ * This transfers momentum to the struck bodies and removes the fast body's approach, so
1641
+ * the discrete step resolves a resting (not a plowing) contact — and, at a seam, sharing
1642
+ * the impact across both boxes keeps each below the coupled relax's stability limit. A
1643
+ * static/kinematic target has infinite mass along the normal, so the common velocity is
1644
+ * that surface's (a hard stop). Only the normal component changes; tangential is kept.
1645
+ */
1646
+ function applyToiResponse(fast, solverBodies, clamp) {
1647
+ fast.p = {
1648
+ x: clamp.prePos.x + clamp.dirX * clamp.maxAdvance,
1649
+ y: clamp.prePos.y + clamp.dirY * clamp.maxAdvance,
1650
+ z: clamp.prePos.z + clamp.dirZ * clamp.maxAdvance,
1651
+ };
1652
+ const nx = clamp.normalX;
1653
+ const ny = clamp.normalY;
1654
+ const nz = clamp.normalZ;
1655
+ const normalMassAlong = (b) => {
1656
+ const k = nx * nx * b.invMassVec.x + ny * ny * b.invMassVec.y + nz * nz * b.invMassVec.z;
1657
+ return k > 0 ? 1 / k : Infinity; // effective mass along the contact normal
1658
+ };
1659
+ const setNormalVelocity = (b, vc) => {
1660
+ const vn = b.v.x * nx + b.v.y * ny + b.v.z * nz;
1661
+ const d = vc - vn;
1662
+ b.v = { x: b.v.x + nx * d, y: b.v.y + ny * d, z: b.v.z + nz * d };
1663
+ };
1664
+ const fastMass = normalMassAlong(fast); // dynamic ⇒ finite
1665
+ let sumMass = fastMass;
1666
+ let sumMomentum = fastMass * (fast.v.x * nx + fast.v.y * ny + fast.v.z * nz);
1667
+ let anyImmovable = false;
1668
+ let immovableVn = 0;
1669
+ for (const ti of clamp.targetIndices) {
1670
+ const t = solverBodies[ti];
1671
+ const tm = normalMassAlong(t);
1672
+ if (tm === Infinity) {
1673
+ anyImmovable = true;
1674
+ immovableVn = t.v.x * nx + t.v.y * ny + t.v.z * nz; // static/kinematic ⇒ its surface velocity
1675
+ }
1676
+ else {
1677
+ sumMass += tm;
1678
+ sumMomentum += tm * (t.v.x * nx + t.v.y * ny + t.v.z * nz);
1679
+ }
1680
+ }
1681
+ // Common post-collision normal velocity: a struck static/kinematic body pins it.
1682
+ const vc = anyImmovable ? immovableVn : sumMomentum / sumMass;
1683
+ // Only resolve if the fast body is actually approaching the contact (moving into it).
1684
+ if ((fast.v.x * nx + fast.v.y * ny + fast.v.z * nz) - vc >= 0) {
1685
+ return;
1686
+ }
1687
+ setNormalVelocity(fast, vc);
1688
+ for (const ti of clamp.targetIndices) {
1689
+ const t = solverBodies[ti];
1690
+ if (normalMassAlong(t) !== Infinity) {
1691
+ setNormalVelocity(t, vc);
1692
+ }
1693
+ }
1694
+ }
1695
+ /**
1696
+ * Clamp a fast body's POSITION so it advances no farther than its first TOI along its
1697
+ * intended motion. Velocity is set by the pre-solve TOI response and the discrete solver;
1698
+ * this only removes any residual along-`dir` overshoot past the contact.
1699
+ */
1700
+ function clampFastToToi(sb, clamp) {
1701
+ const s = (sb.p.x - clamp.prePos.x) * clamp.dirX
1702
+ + (sb.p.y - clamp.prePos.y) * clamp.dirY
1703
+ + (sb.p.z - clamp.prePos.z) * clamp.dirZ;
1704
+ if (s <= clamp.maxAdvance) {
1705
+ return;
1706
+ }
1707
+ const excess = s - clamp.maxAdvance;
1708
+ sb.p = {
1709
+ x: sb.p.x - clamp.dirX * excess,
1710
+ y: sb.p.y - clamp.dirY * excess,
1711
+ z: sb.p.z - clamp.dirZ * excess,
1712
+ };
1713
+ }
1714
+ function runRigidSolver(world, dt, gravityY, velocityIterations, substeps, joints, pairs) {
1443
1715
  const source = world.bodies;
1444
1716
  const indexById = new Map(source.map((body, index) => [body.id, index]));
1445
1717
  const solverBodies = source.map((body, index) => buildSolverBody(body, index));
@@ -1462,19 +1734,61 @@ function runRigidSolver(world, dt, gravityY, velocityIterations, joints) {
1462
1734
  })
1463
1735
  .filter((key) => key !== undefined);
1464
1736
  const noCollidePairs = new Set(noCollideKeys);
1737
+ // Extreme-speed ccd (box3d model): flag fast bodies and find each one's exact first
1738
+ // time-of-impact. The discrete solve runs with a small speculative margin (fast ccd
1739
+ // bodies plow through during the step, staying finite); afterward each fast body's
1740
+ // POSITION is clamped to its TOI so it never tunnels, while its VELOCITY is left to
1741
+ // the solver so the next step's contact transfers momentum. Empty set ⇒ no clamp.
1742
+ const fastClamps = computeFastCcdClamps(world, source, solverBodies, dt, indexById, noCollidePairs);
1743
+ // Pre-solve TOI response (box3d-style, in stable body-index order): advance each fast
1744
+ // body to its exact first contact and apply a one-shot inelastic normal impulse that
1745
+ // transfers momentum to the struck body and arrests the approach, so the discrete step
1746
+ // never sees the fast body plow through a coupled cluster.
1747
+ for (const [index, clamp] of fastClamps) {
1748
+ applyToiResponse(solverBodies[index], solverBodies, clamp);
1749
+ }
1465
1750
  const result = (0, physics3d_solver_js_1.solveRigidStep)(solverBodies, {
1466
1751
  dt,
1467
1752
  gravityY,
1468
1753
  velocityIterations,
1754
+ substeps,
1469
1755
  noCollidePairs: noCollidePairs.size > 0 ? noCollidePairs : undefined,
1470
1756
  warm,
1757
+ pairs,
1471
1758
  joints: solverJoints,
1472
1759
  });
1760
+ // Post-solve safety clamp: no fast body may end past its TOI along its motion.
1761
+ for (const [index, clamp] of fastClamps) {
1762
+ clampFastToToi(solverBodies[index], clamp);
1763
+ }
1473
1764
  const bodies = source.map((body, index) => {
1474
- if (body.kind !== 'dynamic') {
1765
+ if (body.kind === 'static') {
1475
1766
  return body;
1476
1767
  }
1477
1768
  const sb = solverBodies[index];
1769
+ if (body.kind === 'kinematic') {
1770
+ // Author-driven: the solver advanced the pose from the authored velocity
1771
+ // and never touched the velocity, mass, or sleep state.
1772
+ const moved = {
1773
+ ...body,
1774
+ x: (0, physics3d_shared_js_1.quantizeDistance)(sb.p.x),
1775
+ y: (0, physics3d_shared_js_1.quantizeDistance)(sb.p.y),
1776
+ z: (0, physics3d_shared_js_1.quantizeDistance)(sb.p.z),
1777
+ };
1778
+ if (body.orientation === undefined) {
1779
+ // legacy quarter-turn decoration, same as the dynamic path
1780
+ return { ...moved, ...integrateAngularState(body, dt) };
1781
+ }
1782
+ return {
1783
+ ...moved,
1784
+ orientation: {
1785
+ x: (0, physics3d_shared_js_1.quantizeDistance)(sb.q.x),
1786
+ y: (0, physics3d_shared_js_1.quantizeDistance)(sb.q.y),
1787
+ z: (0, physics3d_shared_js_1.quantizeDistance)(sb.q.z),
1788
+ w: (0, physics3d_shared_js_1.quantizeDistance)(sb.q.w),
1789
+ },
1790
+ };
1791
+ }
1478
1792
  const mapped = {
1479
1793
  ...body,
1480
1794
  x: (0, physics3d_shared_js_1.quantizeDistance)(sb.p.x),
@@ -1577,7 +1891,7 @@ function detectContacts(bodies, keepPair) {
1577
1891
  return contacts.sort((left, right) => compareStrings(left.pair, right.pair));
1578
1892
  }
1579
1893
  function solveConstraints3D(bodies, constraints) {
1580
- const next = bodies.map((body) => ({ ...body }));
1894
+ const next = [...bodies];
1581
1895
  let corrections = 0;
1582
1896
  for (const constraint of [...constraints].sort((left, right) => compareStrings(left.id, right.id))) {
1583
1897
  const aIndex = next.findIndex((body) => body.id === constraint.a);
@@ -1736,9 +2050,21 @@ function validatePhysicsBody3D(body) {
1736
2050
  throw new Error(`physics3d body ${id}: orientation quaternion must have non-zero length`);
1737
2051
  }
1738
2052
  }
2053
+ if (body.shape.type === 'mesh' && body.kind === 'dynamic') {
2054
+ throw new Error(`physics3d body ${id}: mesh collider must be static or kinematic — cook a concave dynamic body into a compound of convex hulls`);
2055
+ }
1739
2056
  validatePhysicsShape3D(id, body.shape);
1740
2057
  }
1741
2058
  function validatePhysicsShape3D(id, shape) {
2059
+ if (shape.type === 'hull') {
2060
+ try {
2061
+ (0, physics3d_hull_js_1.buildSolverHull)(shape.vertices);
2062
+ }
2063
+ catch (error) {
2064
+ throw new Error(`physics3d body ${id}: ${error.message}`);
2065
+ }
2066
+ return;
2067
+ }
1742
2068
  if (shape.type === 'box') {
1743
2069
  for (const [name, value] of [['halfX', shape.halfX], ['halfY', shape.halfY], ['halfZ', shape.halfZ]]) {
1744
2070
  if (!isFiniteNumber(value) || value <= 0) {
@@ -1847,6 +2173,9 @@ function shapePoseBounds(pose) {
1847
2173
  }
1848
2174
  return boundsFromPoints(boxLocalCorners(shape).map((point) => translateRotatedPoint(point, pose)));
1849
2175
  }
2176
+ if (shape.type === 'hull') {
2177
+ return boundsFromPoints(shape.vertices.map((vertex) => translateRotatedPoint(vertex, pose)));
2178
+ }
1850
2179
  if (shape.type === 'mesh') {
1851
2180
  if (shape.vertices.length < 3 || shape.vertices.length % 3 !== 0) {
1852
2181
  throw new Error('3D mesh collider requires triangle-list vertices');
@@ -1972,6 +2301,14 @@ function angularGeometryProof() {
1972
2301
  && meshHit.some((hit) => hit.bodyId === 'rotated-mesh')
1973
2302
  && overlap.includes('rotated-box'));
1974
2303
  }
2304
+ function poseToSolverGeom(pose, index) {
2305
+ return {
2306
+ index,
2307
+ p: { x: pose.x, y: pose.y, z: pose.z },
2308
+ q: (0, physics3d_shared_js_1.quaternionFromQuarterTurns)({ x: pose.rx ?? 0, y: pose.ry ?? 0, z: pose.rz ?? 0 }),
2309
+ shape: toSolverShape(pose.shape),
2310
+ };
2311
+ }
1975
2312
  function shapesOverlap3D(left, right) {
1976
2313
  if (left.shape.type === 'compound') {
1977
2314
  return compoundChildPoses(left).some((childPose) => shapesOverlap3D(childPose, right));
@@ -1979,6 +2316,11 @@ function shapesOverlap3D(left, right) {
1979
2316
  if (right.shape.type === 'compound') {
1980
2317
  return compoundChildPoses(right).some((childPose) => shapesOverlap3D(left, childPose));
1981
2318
  }
2319
+ if (left.shape.type === 'hull' || right.shape.type === 'hull') {
2320
+ // Exact, orientation-aware: build solver geometry from each pose (mesh is
2321
+ // one-sided and handled by generateManifold's mesh dispatch).
2322
+ return (0, physics3d_solver_js_1.generateManifold)(poseToSolverGeom(left, 0), poseToSolverGeom(right, 1)) !== undefined;
2323
+ }
1982
2324
  if (left.shape.type === 'mesh') {
1983
2325
  return meshOverlapsShape(left, right);
1984
2326
  }
@@ -2411,8 +2753,42 @@ function rayBodyDistance(body, query) {
2411
2753
  if (body.shape.type === 'mesh') {
2412
2754
  return rayMeshDistance(meshPose(bodyPose(body), body.shape), query);
2413
2755
  }
2756
+ if (body.shape.type === 'hull') {
2757
+ return rayHullBodyDistance(body, body.shape, query);
2758
+ }
2414
2759
  return rayAabbDistance(toBounds(body), query);
2415
2760
  }
2761
+ function rayHullBodyDistance(body, shape, query) {
2762
+ // Oriented bodies already recursed with orientation cleared. The remaining
2763
+ // rotation is legacy quarter-turns; fold it into the ray so we can reuse the
2764
+ // cached (identity-space) hull half-planes rather than rebuilding per query.
2765
+ const hull = (0, physics3d_hull_js_1.buildSolverHull)(shape.vertices);
2766
+ const local = inverseRotateQuarterTurns({ x: query.x - body.x, y: query.y - body.y, z: query.z - body.z }, body.rx ?? 0, body.ry ?? 0, body.rz ?? 0);
2767
+ const localDir = inverseRotateQuarterTurns({ x: query.dx, y: query.dy, z: query.dz }, body.rx ?? 0, body.ry ?? 0, body.rz ?? 0);
2768
+ const interval = (0, physics3d_hull_js_1.rayHullIntervalLocal)(local, localDir, hull);
2769
+ if (interval === undefined) {
2770
+ return undefined;
2771
+ }
2772
+ const t = Math.max(0, interval.tEnter);
2773
+ if (t > query.maxDistance) {
2774
+ return undefined;
2775
+ }
2776
+ return (0, physics3d_shared_js_1.quantizeDistance)(t);
2777
+ }
2778
+ function inverseRotateQuarterTurns(point, rx, ry, rz) {
2779
+ // Inverse of rotateQuarterTurns: apply the opposite turns in reverse order.
2780
+ let rotated = point;
2781
+ for (let index = 0; index < (4 - quarterTurns(rz)) % 4; index += 1) {
2782
+ rotated = { x: -rotated.y, y: rotated.x, z: rotated.z };
2783
+ }
2784
+ for (let index = 0; index < (4 - quarterTurns(ry)) % 4; index += 1) {
2785
+ rotated = { x: rotated.z, y: rotated.y, z: -rotated.x };
2786
+ }
2787
+ for (let index = 0; index < (4 - quarterTurns(rx)) % 4; index += 1) {
2788
+ rotated = { x: rotated.x, y: -rotated.z, z: rotated.y };
2789
+ }
2790
+ return rotated;
2791
+ }
2416
2792
  function shapeCastBodyDistance(body, query) {
2417
2793
  if (body.orientation !== undefined) {
2418
2794
  // Transform the cast into the body's local frame (isometry preserves the hit
@@ -2676,7 +3052,12 @@ function expandBoundsByBounds(bounds, extents) {
2676
3052
  maxZ: bounds.maxZ - extents.minZ,
2677
3053
  };
2678
3054
  }
3055
+ let countCandidatePairsCalls = 0;
3056
+ function __countCandidatePairsCalls() {
3057
+ return countCandidatePairsCalls;
3058
+ }
2679
3059
  function countCandidatePairs(bodies) {
3060
+ countCandidatePairsCalls += 1;
2680
3061
  return broadphaseCandidatePairs(bodies).length;
2681
3062
  }
2682
3063
  function detailedHitForBody(body, query, distance, castRadius) {