@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
@@ -14,7 +14,12 @@
14
14
  Object.defineProperty(exports, "__esModule", { value: true });
15
15
  exports.createSolverJoints = createSolverJoints;
16
16
  const physics3d_shared_js_1 = require("./physics3d-shared.js");
17
+ const math_js_1 = require("./math.js");
17
18
  const JOINT_BAUMGARTE = 0.2;
19
+ const TWO_PI = 6.283185307179586;
20
+ // Module-level deterministic math for cone-twist cosine (vehicle precedent); the
21
+ // generator is pure and the tables are read-only, so no cross-frame state leaks.
22
+ const jointMath = (0, math_js_1.createDeterministicMath)();
18
23
  function requireIndex(id, jointId, indexById) {
19
24
  const index = indexById.get(id);
20
25
  if (index === undefined) {
@@ -42,13 +47,37 @@ function ballJoint(joint, aIndex, bIndex, warm, weld) {
42
47
  const state = {
43
48
  impulse: warm ? { x: warm.impulseX, y: warm.impulseY, z: warm.impulseZ } : { x: 0, y: 0, z: 0 },
44
49
  axialImpulse: 0,
45
- hingeAngle: 0,
50
+ hingeAngle: warm?.hingeAngle ?? 0,
51
+ stepImpulse: { x: 0, y: 0, z: 0 },
52
+ stepAxial: 0,
46
53
  };
54
+ // Cone-twist limits (ball only). coneCos is the cosine of the swing half-angle;
55
+ // twist limits are stored in radians. axisA/axisB are the local twist axes.
56
+ const coneTwist = !weld && joint.type === 'ball'
57
+ && (joint.coneLimitTurns !== undefined || joint.twistMinTurns !== undefined || joint.twistMaxTurns !== undefined)
58
+ ? {
59
+ localAxisA: joint.axisA,
60
+ localAxisB: (joint.axisB ?? joint.axisA),
61
+ coneCos: joint.coneLimitTurns !== undefined
62
+ ? jointMath.cosTurns(Math.round(joint.coneLimitTurns * jointMath.fixedScale)) / jointMath.fixedScale
63
+ : undefined,
64
+ twistMin: joint.twistMinTurns !== undefined ? joint.twistMinTurns * TWO_PI : undefined,
65
+ twistMax: joint.twistMaxTurns !== undefined ? joint.twistMaxTurns * TWO_PI : undefined,
66
+ }
67
+ : undefined;
47
68
  let rA = { x: 0, y: 0, z: 0 };
48
69
  let rB = { x: 0, y: 0, z: 0 };
49
70
  let kInv = [0, 0, 0, 0, 0, 0, 0, 0, 0];
50
71
  let biasTarget = { x: 0, y: 0, z: 0 };
51
72
  let angKInv = [0, 0, 0, 0, 0, 0, 0, 0, 0];
73
+ let twistAxis = { x: 0, y: 1, z: 0 };
74
+ let swingV = { x: 0, y: 1, z: 0 };
75
+ let coneImpulse = 0;
76
+ let twistImpulse = 0;
77
+ const angularMassAlong = (ctx, axis) => {
78
+ const denom = (0, physics3d_shared_js_1.dotVec3)(axis, (0, physics3d_shared_js_1.mulMat3Vec)(ctx.invInertia[aIndex], axis)) + (0, physics3d_shared_js_1.dotVec3)(axis, (0, physics3d_shared_js_1.mulMat3Vec)(ctx.invInertia[bIndex], axis));
79
+ return denom === 0 ? 0 : 1 / denom;
80
+ };
52
81
  return {
53
82
  bodyIndices: [aIndex, bIndex],
54
83
  prepare(ctx) {
@@ -64,6 +93,12 @@ function ballJoint(joint, aIndex, bIndex, warm, weld) {
64
93
  if (weld) {
65
94
  angKInv = (0, physics3d_shared_js_1.invertMat3)((0, physics3d_shared_js_1.mat3Sub)((0, physics3d_shared_js_1.mat3Sub)((0, physics3d_shared_js_1.mat3FromDiag)({ x: 0, y: 0, z: 0 }), scaleMat3(ctx.invInertia[aIndex], -1)), scaleMat3(ctx.invInertia[bIndex], -1)));
66
95
  }
96
+ if (coneTwist !== undefined) {
97
+ twistAxis = unit((0, physics3d_shared_js_1.rotatePointByQuat)(A.q, coneTwist.localAxisA));
98
+ swingV = unit((0, physics3d_shared_js_1.rotatePointByQuat)(B.q, coneTwist.localAxisB));
99
+ coneImpulse = 0;
100
+ twistImpulse = 0;
101
+ }
67
102
  ctx.apply(aIndex, bIndex, rA, rB, state.impulse);
68
103
  },
69
104
  iterate(ctx) {
@@ -78,16 +113,63 @@ function ballJoint(joint, aIndex, bIndex, warm, weld) {
78
113
  const angImpulse = (0, physics3d_shared_js_1.scaleVec3)((0, physics3d_shared_js_1.mulMat3Vec)(angKInv, relOmega), -1);
79
114
  ctx.applyAngular(aIndex, bIndex, angImpulse);
80
115
  }
116
+ if (coneTwist !== undefined) {
117
+ // Swing cone: one-sided limit on the angle between the two twist axes.
118
+ if (coneTwist.coneCos !== undefined) {
119
+ const cosAngle = (0, physics3d_shared_js_1.dotVec3)(twistAxis, swingV);
120
+ if (cosAngle < coneTwist.coneCos) {
121
+ const rawAxis = (0, physics3d_shared_js_1.crossVec3)(swingV, twistAxis);
122
+ const len = (0, physics3d_shared_js_1.solverSqrt)((0, physics3d_shared_js_1.dotVec3)(rawAxis, rawAxis));
123
+ if (len > 1e-9) {
124
+ const axis = (0, physics3d_shared_js_1.scaleVec3)(rawAxis, 1 / len);
125
+ const relW = (0, physics3d_shared_js_1.dotVec3)((0, physics3d_shared_js_1.subVec3)(B.w, A.w), axis);
126
+ const mass = angularMassAlong(ctx, axis);
127
+ let dCone = mass * (-relW + (JOINT_BAUMGARTE / ctx.dt) * (coneTwist.coneCos - cosAngle));
128
+ const newAcc = Math.max(0, coneImpulse + dCone);
129
+ dCone = newAcc - coneImpulse;
130
+ coneImpulse = newAcc;
131
+ ctx.applyAngular(aIndex, bIndex, (0, physics3d_shared_js_1.scaleVec3)(axis, dCone));
132
+ }
133
+ }
134
+ }
135
+ // Twist: min/max inequality rows about the shared twist axis (mirrors hinge).
136
+ if (coneTwist.twistMin !== undefined && state.hingeAngle <= coneTwist.twistMin) {
137
+ const overshoot = coneTwist.twistMin - state.hingeAngle;
138
+ const relW = (0, physics3d_shared_js_1.dotVec3)((0, physics3d_shared_js_1.subVec3)(B.w, A.w), twistAxis);
139
+ const mass = angularMassAlong(ctx, twistAxis);
140
+ let dTwist = mass * (-relW + (JOINT_BAUMGARTE / ctx.dt) * overshoot);
141
+ const newAcc = Math.max(0, twistImpulse + dTwist);
142
+ dTwist = newAcc - twistImpulse;
143
+ twistImpulse = newAcc;
144
+ ctx.applyAngular(aIndex, bIndex, (0, physics3d_shared_js_1.scaleVec3)(twistAxis, dTwist));
145
+ }
146
+ else if (coneTwist.twistMax !== undefined && state.hingeAngle >= coneTwist.twistMax) {
147
+ const overshoot = state.hingeAngle - coneTwist.twistMax;
148
+ const relW = (0, physics3d_shared_js_1.dotVec3)((0, physics3d_shared_js_1.subVec3)(B.w, A.w), twistAxis);
149
+ const mass = angularMassAlong(ctx, twistAxis);
150
+ let dTwist = mass * (-relW - (JOINT_BAUMGARTE / ctx.dt) * overshoot);
151
+ const newAcc = Math.min(0, twistImpulse + dTwist);
152
+ dTwist = newAcc - twistImpulse;
153
+ twistImpulse = newAcc;
154
+ ctx.applyAngular(aIndex, bIndex, (0, physics3d_shared_js_1.scaleVec3)(twistAxis, dTwist));
155
+ }
156
+ }
81
157
  // position correction on pseudo-velocities
82
158
  const relBias = ctx.biasRelativeVelocity(aIndex, bIndex, rA, rB);
83
159
  const dBias = (0, physics3d_shared_js_1.mulMat3Vec)(kInv, (0, physics3d_shared_js_1.subVec3)(biasTarget, relBias));
84
160
  ctx.applyBias(aIndex, bIndex, rA, rB, dBias);
85
161
  },
162
+ postSubstep(ctx) {
163
+ state.stepImpulse = (0, physics3d_shared_js_1.addVec3)(state.stepImpulse, state.impulse);
164
+ if (coneTwist !== undefined) {
165
+ state.hingeAngle += (0, physics3d_shared_js_1.dotVec3)((0, physics3d_shared_js_1.subVec3)(ctx.bodies[bIndex].w, ctx.bodies[aIndex].w), twistAxis) * ctx.dt;
166
+ }
167
+ },
86
168
  emitCache() {
87
- const reaction2 = (0, physics3d_shared_js_1.dotVec3)(state.impulse, state.impulse);
169
+ const reaction2 = (0, physics3d_shared_js_1.dotVec3)(state.stepImpulse, state.stepImpulse);
88
170
  const broken = joint.breakImpulse !== undefined && reaction2 > joint.breakImpulse * joint.breakImpulse;
89
171
  return {
90
- jointId: joint.id, impulseX: state.impulse.x, impulseY: state.impulse.y, impulseZ: state.impulse.z, axialImpulse: 0, hingeAngle: 0,
172
+ jointId: joint.id, impulseX: state.impulse.x, impulseY: state.impulse.y, impulseZ: state.impulse.z, axialImpulse: 0, hingeAngle: coneTwist !== undefined ? state.hingeAngle : 0,
91
173
  ...(broken ? { broken: true } : {}),
92
174
  };
93
175
  },
@@ -101,6 +183,8 @@ function hingeJoint(joint, aIndex, bIndex, warm) {
101
183
  impulse: warm ? { x: warm.impulseX, y: warm.impulseY, z: warm.impulseZ } : { x: 0, y: 0, z: 0 },
102
184
  axialImpulse: warm?.axialImpulse ?? 0,
103
185
  hingeAngle: warm?.hingeAngle ?? 0,
186
+ stepImpulse: { x: 0, y: 0, z: 0 },
187
+ stepAxial: 0,
104
188
  };
105
189
  let rA = { x: 0, y: 0, z: 0 };
106
190
  let rB = { x: 0, y: 0, z: 0 };
@@ -113,7 +197,6 @@ function hingeJoint(joint, aIndex, bIndex, warm) {
113
197
  let angMassT1 = 0;
114
198
  let angMassT2 = 0;
115
199
  let limitImpulse = 0;
116
- let ctxRef;
117
200
  const angularMass = (ctx, t) => {
118
201
  const denom = (0, physics3d_shared_js_1.dotVec3)(t, (0, physics3d_shared_js_1.mulMat3Vec)(ctx.invInertia[aIndex], t)) + (0, physics3d_shared_js_1.dotVec3)(t, (0, physics3d_shared_js_1.mulMat3Vec)(ctx.invInertia[bIndex], t));
119
202
  /* istanbul ignore next -- a hinge with a real dynamic body has non-zero inverse inertia along the axis; the zero guard is defensive. */
@@ -139,7 +222,6 @@ function hingeJoint(joint, aIndex, bIndex, warm) {
139
222
  angMassT1 = angularMass(ctx, t1);
140
223
  angMassT2 = angularMass(ctx, t2);
141
224
  limitImpulse = 0;
142
- ctxRef = ctx;
143
225
  ctx.apply(aIndex, bIndex, rA, rB, state.impulse);
144
226
  if (state.axialImpulse !== 0) {
145
227
  ctx.applyAngular(aIndex, bIndex, (0, physics3d_shared_js_1.scaleVec3)(axis, state.axialImpulse));
@@ -193,14 +275,14 @@ function hingeJoint(joint, aIndex, bIndex, warm) {
193
275
  ctx.applyAngular(aIndex, bIndex, (0, physics3d_shared_js_1.scaleVec3)(axis, dImpulse));
194
276
  }
195
277
  },
196
- emitCache() {
278
+ postSubstep(ctx) {
197
279
  // integrate the hinge angle from the post-solve relative axial velocity
198
- /* istanbul ignore else -- prepare() always runs before emitCache(), so ctxRef is defined. */
199
- if (ctxRef !== undefined) {
200
- const relAxial = (0, physics3d_shared_js_1.dotVec3)((0, physics3d_shared_js_1.subVec3)(ctxRef.bodies[bIndex].w, ctxRef.bodies[aIndex].w), axis);
201
- state.hingeAngle += relAxial * ctxRef.dt;
202
- }
203
- const reaction2 = (0, physics3d_shared_js_1.dotVec3)(state.impulse, state.impulse);
280
+ const relAxial = (0, physics3d_shared_js_1.dotVec3)((0, physics3d_shared_js_1.subVec3)(ctx.bodies[bIndex].w, ctx.bodies[aIndex].w), axis);
281
+ state.hingeAngle += relAxial * ctx.dt;
282
+ state.stepImpulse = (0, physics3d_shared_js_1.addVec3)(state.stepImpulse, state.impulse);
283
+ },
284
+ emitCache() {
285
+ const reaction2 = (0, physics3d_shared_js_1.dotVec3)(state.stepImpulse, state.stepImpulse);
204
286
  const broken = joint.breakImpulse !== undefined && reaction2 > joint.breakImpulse * joint.breakImpulse;
205
287
  return {
206
288
  jointId: joint.id,
@@ -224,6 +306,8 @@ function distanceJoint(joint, aIndex, bIndex, warm) {
224
306
  impulse: { x: warm?.axialImpulse ?? 0, y: 0, z: 0 },
225
307
  axialImpulse: warm?.axialImpulse ?? 0,
226
308
  hingeAngle: 0,
309
+ stepImpulse: { x: 0, y: 0, z: 0 },
310
+ stepAxial: 0,
227
311
  };
228
312
  let rA = { x: 0, y: 0, z: 0 };
229
313
  let rB = { x: 0, y: 0, z: 0 };
@@ -248,7 +332,10 @@ function distanceJoint(joint, aIndex, bIndex, warm) {
248
332
  const denom = linear + (0, physics3d_shared_js_1.dotVec3)(raxn, (0, physics3d_shared_js_1.mulMat3Vec)(ctx.invInertia[aIndex], raxn)) + (0, physics3d_shared_js_1.dotVec3)(rbxn, (0, physics3d_shared_js_1.mulMat3Vec)(ctx.invInertia[bIndex], rbxn));
249
333
  /* istanbul ignore next -- a distance joint with a real dynamic body has non-zero effective mass; the zero guard is defensive. */
250
334
  mass = denom === 0 ? 0 : 1 / denom;
251
- state.axialImpulse = warm?.axialImpulse ?? 0;
335
+ // Warm-start the persisted axial impulse (it accumulates across substeps and
336
+ // is zeroed by iterate when the rope goes slack). Re-reading the frame's warm
337
+ // value here would drop intra-step buildup and undo the slack-rope zeroing on
338
+ // later substeps — mirror the spring joint, which persists axialImpulse too.
252
339
  ctx.apply(aIndex, bIndex, rA, rB, (0, physics3d_shared_js_1.scaleVec3)(n, state.axialImpulse));
253
340
  },
254
341
  iterate(ctx) {
@@ -281,8 +368,11 @@ function distanceJoint(joint, aIndex, bIndex, warm) {
281
368
  state.axialImpulse = 0;
282
369
  }
283
370
  },
371
+ postSubstep() {
372
+ state.stepAxial += state.axialImpulse;
373
+ },
284
374
  emitCache() {
285
- const reaction2 = state.axialImpulse * state.axialImpulse;
375
+ const reaction2 = state.stepAxial * state.stepAxial;
286
376
  const broken = joint.breakImpulse !== undefined && reaction2 > joint.breakImpulse * joint.breakImpulse;
287
377
  return {
288
378
  jointId: joint.id, impulseX: 0, impulseY: 0, impulseZ: 0, axialImpulse: state.axialImpulse, hingeAngle: 0,
@@ -296,6 +386,8 @@ function prismaticJoint(joint, aIndex, bIndex, warm) {
296
386
  impulse: warm ? { x: warm.impulseX, y: warm.impulseY, z: warm.impulseZ } : { x: 0, y: 0, z: 0 },
297
387
  axialImpulse: warm?.axialImpulse ?? 0,
298
388
  hingeAngle: 0,
389
+ stepImpulse: { x: 0, y: 0, z: 0 },
390
+ stepAxial: 0,
299
391
  };
300
392
  let rA = { x: 0, y: 0, z: 0 };
301
393
  let rB = { x: 0, y: 0, z: 0 };
@@ -398,10 +490,13 @@ function prismaticJoint(joint, aIndex, bIndex, warm) {
398
490
  ctx.apply(aIndex, bIndex, rA, rB, (0, physics3d_shared_js_1.scaleVec3)(axis, dLambda));
399
491
  }
400
492
  },
493
+ postSubstep() {
494
+ state.stepImpulse = (0, physics3d_shared_js_1.addVec3)(state.stepImpulse, state.impulse);
495
+ },
401
496
  emitCache() {
402
497
  // perpendicular reaction only — the axialImpulse is the motor accumulator,
403
498
  // and motors/limits are excluded from breaking.
404
- const reaction2 = (0, physics3d_shared_js_1.dotVec3)(state.impulse, state.impulse);
499
+ const reaction2 = (0, physics3d_shared_js_1.dotVec3)(state.stepImpulse, state.stepImpulse);
405
500
  const broken = joint.breakImpulse !== undefined && reaction2 > joint.breakImpulse * joint.breakImpulse;
406
501
  return {
407
502
  jointId: joint.id,
@@ -420,16 +515,18 @@ function springJoint(joint, aIndex, bIndex, warm) {
420
515
  impulse: { x: 0, y: 0, z: 0 },
421
516
  axialImpulse: warm?.axialImpulse ?? 0,
422
517
  hingeAngle: 0,
518
+ stepImpulse: { x: 0, y: 0, z: 0 },
519
+ stepAxial: 0,
423
520
  };
424
521
  const omega = 6.283185307179586 * joint.frequencyHz;
425
522
  let rA = { x: 0, y: 0, z: 0 };
426
523
  let rB = { x: 0, y: 0, z: 0 };
427
524
  let n = { x: 0, y: 1, z: 0 };
428
- let mass = 0;
525
+ let massEff = 0;
429
526
  let length = 0;
430
- let gamma = 0;
431
- let bias = 0;
432
- let massSoft = 0;
527
+ let biasRate = 0;
528
+ let massScale = 1;
529
+ let impulseScale = 0;
433
530
  return {
434
531
  bodyIndices: [aIndex, bIndex],
435
532
  prepare(ctx) {
@@ -446,16 +543,19 @@ function springJoint(joint, aIndex, bIndex, warm) {
446
543
  const imB = ctx.invMass[bIndex];
447
544
  const linear = n.x * n.x * (imA.x + imB.x) + n.y * n.y * (imA.y + imB.y) + n.z * n.z * (imA.z + imB.z);
448
545
  const denom = linear + (0, physics3d_shared_js_1.dotVec3)(raxn, (0, physics3d_shared_js_1.mulMat3Vec)(ctx.invInertia[aIndex], raxn)) + (0, physics3d_shared_js_1.dotVec3)(rbxn, (0, physics3d_shared_js_1.mulMat3Vec)(ctx.invInertia[bIndex], rbxn));
449
- mass = denom === 0 ? 0 : 1 / denom;
450
- // Box2D soft-constraint coefficients from frequency + damping ratio.
451
- const c = 2 * mass * joint.dampingRatio * omega;
452
- const k = mass * omega * omega;
453
- const softDenom = c + ctx.dt * k;
454
- gamma = softDenom === 0 ? 0 : 1 / (ctx.dt * softDenom);
455
- const beta = ctx.dt * k * gamma;
456
- bias = (beta / ctx.dt) * (length - joint.restLength);
457
- massSoft = mass === 0 ? 0 : 1 / (1 / mass + gamma);
458
- state.axialImpulse = warm?.axialImpulse ?? 0;
546
+ massEff = denom === 0 ? 0 : 1 / denom;
547
+ // Box2D-v3 soft-constraint coefficients: substep-invariant (biasRate stays
548
+ // bounded as h -> 0), unlike the gamma/beta form whose 1/dt bias diverges
549
+ // under sub-stepping. hertz = frequencyHz, zeta = dampingRatio, h = ctx.dt.
550
+ const h = ctx.dt;
551
+ const a1 = 2 * joint.dampingRatio + omega * h;
552
+ const a2 = h * omega * a1;
553
+ const a3 = 1 / (1 + a2);
554
+ /* istanbul ignore next -- frequencyHz > 0 is validated and dampingRatio >= 0, so a1 = 2*zeta + omega*h is always positive. */
555
+ biasRate = a1 === 0 ? 0 : omega / a1;
556
+ massScale = a2 * a3;
557
+ impulseScale = a3;
558
+ // warm start: axialImpulse persists across substeps (per-substep magnitude).
459
559
  ctx.apply(aIndex, bIndex, rA, rB, (0, physics3d_shared_js_1.scaleVec3)(n, state.axialImpulse));
460
560
  },
461
561
  iterate(ctx) {
@@ -463,12 +563,20 @@ function springJoint(joint, aIndex, bIndex, warm) {
463
563
  const B = ctx.bodies[bIndex];
464
564
  const relVel = (0, physics3d_shared_js_1.subVec3)((0, physics3d_shared_js_1.addVec3)(B.v, (0, physics3d_shared_js_1.crossVec3)(B.w, rB)), (0, physics3d_shared_js_1.addVec3)(A.v, (0, physics3d_shared_js_1.crossVec3)(A.w, rA)));
465
565
  const cdot = (0, physics3d_shared_js_1.dotVec3)(relVel, n);
466
- const dLambda = -massSoft * (cdot + bias + gamma * state.axialImpulse);
566
+ const dLambda = -massScale * massEff * (cdot + biasRate * (length - joint.restLength)) - impulseScale * state.axialImpulse;
467
567
  state.axialImpulse += dLambda;
468
568
  ctx.apply(aIndex, bIndex, rA, rB, (0, physics3d_shared_js_1.scaleVec3)(n, dLambda));
469
569
  },
570
+ postSubstep() {
571
+ state.stepAxial += state.axialImpulse;
572
+ },
470
573
  emitCache() {
471
- return { jointId: joint.id, impulseX: 0, impulseY: 0, impulseZ: 0, axialImpulse: state.axialImpulse, hingeAngle: 0 };
574
+ const reaction2 = state.stepAxial * state.stepAxial;
575
+ const broken = joint.breakImpulse !== undefined && reaction2 > joint.breakImpulse * joint.breakImpulse;
576
+ return {
577
+ jointId: joint.id, impulseX: 0, impulseY: 0, impulseZ: 0, axialImpulse: state.axialImpulse, hingeAngle: 0,
578
+ ...(broken ? { broken: true } : {}),
579
+ };
472
580
  },
473
581
  };
474
582
  }
@@ -477,6 +585,8 @@ function grabJoint(joint, bodyIndex, warm) {
477
585
  impulse: warm ? { x: warm.impulseX, y: warm.impulseY, z: warm.impulseZ } : { x: 0, y: 0, z: 0 },
478
586
  axialImpulse: 0,
479
587
  hingeAngle: 0,
588
+ stepImpulse: { x: 0, y: 0, z: 0 },
589
+ stepAxial: 0,
480
590
  };
481
591
  let r = { x: 0, y: 0, z: 0 };
482
592
  let kInv = [0, 0, 0, 0, 0, 0, 0, 0, 0];
@@ -538,6 +648,28 @@ function validateJoint(joint) {
538
648
  if ('breakImpulse' in joint && joint.breakImpulse !== undefined && (!Number.isFinite(joint.breakImpulse) || joint.breakImpulse <= 0)) {
539
649
  throw new Error(`physics3d joint ${joint.id}: breakImpulse must be positive`);
540
650
  }
651
+ if (joint.type === 'ball') {
652
+ const hasLimit = joint.coneLimitTurns !== undefined || joint.twistMinTurns !== undefined || joint.twistMaxTurns !== undefined;
653
+ if (hasLimit) {
654
+ if (joint.axisA === undefined || !Number.isFinite(joint.axisA.x) || !Number.isFinite(joint.axisA.y) || !Number.isFinite(joint.axisA.z) || (0, physics3d_shared_js_1.dotVec3)(joint.axisA, joint.axisA) === 0) {
655
+ throw new Error(`physics3d joint ${joint.id}: cone-twist limits require a non-zero finite axisA`);
656
+ }
657
+ if (joint.axisB !== undefined && (!Number.isFinite(joint.axisB.x) || !Number.isFinite(joint.axisB.y) || !Number.isFinite(joint.axisB.z) || (0, physics3d_shared_js_1.dotVec3)(joint.axisB, joint.axisB) === 0)) {
658
+ throw new Error(`physics3d joint ${joint.id}: cone-twist axisB must be non-zero finite`);
659
+ }
660
+ }
661
+ if (joint.coneLimitTurns !== undefined && (!Number.isFinite(joint.coneLimitTurns) || joint.coneLimitTurns <= 0 || joint.coneLimitTurns > 0.5)) {
662
+ throw new Error(`physics3d joint ${joint.id}: coneLimitTurns must be in (0, 0.5]`);
663
+ }
664
+ for (const [name, value] of [['twistMinTurns', joint.twistMinTurns], ['twistMaxTurns', joint.twistMaxTurns]]) {
665
+ if (value !== undefined && (!Number.isFinite(value) || value < -0.5 || value > 0.5)) {
666
+ throw new Error(`physics3d joint ${joint.id}: ${name} must be in [-0.5, 0.5]`);
667
+ }
668
+ }
669
+ if (joint.twistMinTurns !== undefined && joint.twistMaxTurns !== undefined && joint.twistMinTurns > joint.twistMaxTurns) {
670
+ throw new Error(`physics3d joint ${joint.id}: twistMinTurns must not exceed twistMaxTurns`);
671
+ }
672
+ }
541
673
  if (joint.type === 'hinge') {
542
674
  if ((0, physics3d_shared_js_1.dotVec3)(joint.axisA, joint.axisA) === 0) {
543
675
  throw new Error(`physics3d joint ${joint.id}: hinge axisA must be non-zero`);
@@ -0,0 +1,197 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runDeterministicRagdoll3DFixture = runDeterministicRagdoll3DFixture;
4
+ exports.runDeterministicRigidWheelVehicle3DFixture = runDeterministicRigidWheelVehicle3DFixture;
5
+ const canonical_js_1 = require("./canonical.js");
6
+ const physics3d_js_1 = require("./physics3d.js");
7
+ const physics3d_shared_js_1 = require("./physics3d-shared.js");
8
+ const math_js_1 = require("./math.js");
9
+ const RAGDOLL_GRAVITY_Y = -0.02;
10
+ const VEHICLE_GRAVITY_Y = -0.02;
11
+ const IDENT = { x: 0, y: 0, z: 0, w: 1 };
12
+ // Deterministic fixed-point trig for per-tick wheel steering (vehicle precedent).
13
+ const recipeMath = (0, math_js_1.createDeterministicMath)();
14
+ function upDot(orientation) {
15
+ /* istanbul ignore next -- fixture bodies always carry an orientation. */
16
+ const q = orientation ?? IDENT;
17
+ return 1 - 2 * (q.x * q.x + q.z * q.z);
18
+ }
19
+ function ragdollBodies() {
20
+ const box = (id, x, y, z, half, mass) => ({
21
+ id, kind: 'dynamic', x, y, z, vx: 0, vy: 0, vz: 0, orientation: IDENT, angularVel: { x: 0, y: 0, z: 0 },
22
+ shape: { type: 'box', halfX: half.x, halfY: half.y, halfZ: half.z }, layer: 1, mask: 1, material: { friction: 6, restitution: 0 }, mass,
23
+ });
24
+ const capsule = (id, x, y, z, radius, halfHeight, mass) => ({
25
+ id, kind: 'dynamic', x, y, z, vx: 0, vy: 0, vz: 0, orientation: IDENT, angularVel: { x: 0, y: 0, z: 0 },
26
+ shape: { type: 'capsule', radius, halfHeight }, layer: 1, mask: 1, material: { friction: 6, restitution: 0 }, mass,
27
+ });
28
+ return [
29
+ { id: 'floor', kind: 'static', x: 0, y: -1, z: 0, vx: 0, vy: 0, vz: 0, shape: { type: 'box', halfX: 40, halfY: 1, halfZ: 40 }, layer: 1, mask: 1, material: { friction: 10, restitution: 0 } },
30
+ box('torso', 0, 4, 0, { x: 0.4, y: 0.6, z: 0.25 }, 4),
31
+ box('head', 0, 4.85, 0, { x: 0.25, y: 0.25, z: 0.25 }, 1),
32
+ capsule('arm-l', -0.4, 4.1, 0, 0.15, 0.5, 1),
33
+ capsule('arm-r', 0.4, 4.1, 0, 0.15, 0.5, 1),
34
+ capsule('leg-l', -0.2, 2.8, 0, 0.18, 0.6, 1.5),
35
+ capsule('leg-r', 0.2, 2.8, 0, 0.18, 0.6, 1.5),
36
+ ];
37
+ }
38
+ function ragdollJoints() {
39
+ return [
40
+ { id: 'neck', type: 'ball', a: 'torso', b: 'head', anchorA: { x: 0, y: 0.6, z: 0 }, anchorB: { x: 0, y: -0.25, z: 0 }, axisA: { x: 0, y: 1, z: 0 }, coneLimitTurns: 0.1, twistMinTurns: -0.05, twistMaxTurns: 0.05, disableCollision: true },
41
+ { id: 'shoulder-l', type: 'ball', a: 'torso', b: 'arm-l', anchorA: { x: -0.4, y: 0.6, z: 0 }, anchorB: { x: 0, y: 0.5, z: 0 }, axisA: { x: 0, y: 1, z: 0 }, coneLimitTurns: 0.2, disableCollision: true },
42
+ { id: 'shoulder-r', type: 'ball', a: 'torso', b: 'arm-r', anchorA: { x: 0.4, y: 0.6, z: 0 }, anchorB: { x: 0, y: 0.5, z: 0 }, axisA: { x: 0, y: 1, z: 0 }, coneLimitTurns: 0.2, disableCollision: true },
43
+ { id: 'hip-l', type: 'hinge', a: 'torso', b: 'leg-l', anchorA: { x: -0.2, y: -0.6, z: 0 }, anchorB: { x: 0, y: 0.6, z: 0 }, axisA: { x: 1, y: 0, z: 0 }, minAngle: -0.3, maxAngle: 2.0, disableCollision: true },
44
+ { id: 'hip-r', type: 'hinge', a: 'torso', b: 'leg-r', anchorA: { x: 0.2, y: -0.6, z: 0 }, anchorB: { x: 0, y: 0.6, z: 0 }, axisA: { x: 1, y: 0, z: 0 }, minAngle: -0.3, maxAngle: 2.0, disableCollision: true },
45
+ ];
46
+ }
47
+ function worldAnchor(world, id, local) {
48
+ const body = world.bodies.find((candidate) => candidate.id === id);
49
+ /* istanbul ignore next -- fixture joints only ever name bodies the fixture authored. */
50
+ if (body === undefined) {
51
+ throw new Error(`ragdoll fixture: unknown body ${id}`);
52
+ }
53
+ /* istanbul ignore next -- every fixture body is authored with an orientation. */
54
+ const rotated = (0, physics3d_shared_js_1.rotatePointByQuat)(body.orientation ?? IDENT, local);
55
+ return { x: body.x + rotated.x, y: body.y + rotated.y, z: body.z + rotated.z };
56
+ }
57
+ function runRagdollLoop(bodies, joints, frames) {
58
+ let world = (0, physics3d_js_1.createDeterministicPhysicsWorld3D)(bodies);
59
+ const snapshotFrame = Math.floor(frames / 2);
60
+ let snapshot = world;
61
+ for (let frame = 0; frame < frames; frame += 1) {
62
+ if (frame === snapshotFrame) {
63
+ snapshot = (0, canonical_js_1.cloneCanonical)(world);
64
+ }
65
+ world = (0, physics3d_js_1.stepDeterministicPhysicsWorld3D)(world, { gravityY: RAGDOLL_GRAVITY_Y, joints, computeChecksum: false }).world;
66
+ }
67
+ return { world, snapshot, snapshotFrame };
68
+ }
69
+ function runDeterministicRagdoll3DFixture(frames = 600) {
70
+ const bodies = ragdollBodies();
71
+ const joints = ragdollJoints();
72
+ const main = runRagdollLoop(bodies, joints, frames);
73
+ let restored = main.snapshot;
74
+ for (let frame = main.snapshotFrame; frame < frames; frame += 1) {
75
+ restored = (0, physics3d_js_1.stepDeterministicPhysicsWorld3D)(restored, { gravityY: RAGDOLL_GRAVITY_Y, joints, computeChecksum: false }).world;
76
+ }
77
+ const permuted = runRagdollLoop([...bodies].reverse(), [...joints].reverse(), frames);
78
+ const dyn = main.world.bodies.filter((body) => body.kind === 'dynamic');
79
+ let maxFinalSpeed = 0;
80
+ let maxFinalSpin = 0;
81
+ for (const body of dyn) {
82
+ maxFinalSpeed = Math.max(maxFinalSpeed, Math.abs(body.vx), Math.abs(body.vy), Math.abs(body.vz));
83
+ /* istanbul ignore next -- every fixture body is authored with an angularVel. */
84
+ const av = body.angularVel ?? { x: 0, y: 0, z: 0 };
85
+ maxFinalSpin = Math.max(maxFinalSpin, Math.abs(av.x), Math.abs(av.y), Math.abs(av.z));
86
+ }
87
+ let maxJointGap = 0;
88
+ for (const joint of joints) {
89
+ /* istanbul ignore next -- the ragdoll authors only ball/hinge joints; grab joints carry no anchor pair. */
90
+ if (joint.type === 'grab') {
91
+ continue;
92
+ }
93
+ const anchorA = worldAnchor(main.world, joint.a, joint.anchorA);
94
+ const anchorB = worldAnchor(main.world, joint.b, joint.anchorB);
95
+ const gap = Math.hypot(anchorA.x - anchorB.x, anchorA.y - anchorB.y, anchorA.z - anchorB.z);
96
+ maxJointGap = Math.max(maxJointGap, gap);
97
+ }
98
+ const brokenJoints = main.world.jointCache.filter((entry) => entry.broken === true).length;
99
+ const checksum = (0, canonical_js_1.defaultChecksum)(main.world);
100
+ return {
101
+ checksum,
102
+ restoredChecksum: (0, canonical_js_1.defaultChecksum)(restored),
103
+ permutedChecksum: (0, canonical_js_1.defaultChecksum)(permuted.world),
104
+ rollbackEquivalent: checksum === (0, canonical_js_1.defaultChecksum)(restored),
105
+ stableUnderAuthoringOrderPermutation: checksum === (0, canonical_js_1.defaultChecksum)(permuted.world),
106
+ maxFinalSpeed,
107
+ maxFinalSpin,
108
+ bodies: dyn.length,
109
+ maxJointGap,
110
+ brokenJoints,
111
+ };
112
+ }
113
+ // Rigid-wheel car: a chassis with four sphere wheels driven by hinge motors —
114
+ // no raycast controller, just the rigid solver. The `angularDamping: 1` on the
115
+ // wheels is load-bearing: the default 0.98 bleeds wheel spin and the car crawls.
116
+ const WHEEL_OFFSETS = [
117
+ { id: 'wheel-fl', x: -0.9, z: 1.6, steered: true },
118
+ { id: 'wheel-fr', x: 0.9, z: 1.6, steered: true },
119
+ { id: 'wheel-rl', x: -0.9, z: -1.6, steered: false },
120
+ { id: 'wheel-rr', x: 0.9, z: -1.6, steered: false },
121
+ ];
122
+ function rigidWheelBodies() {
123
+ const bodies = [
124
+ { id: 'floor', kind: 'static', x: 0, y: -1, z: 0, vx: 0, vy: 0, vz: 0, shape: { type: 'box', halfX: 60, halfY: 1, halfZ: 60 }, layer: 1, mask: 1, material: { friction: 10, restitution: 0 } },
125
+ { id: 'chassis', kind: 'dynamic', x: 0, y: 1, z: 0, vx: 0, vy: 0, vz: 0, orientation: IDENT, angularVel: { x: 0, y: 0, z: 0 }, shape: { type: 'box', halfX: 0.9, halfY: 0.25, halfZ: 1.6 }, layer: 1, mask: 1, material: { friction: 4, restitution: 0 }, mass: 8 },
126
+ ];
127
+ for (const wheel of WHEEL_OFFSETS) {
128
+ bodies.push({ id: wheel.id, kind: 'dynamic', x: wheel.x, y: 0.5, z: wheel.z, vx: 0, vy: 0, vz: 0, orientation: IDENT, angularVel: { x: 0, y: 0, z: 0 }, shape: { type: 'sphere', radius: 0.5 }, layer: 1, mask: 1, material: { friction: 10, restitution: 0 }, mass: 0.5, angularDamping: 1 });
129
+ }
130
+ return bodies;
131
+ }
132
+ // Rotate the spin axis (world +x) about world +y by `steerTurns` so a steered
133
+ // front wheel rolls at an angle. Deterministic fixed-point trig, quantized.
134
+ function steerAxis(steerTurns) {
135
+ const fixed = Math.round(steerTurns * recipeMath.fixedScale);
136
+ const cos = recipeMath.cosTurns(fixed) / recipeMath.fixedScale;
137
+ const sin = recipeMath.sinTurns(fixed) / recipeMath.fixedScale;
138
+ return { x: cos, y: 0, z: -sin };
139
+ }
140
+ function rigidWheelJoints(steerTurns) {
141
+ return WHEEL_OFFSETS.map((wheel) => ({
142
+ id: `axle-${wheel.id}`,
143
+ type: 'hinge',
144
+ a: 'chassis',
145
+ b: wheel.id,
146
+ anchorA: { x: wheel.x, y: -0.5, z: wheel.z },
147
+ anchorB: { x: 0, y: 0, z: 0 },
148
+ axisA: wheel.steered ? steerAxis(steerTurns) : { x: 1, y: 0, z: 0 },
149
+ motorSpeed: -0.15,
150
+ maxMotorTorque: 0.4,
151
+ disableCollision: true,
152
+ }));
153
+ }
154
+ // Straight until frame 300, steer left through frame 450, then straight again.
155
+ function steerForFrame(frame) {
156
+ return frame >= 300 && frame < 450 ? 0.03 : 0;
157
+ }
158
+ function runRigidWheelLoop(bodies, frames) {
159
+ let world = (0, physics3d_js_1.createDeterministicPhysicsWorld3D)(bodies);
160
+ const snapshotFrame = Math.floor(frames / 2);
161
+ let snapshot = world;
162
+ let minUpDot = 1;
163
+ for (let frame = 0; frame < frames; frame += 1) {
164
+ if (frame === snapshotFrame) {
165
+ snapshot = (0, canonical_js_1.cloneCanonical)(world);
166
+ }
167
+ world = (0, physics3d_js_1.stepDeterministicPhysicsWorld3D)(world, { gravityY: VEHICLE_GRAVITY_Y, joints: rigidWheelJoints(steerForFrame(frame)), computeChecksum: false }).world;
168
+ const chassis = world.bodies.find((body) => body.id === 'chassis');
169
+ /* istanbul ignore else -- the chassis is authored by the fixture and never removed. */
170
+ if (chassis !== undefined) {
171
+ minUpDot = Math.min(minUpDot, upDot(chassis.orientation));
172
+ }
173
+ }
174
+ return { world, snapshot, snapshotFrame, minUpDot };
175
+ }
176
+ function runDeterministicRigidWheelVehicle3DFixture(frames = 600) {
177
+ const bodies = rigidWheelBodies();
178
+ const main = runRigidWheelLoop(bodies, frames);
179
+ let restored = main.snapshot;
180
+ for (let frame = main.snapshotFrame; frame < frames; frame += 1) {
181
+ restored = (0, physics3d_js_1.stepDeterministicPhysicsWorld3D)(restored, { gravityY: VEHICLE_GRAVITY_Y, joints: rigidWheelJoints(steerForFrame(frame)), computeChecksum: false }).world;
182
+ }
183
+ const permuted = runRigidWheelLoop([...bodies].reverse(), frames);
184
+ const chassis = main.world.bodies.find((body) => body.id === 'chassis');
185
+ const checksum = (0, canonical_js_1.defaultChecksum)(main.world);
186
+ return {
187
+ checksum,
188
+ restoredChecksum: (0, canonical_js_1.defaultChecksum)(restored),
189
+ permutedChecksum: (0, canonical_js_1.defaultChecksum)(permuted.world),
190
+ rollbackEquivalent: checksum === (0, canonical_js_1.defaultChecksum)(restored),
191
+ stableUnderAuthoringOrderPermutation: checksum === (0, canonical_js_1.defaultChecksum)(permuted.world),
192
+ forwardDistance: chassis.z,
193
+ lateralDistance: Math.abs(chassis.x),
194
+ minChassisUpDot: main.minUpDot,
195
+ bodies: main.world.bodies.filter((body) => body.kind === 'dynamic').length,
196
+ };
197
+ }
@@ -81,6 +81,12 @@ function solverSqrt(value) {
81
81
  if (value <= 0) {
82
82
  return 0;
83
83
  }
84
+ // Non-finite guard: `while (magnitude >= 4) magnitude /= 4` never terminates for
85
+ // Infinity (Inf/4 === Inf). Returning value keeps sqrt(Inf) = Inf / sqrt(NaN) = NaN
86
+ // without spinning, so a diverged body surfaces as a bad checksum, not a hang.
87
+ if (!Number.isFinite(value)) {
88
+ return value;
89
+ }
84
90
  let estimate = 1;
85
91
  let magnitude = value;
86
92
  while (magnitude >= 4) {