@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
@@ -15,7 +15,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
15
15
  exports.__solverInternals = exports.WARM_KEY_BASE = exports.SOLVER_CONSTANTS = void 0;
16
16
  exports.generateManifold = generateManifold;
17
17
  exports.solveRigidStep = solveRigidStep;
18
+ exports.minExtentOf = minExtentOf;
19
+ exports.boundingRadiusOf = boundingRadiusOf;
18
20
  const physics3d_shared_js_1 = require("./physics3d-shared.js");
21
+ const physics3d_hull_js_1 = require("./physics3d-hull.js");
19
22
  const ZERO = { x: 0, y: 0, z: 0 };
20
23
  /** Expand a compound body into primitive sub-bodies in world space. */
21
24
  function primitiveViews(body) {
@@ -128,17 +131,38 @@ function collidePrimitives(a, b, centerA, centerB, speculativeMargin, featureBas
128
131
  if (ka === 'capsule' && kb === 'box') {
129
132
  return capsuleBox(a, b, centerA, centerB, speculativeMargin, featureBase, false);
130
133
  }
131
- /* istanbul ignore next -- when a box operand reaches this point kb is necessarily 'capsule' (box-box/box-sphere returned earlier), so the second condition is always true. */
132
134
  if (ka === 'box' && kb === 'capsule') {
133
135
  return capsuleBox(b, a, centerB, centerA, speculativeMargin, featureBase, true);
134
136
  }
135
- /* istanbul ignore next -- every primitive shape pairing is enumerated above; this is an exhaustiveness guard. */
137
+ if (ka === 'hull' && kb === 'hull') {
138
+ return collideHulls(a, b, centerA, centerB, speculativeMargin, featureBase);
139
+ }
140
+ if (ka === 'hull' && kb === 'box') {
141
+ return collideHulls(a, boxAsHull(b), centerA, centerB, speculativeMargin, featureBase);
142
+ }
143
+ if (ka === 'box' && kb === 'hull') {
144
+ return collideHulls(boxAsHull(a), b, centerA, centerB, speculativeMargin, featureBase);
145
+ }
146
+ if (ka === 'hull' && kb === 'sphere') {
147
+ return sphereHull(b, a, centerB, centerA, speculativeMargin, featureBase, true);
148
+ }
149
+ if (ka === 'sphere' && kb === 'hull') {
150
+ return sphereHull(a, b, centerA, centerB, speculativeMargin, featureBase, false);
151
+ }
152
+ if (ka === 'hull' && kb === 'capsule') {
153
+ return capsuleHull(b, a, centerB, centerA, speculativeMargin, featureBase, true);
154
+ }
155
+ /* istanbul ignore else -- every primitive shape pairing is enumerated above; the fallthrough is an exhaustiveness guard. */
156
+ if (ka === 'capsule' && kb === 'hull') {
157
+ return capsuleHull(a, b, centerA, centerB, speculativeMargin, featureBase, false);
158
+ }
159
+ /* istanbul ignore next -- unreachable exhaustiveness guard. */
136
160
  return undefined;
137
161
  }
138
162
  // ---------- sphere / capsule closed forms ----------
139
163
  function radiusOf(shape) {
140
- /* istanbul ignore next -- radiusOf is only called for sphere/capsule operands; the box arm is defensive. */
141
- return shape.kind === 'box' ? 0 : shape.radius;
164
+ /* istanbul ignore next -- radiusOf is only called for sphere/capsule operands; the non-radius arm is defensive. */
165
+ return shape.kind === 'sphere' || shape.kind === 'capsule' ? shape.radius : 0;
142
166
  }
143
167
  /**
144
168
  * Build a single contact point from two world points on the surfaces of two
@@ -617,6 +641,7 @@ function collideBoxes(A, B, centerA, centerB, speculativeMargin, featureBase) {
617
641
  swap = poly;
618
642
  poly = spare;
619
643
  spare = swap;
644
+ /* istanbul ignore next -- SAT already proved the faces overlap, so the Sutherland-Hodgman clip against the reference face's own side planes always leaves at least one vertex (20k-pair box/hull fuzz: never hit). */
620
645
  if (polyCount === 0) {
621
646
  return undefined;
622
647
  }
@@ -648,6 +673,404 @@ function collideBoxes(A, B, centerA, centerB, speculativeMargin, featureBase) {
648
673
  }
649
674
  return { a: 0, b: 0, normal, points };
650
675
  }
676
+ // ---------- convex hull SAT + face clipping ----------
677
+ /** Outward bias on reference side planes so boundary-incident corners survive fp clipping. */
678
+ const CLIP_BOUNDARY_TOL = 1e-4;
679
+ // Box-as-hull view: the 8 corners of a box are a valid convex hull, so hull×box
680
+ // reuses the generalized hull path. Keyed by the box shape object identity (pure
681
+ // derived data, never canonical state — same rationale as quatMat3Cache).
682
+ // rdm-ignore-next-line determinism/no-module-mutable-state: pure derived box-hull view keyed by immutable box shape identity
683
+ const boxHullViewCache = new WeakMap();
684
+ function boxAsHull(box) {
685
+ const shape = box.shape;
686
+ /* istanbul ignore next -- boxAsHull is only called for box operands; type guard only. */
687
+ if (shape.kind !== 'box') {
688
+ throw new Error('boxAsHull requires a box');
689
+ }
690
+ let data = boxHullViewCache.get(shape);
691
+ if (data === undefined) {
692
+ const h = shape.half;
693
+ const verts = [];
694
+ for (const sx of [-1, 1]) {
695
+ for (const sy of [-1, 1]) {
696
+ for (const sz of [-1, 1]) {
697
+ verts.push({ x: sx * h.x, y: sy * h.y, z: sz * h.z });
698
+ }
699
+ }
700
+ }
701
+ data = (0, physics3d_hull_js_1.buildSolverHull)(verts);
702
+ boxHullViewCache.set(shape, data);
703
+ }
704
+ return { index: box.index, p: box.p, q: box.q, shape: { kind: 'hull', data } };
705
+ }
706
+ function hullData(shape) {
707
+ /* istanbul ignore next -- callers guard hull; type guard only. */
708
+ if (shape.kind !== 'hull') {
709
+ throw new Error('hullData requires a hull');
710
+ }
711
+ return shape.data;
712
+ }
713
+ /** World-space hull vertices (support samples for mesh + queries). */
714
+ function hullWorldVertices(hull) {
715
+ const data = hullData(hull.shape);
716
+ return data.vertices.map((v) => (0, physics3d_shared_js_1.addVec3)(hull.p, (0, physics3d_shared_js_1.rotatePointByQuat)(hull.q, v)));
717
+ }
718
+ function hullWorldFaces(hull, worldVerts) {
719
+ const data = hullData(hull.shape);
720
+ const rot = quatToMat3Cached(hull.q);
721
+ return data.faces.map((face) => {
722
+ const nx = rot[0] * face.normal.x + rot[1] * face.normal.y + rot[2] * face.normal.z;
723
+ const ny = rot[3] * face.normal.x + rot[4] * face.normal.y + rot[5] * face.normal.z;
724
+ const nz = rot[6] * face.normal.x + rot[7] * face.normal.y + rot[8] * face.normal.z;
725
+ const v0 = worldVerts[face.verts[0]];
726
+ const offset = nx * v0.x + ny * v0.y + nz * v0.z;
727
+ return { nx, ny, nz, offset, verts: face.verts };
728
+ });
729
+ }
730
+ function hullWorldEdgeDirs(hull) {
731
+ const data = hullData(hull.shape);
732
+ return data.edgeDirs.map((d) => (0, physics3d_shared_js_1.rotatePointByQuat)(hull.q, d));
733
+ }
734
+ /**
735
+ * Project a hull (given its world vertices) onto an axis, returning the max
736
+ * support value dot(n, v). Used for SAT separation along arbitrary axes.
737
+ */
738
+ function hullSupportMax(worldVerts, nx, ny, nz) {
739
+ let max = -Infinity;
740
+ for (const v of worldVerts) {
741
+ const d = nx * v.x + ny * v.y + nz * v.z;
742
+ if (d > max) {
743
+ max = d;
744
+ }
745
+ }
746
+ return max;
747
+ }
748
+ function hullSupportMin(worldVerts, nx, ny, nz) {
749
+ let min = Infinity;
750
+ for (const v of worldVerts) {
751
+ const d = nx * v.x + ny * v.y + nz * v.z;
752
+ if (d < min) {
753
+ min = d;
754
+ }
755
+ }
756
+ return min;
757
+ }
758
+ /** Separation of B beyond A along axis n (positive = separated). */
759
+ function axisSeparation(vertsA, vertsB, nx, ny, nz) {
760
+ const maxA = hullSupportMax(vertsA, nx, ny, nz);
761
+ const minB = hullSupportMin(vertsB, nx, ny, nz);
762
+ return minB - maxA;
763
+ }
764
+ function collideHulls(A, B, centerA, centerB, speculativeMargin, featureBase) {
765
+ const vertsA = hullWorldVertices(A);
766
+ const vertsB = hullWorldVertices(B);
767
+ const facesA = hullWorldFaces(A, vertsA);
768
+ const facesB = hullWorldFaces(B, vertsB);
769
+ const dx = B.p.x - A.p.x;
770
+ const dy = B.p.y - A.p.y;
771
+ const dz = B.p.z - A.p.z;
772
+ // Face SAT: use each face's outward normal directly (already oriented).
773
+ let faceSep = -Infinity;
774
+ let faceRefIsA = true;
775
+ let faceIndex = -1;
776
+ let faceNx = 0;
777
+ let faceNy = 0;
778
+ let faceNz = 0;
779
+ for (let i = 0; i < facesA.length; i += 1) {
780
+ const f = facesA[i];
781
+ // Reference face outward normal points away from A. All A verts satisfy
782
+ // dot(n,v) <= offset; separation = min_B(dot(n,v)) - offset (>0 = gap).
783
+ // The best reference face maximizes separation (shallowest penetration).
784
+ // Strict-greater keeps the lowest sorted-index face on near-ties (stable).
785
+ const s = hullSupportMin(vertsB, f.nx, f.ny, f.nz) - f.offset;
786
+ if (s > faceSep + (i === 0 ? 0 : 1e-9)) {
787
+ faceSep = s;
788
+ faceRefIsA = true;
789
+ faceIndex = i;
790
+ faceNx = f.nx;
791
+ faceNy = f.ny;
792
+ faceNz = f.nz;
793
+ }
794
+ }
795
+ for (let i = 0; i < facesB.length; i += 1) {
796
+ const f = facesB[i];
797
+ const s = hullSupportMin(vertsA, f.nx, f.ny, f.nz) - f.offset;
798
+ // Hysteresis: B's face wins only when strictly deeper, so a resting stack
799
+ // keeps a stable reference frame-to-frame (warm-start anchors don't jump).
800
+ if (s > faceSep + 1e-9) {
801
+ faceSep = s;
802
+ faceRefIsA = false;
803
+ faceIndex = i;
804
+ faceNx = f.nx;
805
+ faceNy = f.ny;
806
+ faceNz = f.nz;
807
+ }
808
+ }
809
+ if (faceSep > speculativeMargin) {
810
+ return undefined;
811
+ }
812
+ // Edge SAT: cross products of edge-direction pairs.
813
+ const edgesA = hullWorldEdgeDirs(A);
814
+ const edgesB = hullWorldEdgeDirs(B);
815
+ let edgeSep = -Infinity;
816
+ let edgeNx = 0;
817
+ let edgeNy = 0;
818
+ let edgeNz = 0;
819
+ let edgeFound = false;
820
+ let edgePairIndex = -1;
821
+ for (let i = 0; i < edgesA.length; i += 1) {
822
+ const ea = edgesA[i];
823
+ for (let j = 0; j < edgesB.length; j += 1) {
824
+ const eb = edgesB[j];
825
+ let cx = ea.y * eb.z - ea.z * eb.y;
826
+ let cy = ea.z * eb.x - ea.x * eb.z;
827
+ let cz = ea.x * eb.y - ea.y * eb.x;
828
+ const l2 = cx * cx + cy * cy + cz * cz;
829
+ if (l2 < 1e-8) {
830
+ continue;
831
+ }
832
+ const inv = 1 / (0, physics3d_shared_js_1.solverSqrt)(l2);
833
+ cx *= inv;
834
+ cy *= inv;
835
+ cz *= inv;
836
+ // orient axis from A toward B
837
+ if (cx * dx + cy * dy + cz * dz < 0) {
838
+ cx = -cx;
839
+ cy = -cy;
840
+ cz = -cz;
841
+ }
842
+ const sep = axisSeparation(vertsA, vertsB, cx, cy, cz);
843
+ if (sep > speculativeMargin) {
844
+ return undefined;
845
+ }
846
+ if (sep > edgeSep + 1e-9) {
847
+ edgeSep = sep;
848
+ edgeNx = cx;
849
+ edgeNy = cy;
850
+ edgeNz = cz;
851
+ edgeFound = true;
852
+ edgePairIndex = i * edgesB.length + j;
853
+ }
854
+ }
855
+ }
856
+ // qu3e-style hysteresis: prefer the edge axis only when decisively shallower
857
+ // separation (deeper penetration) than the face axis, and only for real overlaps.
858
+ const facePen = -faceSep;
859
+ const edgePen = -edgeSep;
860
+ const edgePreferred = edgeFound && facePen > 0 && edgePen < facePen * 0.9 - 5e-3;
861
+ if (edgePreferred) {
862
+ return hullEdgeContact(A, B, edgesA, edgesB, edgePairIndex, edgeNx, edgeNy, edgeNz, edgePen, centerA, centerB, featureBase);
863
+ }
864
+ return hullFaceContact(faceRefIsA ? A : B, faceRefIsA ? B : A, faceRefIsA ? vertsA : vertsB, faceRefIsA ? vertsB : vertsA, faceRefIsA ? facesA : facesB, faceRefIsA ? facesB : facesA, faceIndex, faceNx, faceNy, faceNz, faceRefIsA, speculativeMargin, centerA, centerB, featureBase);
865
+ }
866
+ function hullEdgeContact(A, B, edgesA, edgesB, pairIndex, nx, ny, nz, penetration, centerA, centerB, featureBase) {
867
+ const i = Math.trunc(pairIndex / edgesB.length);
868
+ const j = pairIndex % edgesB.length;
869
+ const vertsA = hullWorldVertices(A);
870
+ const vertsB = hullWorldVertices(B);
871
+ // support vertices of A along +n and B along -n approximate the contacting edges
872
+ const pA = supportVertex(vertsA, nx, ny, nz);
873
+ const pB = supportVertex(vertsB, -nx, -ny, -nz);
874
+ const eA = edgesA[i];
875
+ const eB = edgesB[j];
876
+ const seg = closestSegmentSegment((0, physics3d_shared_js_1.subVec3)(pA, (0, physics3d_shared_js_1.scaleVec3)(eA, 1)), (0, physics3d_shared_js_1.addVec3)(pA, (0, physics3d_shared_js_1.scaleVec3)(eA, 1)), (0, physics3d_shared_js_1.subVec3)(pB, (0, physics3d_shared_js_1.scaleVec3)(eB, 1)), (0, physics3d_shared_js_1.addVec3)(pB, (0, physics3d_shared_js_1.scaleVec3)(eB, 1)));
877
+ const mid = (0, physics3d_shared_js_1.scaleVec3)((0, physics3d_shared_js_1.addVec3)(seg.c1, seg.c2), 0.5);
878
+ const normal = { x: nx, y: ny, z: nz };
879
+ return {
880
+ a: 0,
881
+ b: 0,
882
+ normal,
883
+ points: [{ rA: (0, physics3d_shared_js_1.subVec3)(mid, centerA), rB: (0, physics3d_shared_js_1.subVec3)(mid, centerB), penetration, feature: featureBase + 4096 + pairIndex }],
884
+ };
885
+ }
886
+ function supportVertex(worldVerts, nx, ny, nz) {
887
+ let best = worldVerts[0];
888
+ let bestD = -Infinity;
889
+ for (const v of worldVerts) {
890
+ const d = nx * v.x + ny * v.y + nz * v.z;
891
+ if (d > bestD) {
892
+ bestD = d;
893
+ best = v;
894
+ }
895
+ }
896
+ return best;
897
+ }
898
+ function hullFaceContact(ref, inc, refVerts, incVerts, refFaces, incFaces, refFaceIndex, nx, ny, nz, refIsA, speculativeMargin, centerA, centerB, featureBase) {
899
+ void ref;
900
+ void inc;
901
+ const refFace = refFaces[refFaceIndex];
902
+ // Incident face = the incident-hull face most anti-parallel to the reference normal.
903
+ let incFaceIndex = 0;
904
+ let minDot = Infinity;
905
+ for (let i = 0; i < incFaces.length; i += 1) {
906
+ const f = incFaces[i];
907
+ const d = f.nx * nx + f.ny * ny + f.nz * nz;
908
+ // Strict-less keeps the lowest sorted-index incident face on near-ties, so
909
+ // the clipped polygon (and its feature ids) stay stable frame-to-frame.
910
+ if (d < minDot - (i === 0 ? Infinity : 1e-9)) {
911
+ minDot = d;
912
+ incFaceIndex = i;
913
+ }
914
+ }
915
+ const incFace = incFaces[incFaceIndex];
916
+ // Load incident face polygon into scratch.
917
+ ensureClipSlots(clipScratch.a, incFace.verts.length);
918
+ let poly = clipScratch.a;
919
+ let spare = clipScratch.b;
920
+ let polyCount = 0;
921
+ for (const vi of incFace.verts) {
922
+ const v = incVerts[vi];
923
+ const slot = poly[polyCount];
924
+ slot.x = v.x;
925
+ slot.y = v.y;
926
+ slot.z = v.z;
927
+ slot.id = featureBase + refFaceIndex * 64 + polyCount + 1;
928
+ polyCount += 1;
929
+ }
930
+ // Clip the incident polygon against each reference-face side plane. Each side
931
+ // plane's normal must point OUTWARD from the reference polygon so clipPlaneScratch
932
+ // (keeps dc <= 0) retains interior points. Orientation is fixed against the
933
+ // face centroid rather than assuming a winding convention.
934
+ const rv = refFace.verts;
935
+ let rcx = 0;
936
+ let rcy = 0;
937
+ let rcz = 0;
938
+ for (const vi of rv) {
939
+ rcx += refVerts[vi].x;
940
+ rcy += refVerts[vi].y;
941
+ rcz += refVerts[vi].z;
942
+ }
943
+ const rInv = 1 / rv.length;
944
+ rcx *= rInv;
945
+ rcy *= rInv;
946
+ rcz *= rInv;
947
+ for (let e = 0; e < rv.length; e += 1) {
948
+ const a = refVerts[rv[e]];
949
+ const b = refVerts[rv[(e + 1) % rv.length]];
950
+ const edge = (0, physics3d_shared_js_1.subVec3)(b, a);
951
+ let pnx = ny * edge.z - nz * edge.y;
952
+ let pny = nz * edge.x - nx * edge.z;
953
+ let pnz = nx * edge.y - ny * edge.x;
954
+ const l2 = pnx * pnx + pny * pny + pnz * pnz;
955
+ /* istanbul ignore next -- a reference-face edge is never degenerate: buildSolverHull dedupes vertices and box faces are axis-aligned quads (20k-pair fuzz: never hit). */
956
+ if (l2 < 1e-12) {
957
+ continue;
958
+ }
959
+ const invl = 1 / (0, physics3d_shared_js_1.solverSqrt)(l2);
960
+ pnx *= invl;
961
+ pny *= invl;
962
+ pnz *= invl;
963
+ let offset = pnx * a.x + pny * a.y + pnz * a.z;
964
+ // Flip to outward: the centroid (interior) must lie on the negative side.
965
+ /* istanbul ignore else -- reference faces are wound CCW about their outward normal, so `n x edge` always points inward and the flip always runs (20k-pair box/hull fuzz: 48532 flips, 0 skips). The guard stays as a winding assertion. */
966
+ if (pnx * rcx + pny * rcy + pnz * rcz > offset) {
967
+ pnx = -pnx;
968
+ pny = -pny;
969
+ pnz = -pnz;
970
+ offset = -offset;
971
+ }
972
+ // Bias the side plane slightly outward so incident-face corners that lie
973
+ // exactly on a reference-face boundary survive floating-point clipping
974
+ // (dropping them mid-settle collapses a resting face manifold to 1-3 points
975
+ // on one side and injects drift). Box clipping avoids this via exact axis
976
+ // alignment; the generic hull path needs the tolerance.
977
+ offset += CLIP_BOUNDARY_TOL;
978
+ polyCount = clipPlaneScratch(poly, polyCount, pnx, pny, pnz, offset, e + 1, spare);
979
+ const swap = poly;
980
+ poly = spare;
981
+ spare = swap;
982
+ if (polyCount === 0) {
983
+ return undefined;
984
+ }
985
+ }
986
+ const refFaceDist = refFace.offset;
987
+ const points = [];
988
+ for (let i = 0; i < polyCount; i += 1) {
989
+ const vert = poly[i];
990
+ const depth = refFaceDist - (nx * vert.x + ny * vert.y + nz * vert.z);
991
+ if (depth >= -speculativeMargin) {
992
+ const onRefX = vert.x + nx * depth;
993
+ const onRefY = vert.y + ny * depth;
994
+ const onRefZ = vert.z + nz * depth;
995
+ const midX = (vert.x + onRefX) * 0.5;
996
+ const midY = (vert.y + onRefY) * 0.5;
997
+ const midZ = (vert.z + onRefZ) * 0.5;
998
+ points.push({
999
+ rA: { x: midX - centerA.x, y: midY - centerA.y, z: midZ - centerA.z },
1000
+ rB: { x: midX - centerB.x, y: midY - centerB.y, z: midZ - centerB.z },
1001
+ penetration: depth,
1002
+ feature: vert.id,
1003
+ });
1004
+ }
1005
+ }
1006
+ /* istanbul ignore next -- SAT overlap guarantees the incident face's deepest surviving vertex is within the speculative margin (20k-pair fuzz: never hit). */
1007
+ if (points.length === 0) {
1008
+ return undefined;
1009
+ }
1010
+ // Normal in caller A->B frame: reference face outward normal points ref->inc.
1011
+ // If ref is A, normal ref->inc = A->B = +n. If ref is B, A->B = -n.
1012
+ const normal = refIsA ? { x: nx, y: ny, z: nz } : { x: -nx, y: -ny, z: -nz };
1013
+ return { a: 0, b: 0, normal, points: reducePoints(points) };
1014
+ }
1015
+ function sphereHull(sphere, hull, cSphere, cHull, margin, feature, flip) {
1016
+ /* istanbul ignore next -- dispatch guarantees a (sphere, hull) pair; type guard only. */
1017
+ if (sphere.shape.kind !== 'sphere' || hull.shape.kind !== 'hull') {
1018
+ return undefined;
1019
+ }
1020
+ const radius = sphere.shape.radius;
1021
+ const data = hull.shape.data;
1022
+ const localCenter = (0, physics3d_shared_js_1.rotatePointByQuatInverse)(hull.q, (0, physics3d_shared_js_1.subVec3)(sphere.p, hull.p));
1023
+ const closest = (0, physics3d_hull_js_1.closestPointOnHullLocal)(localCenter, data);
1024
+ if (closest.inside) {
1025
+ // sphere center inside the hull: push out along the least-violated face normal.
1026
+ const nLocal = closest.normal;
1027
+ const nWorld = (0, physics3d_shared_js_1.rotatePointByQuat)(hull.q, nLocal); // outward from hull
1028
+ const penetration = -closest.distance + radius;
1029
+ const nSphereToHull = (0, physics3d_shared_js_1.scaleVec3)(nWorld, -1);
1030
+ return emitSingle(nSphereToHull, sphere.p, penetration, cSphere, cHull, feature, flip);
1031
+ }
1032
+ const closestWorld = (0, physics3d_shared_js_1.addVec3)(hull.p, (0, physics3d_shared_js_1.rotatePointByQuat)(hull.q, closest.point));
1033
+ const delta = (0, physics3d_shared_js_1.subVec3)(sphere.p, closestWorld);
1034
+ const dist = (0, physics3d_shared_js_1.solverSqrt)((0, physics3d_shared_js_1.dotVec3)(delta, delta));
1035
+ const penetration = radius - dist;
1036
+ if (penetration < -margin) {
1037
+ return undefined;
1038
+ }
1039
+ /* istanbul ignore next -- a sphere centre coincident with its closest hull point is `inside` and returned above, so dist is positive here. */
1040
+ const nHullToSphere = dist > 1e-9 ? (0, physics3d_shared_js_1.scaleVec3)(delta, 1 / dist) : (0, physics3d_shared_js_1.rotatePointByQuat)(hull.q, closest.normal);
1041
+ const nSphereToHull = (0, physics3d_shared_js_1.scaleVec3)(nHullToSphere, -1);
1042
+ const mid = (0, physics3d_shared_js_1.scaleVec3)((0, physics3d_shared_js_1.addVec3)(closestWorld, (0, physics3d_shared_js_1.subVec3)(sphere.p, (0, physics3d_shared_js_1.scaleVec3)(nHullToSphere, radius))), 0.5);
1043
+ return emitSingle(nSphereToHull, mid, penetration, cSphere, cHull, feature, flip);
1044
+ }
1045
+ function capsuleHull(capsule, hull, cCapsule, cHull, margin, feature, flip) {
1046
+ const seg = capsuleSegment(capsule);
1047
+ const samples = [seg.start, seg.end, (0, physics3d_shared_js_1.scaleVec3)((0, physics3d_shared_js_1.addVec3)(seg.start, seg.end), 0.5)];
1048
+ const points = [];
1049
+ let normal;
1050
+ let deepest = -Infinity;
1051
+ samples.forEach((sample, i) => {
1052
+ const sphere = { index: capsule.index, p: sample, q: capsule.q, shape: { kind: 'sphere', radius: seg.radius } };
1053
+ const sub = sphereHull(sphere, hull, cCapsule, cHull, margin, feature + i, flip);
1054
+ if (sub === undefined) {
1055
+ return;
1056
+ }
1057
+ for (const point of sub.points) {
1058
+ points.push(point);
1059
+ }
1060
+ const d = sub.points[0].penetration;
1061
+ if (d > deepest) {
1062
+ deepest = d;
1063
+ normal = sub.normal;
1064
+ }
1065
+ });
1066
+ if (points.length === 0 || normal === undefined) {
1067
+ return undefined;
1068
+ }
1069
+ return { a: 0, b: 0, normal, points };
1070
+ }
1071
+ function hullSupportSamples(hull) {
1072
+ return hullWorldVertices(hull);
1073
+ }
651
1074
  // ---------- mesh (one-sided static triangle) ----------
652
1075
  function meshManifold(a, b, speculativeMargin) {
653
1076
  const meshIsA = a.shape.kind === 'mesh';
@@ -667,29 +1090,55 @@ function meshManifold(a, b, speculativeMargin) {
667
1090
  (0, physics3d_shared_js_1.addVec3)(meshBody.p, (0, physics3d_shared_js_1.rotatePointByQuat)(meshBody.q, tri[2])),
668
1091
  ]);
669
1092
  for (const prim of prims) {
670
- /* istanbul ignore next -- primitiveViews only yields box/sphere/capsule sub-bodies (compound children are primitives); this skip is unreachable. */
671
- if (prim.shape.kind !== 'sphere' && prim.shape.kind !== 'capsule' && prim.shape.kind !== 'box') {
1093
+ /* istanbul ignore next -- primitiveViews only yields box/sphere/capsule/hull sub-bodies; this skip is unreachable. */
1094
+ if (prim.shape.kind !== 'sphere' && prim.shape.kind !== 'capsule' && prim.shape.kind !== 'box' && prim.shape.kind !== 'hull') {
672
1095
  continue;
673
1096
  }
674
- const radius = prim.shape.kind === 'box'
675
- ? 0
676
- : prim.shape.radius;
677
- // Represent boxes by their center as a fallback point-sphere against triangles
678
- // (dynamic box-vs-mesh uses the box's support samples).
1097
+ const radius = prim.shape.kind === 'sphere' || prim.shape.kind === 'capsule'
1098
+ ? prim.shape.radius
1099
+ : 0;
1100
+ // Boxes/hulls sample their support vertices against triangles (like dynamic
1101
+ // box-vs-mesh); sphere/capsule use center/segment samples.
679
1102
  const samplePoints = prim.shape.kind === 'box'
680
1103
  ? boxSupportSamples(prim)
681
- : prim.shape.kind === 'capsule'
682
- ? capsuleSamples(prim)
683
- : [prim.p];
1104
+ : prim.shape.kind === 'hull'
1105
+ ? hullSupportSamples(prim)
1106
+ : prim.shape.kind === 'capsule'
1107
+ ? capsuleSamples(prim)
1108
+ : [prim.p];
1109
+ // A support point that crossed behind the face plane still has to be pushed
1110
+ // back out, but a shape that fully passed the surface must not be yanked
1111
+ // back; cap the depth we will claim at the sample's own shape diameter.
1112
+ const maxPenetration = radius + 2 * boundingRadiusOf(prim.shape);
684
1113
  for (const triangle of triangles) {
685
1114
  for (const sample of samplePoints) {
686
1115
  const closest = closestPointOnTriangle(sample, triangle[0], triangle[1], triangle[2]);
687
1116
  const delta = (0, physics3d_shared_js_1.subVec3)(sample, closest);
688
- const dist = (0, physics3d_shared_js_1.solverSqrt)((0, physics3d_shared_js_1.dotVec3)(delta, delta));
689
1117
  const triNormal = triangleNormal(triangle[0], triangle[1], triangle[2]);
690
- const side = (0, physics3d_shared_js_1.dotVec3)(delta, triNormal);
691
- const penetration = radius - dist;
692
- if (penetration < -speculativeMargin || side < 0) {
1118
+ const planeDist = (0, physics3d_shared_js_1.dotVec3)((0, physics3d_shared_js_1.subVec3)(sample, triangle[0]), triNormal);
1119
+ // delta = perp + planeDist·n, so perp² = |delta|² - planeDist². A zero
1120
+ // perp means the sample projects onto the face interior (or an edge),
1121
+ // where the signed plane distance — not the unsigned closest-point
1122
+ // distance — carries the penetration. Zero-radius shapes (box, hull)
1123
+ // have no other source of depth, so this is the branch that lets them
1124
+ // rest on and be pushed by a mesh at all.
1125
+ const delta2 = (0, physics3d_shared_js_1.dotVec3)(delta, delta);
1126
+ const perp2 = delta2 - planeDist * planeDist;
1127
+ let penetration;
1128
+ if (perp2 <= exports.SOLVER_CONSTANTS.MESH_FACE_PROJECTION_TOL2) {
1129
+ penetration = radius - planeDist;
1130
+ if (penetration > maxPenetration) {
1131
+ continue;
1132
+ }
1133
+ }
1134
+ else {
1135
+ // Edge/vertex region: one-sided, front-face only.
1136
+ if (planeDist < 0) {
1137
+ continue;
1138
+ }
1139
+ penetration = radius - (0, physics3d_shared_js_1.solverSqrt)(delta2);
1140
+ }
1141
+ if (penetration < -speculativeMargin) {
693
1142
  continue;
694
1143
  }
695
1144
  const contactNormal = triNormal; // one-sided: mesh -> other
@@ -787,7 +1236,7 @@ function closestPointOnTriangle(p, a, b, c) {
787
1236
  // Sequential-impulse velocity solver + split impulse + islands + sleep
788
1237
  // ============================================================================
789
1238
  exports.SOLVER_CONSTANTS = {
790
- BAUMGARTE: 0.2,
1239
+ BAUMGARTE: 0.2, // joints only (split impulse) — contacts now soft
791
1240
  SLOP: 0.005,
792
1241
  WAKE_VEL2: 0.005,
793
1242
  SLEEP_VEL2: 0.005,
@@ -796,7 +1245,45 @@ exports.SOLVER_CONSTANTS = {
796
1245
  WARM_MATCH_TOL2: 0.01,
797
1246
  LINEAR_DAMPING: 0.995,
798
1247
  ANGULAR_DAMPING: 0.98,
1248
+ SUBSTEPS: 6,
1249
+ /**
1250
+ * Relax passes per substep = RELAX_ITERATION_SCALE × velocityIterations. The
1251
+ * bias-free relax must drain the warm-start over-push that multiple bias-solve
1252
+ * iterations accumulate into `accN`; under-draining lets weak-gravity stacks
1253
+ * ratchet apart. 2× fully settles the tall-stack / mass-ratio batteries.
1254
+ */
1255
+ RELAX_ITERATION_SCALE: 2,
1256
+ CONTACT_ZETA: 10,
1257
+ /** hertz = CONTACT_HERTZ_FRACTION · substeps/dt (dimensionless ω·h = π/2). */
1258
+ CONTACT_HERTZ_FRACTION: 0.25,
1259
+ /** Static/kinematic-involved pairs solve 2× stiffer (box2d staticSoftness). */
1260
+ STATIC_HERTZ_SCALE: 2,
1261
+ /** Clamp on soft-bias velocity, length units per tick. */
1262
+ MAX_CONTACT_PUSHOUT: 0.05,
1263
+ /**
1264
+ * Squared in-plane distance under which a mesh support sample counts as
1265
+ * projecting onto the triangle face rather than its edge/vertex region.
1266
+ * Sized well below the distance quantum so an exact face projection (perp²
1267
+ * accumulates only rounding error) never falls into the edge branch.
1268
+ */
1269
+ MESH_FACE_PROJECTION_TOL2: 1e-12,
1270
+ /**
1271
+ * A ccd body is "fast" (TOI-bound: pre-advanced to first contact with its approach
1272
+ * velocity arrested) when its intended per-step center motion exceeds this fraction
1273
+ * of its smallest world half-extent (box3d b3_isFast). Below it, the in-loop
1274
+ * speculative margin already suffices.
1275
+ */
1276
+ CCD_FAST_MOTION_FRACTION: 0.5,
1277
+ /**
1278
+ * box3d B3_SPECULATIVE_DISTANCE (4·SLOP): the small fixed speculative margin for ccd
1279
+ * contacts. It only bridges the exact-TOI clamp's residual separation so a clamped
1280
+ * fast body forms its contact next step; a wide velocity-scaled margin instead
1281
+ * generates diverging far-gap contacts (#2821). Fast bodies are kept from tunneling
1282
+ * by the exact-TOI position clamp (runRigidSolver), not by a wide margin.
1283
+ */
1284
+ CCD_SPECULATIVE_MARGIN: 0.02,
799
1285
  };
1286
+ const TWO_PI = 6.283185307179586;
800
1287
  /**
801
1288
  * Warm-cache maps pack the canonical index pair (a<b) into one exact integer
802
1289
  * `a * WARM_KEY_BASE + b` — string pair keys were measurable churn on large piles.
@@ -805,6 +1292,14 @@ exports.WARM_KEY_BASE = 2 ** 20;
805
1292
  function solid(b) {
806
1293
  return b.isDynamic && !b.sleeping;
807
1294
  }
1295
+ /**
1296
+ * Bodies whose pose advances this step. Kinematic bodies integrate but are
1297
+ * never solved: `solid` still gates every impulse, gravity, damping and sleep
1298
+ * path, so a kinematic body's authored velocity survives the step untouched.
1299
+ */
1300
+ function integrates(b) {
1301
+ return b.isKinematic || solid(b);
1302
+ }
808
1303
  function worldInvInertiaLocked(b) {
809
1304
  if (!solid(b)) {
810
1305
  return [0, 0, 0, 0, 0, 0, 0, 0, 0];
@@ -871,10 +1366,11 @@ function takeConstraint() {
871
1366
  if (constraintPool.high === constraintPool.slots.length) {
872
1367
  constraintPool.slots.push({
873
1368
  manifoldIndex: 0, a: 0, b: 0,
874
- normal: ZERO, t1: ZERO, t2: ZERO, rA: ZERO, rB: ZERO,
875
- penetration: 0, speculative: false,
876
- massN: 0, massT1: 0, massT2: 0, bias: 0,
877
- accN: 0, accT1: 0, accT2: 0, accNb: 0,
1369
+ normal: ZERO, t1: ZERO, t2: ZERO, rA: ZERO, rB: ZERO, rA0: ZERO,
1370
+ penetration: 0,
1371
+ massN: 0, massT1: 0, massT2: 0,
1372
+ biasRate: 0, massScale: 1, impulseScale: 0, vn0: 0,
1373
+ accN: 0, accT1: 0, accT2: 0, accNStep: 0,
878
1374
  mu: 0, restitution: 0,
879
1375
  });
880
1376
  }
@@ -890,19 +1386,30 @@ function solveRigidStep(bodies, options) {
890
1386
  }
891
1387
  constraintPool.high = 0;
892
1388
  const dt = options.dt;
893
- // 1. reset pseudo-velocities; apply gravity + damping to awake dynamics.
894
- // v/w are copied so the solver owns them: callers may pass aliased vectors
895
- // (canonical angularVel, shared zero constants) and the hot scalar impulse
896
- // path mutates these objects in place.
1389
+ const h = dt / options.substeps;
1390
+ // 1. reset pseudo-velocities; copy v/w so the solver owns them (callers may
1391
+ // pass aliased vectors — canonical angularVel, shared zero constants and the
1392
+ // hot scalar impulse path mutates these objects in place). Gravity + damping +
1393
+ // position integration now happen inside the sub-step loop. Locked axes are
1394
+ // zeroed here so residual input velocity on a locked axis never integrates.
897
1395
  for (const b of bodies) {
898
1396
  b.vb = { x: 0, y: 0, z: 0 };
899
1397
  b.wb = { x: 0, y: 0, z: 0 };
900
- b.v = { x: b.v.x, y: b.v.y, z: b.v.z };
901
- b.w = { x: b.w.x, y: b.w.y, z: b.w.z };
902
1398
  if (!solid(b)) {
1399
+ b.v = { x: b.v.x, y: b.v.y, z: b.v.z };
1400
+ b.w = { x: b.w.x, y: b.w.y, z: b.w.z };
903
1401
  continue;
904
1402
  }
905
- b.v.y = b.v.y + options.gravityY * b.gravityScale * dt;
1403
+ b.v = {
1404
+ x: b.invMassVec.x === 0 ? 0 : b.v.x,
1405
+ y: b.invMassVec.y === 0 ? 0 : b.v.y,
1406
+ z: b.invMassVec.z === 0 ? 0 : b.v.z,
1407
+ };
1408
+ b.w = {
1409
+ x: b.angularLock.x === 0 ? 0 : b.w.x,
1410
+ y: b.angularLock.y === 0 ? 0 : b.w.y,
1411
+ z: b.angularLock.z === 0 ? 0 : b.w.z,
1412
+ };
906
1413
  }
907
1414
  // 2. narrow-phase manifolds (canonical order i<j).
908
1415
  const manifolds = [];
@@ -921,7 +1428,7 @@ function solveRigidStep(bodies, options) {
921
1428
  if ((A.mask & B.layer) === 0 || (B.mask & A.layer) === 0) {
922
1429
  return;
923
1430
  }
924
- const margin = A.ccd || B.ccd ? speculativeMarginFor(A, B, dt) : 0;
1431
+ const margin = A.ccd || B.ccd ? exports.SOLVER_CONSTANTS.CCD_SPECULATIVE_MARGIN : 0;
925
1432
  const manifold = generateManifold(toGeom(A), toGeom(B), margin);
926
1433
  if (manifold !== undefined) {
927
1434
  manifolds.push(manifold);
@@ -986,9 +1493,35 @@ function solveRigidStep(bodies, options) {
986
1493
  const [t1, t2] = tangentBasis(m.normal);
987
1494
  const combinedMu = Math.floor((toInt10(A.matFriction) + toInt10(B.matFriction)) / 2) / 10;
988
1495
  const combinedRest = Math.max(toInt10(A.matRestitution), toInt10(B.matRestitution)) / 10;
1496
+ // Soft-constraint coefficients (box2d SoftStep): static/kinematic-involved
1497
+ // pairs solve stiffer. hertz = fraction · substeps/dt keeps ω·h = π/2.
1498
+ const softStatic = !A.isDynamic || !B.isDynamic;
1499
+ const hertz = (exports.SOLVER_CONSTANTS.CONTACT_HERTZ_FRACTION * options.substeps / dt)
1500
+ * (softStatic ? exports.SOLVER_CONSTANTS.STATIC_HERTZ_SCALE : 1);
1501
+ const omega = TWO_PI * hertz;
1502
+ const a1 = 2 * exports.SOLVER_CONSTANTS.CONTACT_ZETA + omega * h;
1503
+ const a2 = h * omega * a1;
1504
+ const a3 = 1 / (1 + a2);
1505
+ const biasRate = omega / a1;
1506
+ const massScale = a2 * a3;
1507
+ const impulseScale = a3;
989
1508
  const warmPts = options.warm.get(m.a * exports.WARM_KEY_BASE + m.b);
990
1509
  for (const pt of m.points) {
991
- const matched = matchWarm(warmPts, pt.rA);
1510
+ // A separated (speculative) contact anchors at the gap midpoint, so a wide margin
1511
+ // over a large gap yields anchors far from BOTH body centers (moment arms ≈ half
1512
+ // the gap), whose ω×r terms blow up the coupled relax at extreme speed (the #2821
1513
+ // cluster divergence). Pull each anchor back to its own body's surface witness
1514
+ // point along the normal by half the separation; a touching/penetrating contact
1515
+ // (penetration ≥ 0) is untouched, so resting stacks and the golden batteries are
1516
+ // byte-identical.
1517
+ let rA = pt.rA;
1518
+ let rB = pt.rB;
1519
+ if (pt.penetration < 0) {
1520
+ const half = -pt.penetration * 0.5;
1521
+ rA = { x: pt.rA.x - m.normal.x * half, y: pt.rA.y - m.normal.y * half, z: pt.rA.z - m.normal.z * half };
1522
+ rB = { x: pt.rB.x + m.normal.x * half, y: pt.rB.y + m.normal.y * half, z: pt.rB.z + m.normal.z * half };
1523
+ }
1524
+ const matched = matchWarm(warmPts, rA);
992
1525
  const constraint = takeConstraint();
993
1526
  constraint.manifoldIndex = manifoldIndex;
994
1527
  constraint.a = m.a;
@@ -996,22 +1529,26 @@ function solveRigidStep(bodies, options) {
996
1529
  constraint.normal = m.normal;
997
1530
  constraint.t1 = t1;
998
1531
  constraint.t2 = t2;
999
- constraint.rA = pt.rA;
1000
- constraint.rB = pt.rB;
1532
+ // Working anchors are fresh copies (rotated per substep); rA0 keeps the
1533
+ // frame-start anchor for the cache.
1534
+ constraint.rA = { x: rA.x, y: rA.y, z: rA.z };
1535
+ constraint.rB = { x: rB.x, y: rB.y, z: rB.z };
1536
+ constraint.rA0 = rA;
1001
1537
  constraint.penetration = pt.penetration;
1002
- constraint.speculative = pt.penetration < 0;
1003
- constraint.massN = effectiveMass(m.normal, pt.rA, pt.rB, invMass[m.a], invMass[m.b], invInertia[m.a], invInertia[m.b]);
1004
- constraint.massT1 = effectiveMass(t1, pt.rA, pt.rB, invMass[m.a], invMass[m.b], invInertia[m.a], invInertia[m.b]);
1005
- constraint.massT2 = effectiveMass(t2, pt.rA, pt.rB, invMass[m.a], invMass[m.b], invInertia[m.a], invInertia[m.b]);
1006
- constraint.bias = (exports.SOLVER_CONSTANTS.BAUMGARTE / dt) * Math.max(0, pt.penetration - exports.SOLVER_CONSTANTS.SLOP);
1538
+ constraint.massN = effectiveMass(m.normal, rA, rB, invMass[m.a], invMass[m.b], invInertia[m.a], invInertia[m.b]);
1539
+ constraint.massT1 = effectiveMass(t1, rA, rB, invMass[m.a], invMass[m.b], invInertia[m.a], invInertia[m.b]);
1540
+ constraint.massT2 = effectiveMass(t2, rA, rB, invMass[m.a], invMass[m.b], invInertia[m.a], invInertia[m.b]);
1541
+ constraint.biasRate = biasRate;
1542
+ constraint.massScale = massScale;
1543
+ constraint.impulseScale = impulseScale;
1544
+ // Approach velocity captured pre-substep (before any gravity) for restitution.
1545
+ constraint.vn0 = relativeVelocityAlong(A, B, rA, rB, m.normal.x, m.normal.y, m.normal.z);
1007
1546
  constraint.accN = matched?.normalImpulse ?? 0;
1008
1547
  constraint.accT1 = matched?.tangentImpulse1 ?? 0;
1009
1548
  constraint.accT2 = matched?.tangentImpulse2 ?? 0;
1010
- constraint.accNb = 0;
1549
+ constraint.accNStep = 0;
1011
1550
  constraint.mu = combinedMu;
1012
1551
  constraint.restitution = combinedRest;
1013
- // warm-start impulse, scalar form of normal*accN + (t1*accT1 + t2*accT2)
1014
- applyImpulseScalar(bodies, invMass, invInertia, constraint.a, constraint.b, constraint.rA, constraint.rB, m.normal.x * constraint.accN + (t1.x * constraint.accT1 + t2.x * constraint.accT2), m.normal.y * constraint.accN + (t1.y * constraint.accT1 + t2.y * constraint.accT2), m.normal.z * constraint.accN + (t1.z * constraint.accT1 + t2.z * constraint.accT2), false);
1015
1552
  constraints.push(constraint);
1016
1553
  }
1017
1554
  });
@@ -1020,7 +1557,7 @@ function solveRigidStep(bodies, options) {
1020
1557
  bodies,
1021
1558
  invMass,
1022
1559
  invInertia,
1023
- dt,
1560
+ dt: h,
1024
1561
  apply: (a, b, rA, rB, impulse) => applyImpulse(bodies, invMass, invInertia, a, b, rA, rB, impulse),
1025
1562
  applyAngular: (a, b, angularImpulse) => {
1026
1563
  const A = bodies[a];
@@ -1043,17 +1580,85 @@ function solveRigidStep(bodies, options) {
1043
1580
  applyBias: (a, b, rA, rB, impulse) => applyBiasImpulseAt(bodies, invMass, invInertia, a, b, rA, rB, impulse),
1044
1581
  biasRelativeVelocity: (a, b, rA, rB) => relativeBiasVelocity(bodies[a], bodies[b], rA, rB),
1045
1582
  };
1046
- for (const joint of joints) {
1047
- joint.prepare(jointCtx);
1048
- }
1049
- for (let iter = 0; iter < options.velocityIterations; iter += 1) {
1583
+ // 5. TGS-Soft sub-step loop: gravity(h) joint prepare + contact warm-start →
1584
+ // soft-bias iterations → position integration(h) + anchor/penetration update →
1585
+ // bias-free relax → per-substep joint-angle integration. One restitution pass
1586
+ // follows all substeps, driven by the pre-step approach velocity.
1587
+ for (let substep = 0; substep < options.substeps; substep += 1) {
1588
+ for (const b of bodies) {
1589
+ if (solid(b) && b.invMassVec.y !== 0) {
1590
+ b.v.y = b.v.y + options.gravityY * b.gravityScale * h;
1591
+ }
1592
+ }
1050
1593
  for (const joint of joints) {
1051
- joint.iterate(jointCtx);
1594
+ joint.prepare(jointCtx);
1595
+ }
1596
+ for (const c of constraints) {
1597
+ applyImpulseScalar(bodies, invMass, invInertia, c.a, c.b, c.rA, c.rB, c.normal.x * c.accN + (c.t1.x * c.accT1 + c.t2.x * c.accT2), c.normal.y * c.accN + (c.t1.y * c.accT1 + c.t2.y * c.accT2), c.normal.z * c.accN + (c.t1.z * c.accT1 + c.t2.z * c.accT2));
1598
+ }
1599
+ for (let iter = 0; iter < options.velocityIterations; iter += 1) {
1600
+ for (const joint of joints) {
1601
+ joint.iterate(jointCtx);
1602
+ }
1603
+ for (const c of constraints) {
1604
+ solveContactSoft(bodies, invMass, invInertia, c, h, true);
1605
+ }
1606
+ }
1607
+ for (const b of bodies) {
1608
+ if (!integrates(b)) {
1609
+ continue;
1610
+ }
1611
+ b.p = (0, physics3d_shared_js_1.addVec3)(b.p, (0, physics3d_shared_js_1.scaleVec3)((0, physics3d_shared_js_1.addVec3)(b.v, b.vb), h));
1612
+ if (b.hasOrientation) {
1613
+ b.q = (0, physics3d_shared_js_1.integrateQuat)(b.q, (0, physics3d_shared_js_1.addVec3)(b.w, b.wb), h);
1614
+ }
1052
1615
  }
1053
1616
  for (const c of constraints) {
1054
- solveContactConstraint(bodies, invMass, invInertia, c, dt);
1617
+ const A = bodies[c.a];
1618
+ const B = bodies[c.b];
1619
+ const dpA = integrates(A) ? (0, physics3d_shared_js_1.scaleVec3)((0, physics3d_shared_js_1.addVec3)(A.v, A.vb), h) : ZERO;
1620
+ const dpB = integrates(B) ? (0, physics3d_shared_js_1.scaleVec3)((0, physics3d_shared_js_1.addVec3)(B.v, B.vb), h) : ZERO;
1621
+ const rotA = integrates(A) ? (0, physics3d_shared_js_1.scaleVec3)((0, physics3d_shared_js_1.crossVec3)((0, physics3d_shared_js_1.addVec3)(A.w, A.wb), c.rA), h) : ZERO;
1622
+ const rotB = integrates(B) ? (0, physics3d_shared_js_1.scaleVec3)((0, physics3d_shared_js_1.crossVec3)((0, physics3d_shared_js_1.addVec3)(B.w, B.wb), c.rB), h) : ZERO;
1623
+ const rel = (0, physics3d_shared_js_1.subVec3)((0, physics3d_shared_js_1.addVec3)(dpB, rotB), (0, physics3d_shared_js_1.addVec3)(dpA, rotA));
1624
+ c.penetration -= (0, physics3d_shared_js_1.dotVec3)(rel, c.normal);
1625
+ c.rA = (0, physics3d_shared_js_1.addVec3)(c.rA, rotA);
1626
+ c.rB = (0, physics3d_shared_js_1.addVec3)(c.rB, rotB);
1627
+ }
1628
+ for (const b of bodies) {
1629
+ b.vb = { x: 0, y: 0, z: 0 };
1630
+ b.wb = { x: 0, y: 0, z: 0 };
1631
+ }
1632
+ const relaxIterations = options.velocityIterations * exports.SOLVER_CONSTANTS.RELAX_ITERATION_SCALE;
1633
+ for (let iter = 0; iter < relaxIterations; iter += 1) {
1634
+ for (const c of constraints) {
1635
+ solveContactSoft(bodies, invMass, invInertia, c, h, false);
1636
+ }
1637
+ }
1638
+ // Accumulate the per-substep normal impulse so contact events report the
1639
+ // full-step reaction (the raw accN converges to the sustaining per-substep value).
1640
+ for (const c of constraints) {
1641
+ c.accNStep += c.accN;
1642
+ }
1643
+ for (const joint of joints) {
1644
+ joint.postSubstep?.(jointCtx);
1055
1645
  }
1056
1646
  }
1647
+ // 5b. restitution: replay the pre-step approach velocity for bouncy contacts.
1648
+ for (const c of constraints) {
1649
+ if (c.restitution <= 0 || c.vn0 >= -exports.SOLVER_CONSTANTS.RESTITUTION_THRESHOLD || c.accN <= 0) {
1650
+ continue;
1651
+ }
1652
+ const A = bodies[c.a];
1653
+ const B = bodies[c.b];
1654
+ const vn = relativeVelocityAlong(A, B, c.rA, c.rB, c.normal.x, c.normal.y, c.normal.z);
1655
+ let dLambda = c.massN * (-c.restitution * c.vn0 - vn);
1656
+ const newAcc = Math.max(0, c.accN + dLambda);
1657
+ dLambda = newAcc - c.accN;
1658
+ c.accN = newAcc;
1659
+ c.accNStep += dLambda;
1660
+ applyImpulseScalar(bodies, invMass, invInertia, c.a, c.b, c.rA, c.rB, c.normal.x * dLambda, c.normal.y * dLambda, c.normal.z * dLambda);
1661
+ }
1057
1662
  // 6. store warm cache (index-pair keyed, deterministic order).
1058
1663
  const warm = new Map();
1059
1664
  for (const c of constraints) {
@@ -1063,12 +1668,13 @@ function solveRigidStep(bodies, options) {
1063
1668
  list = [];
1064
1669
  warm.set(key, list);
1065
1670
  }
1066
- list.push({ anchor: c.rA, normalImpulse: c.accN, tangentImpulse1: c.accT1, tangentImpulse2: c.accT2 });
1671
+ list.push({ anchor: c.rA0, normalImpulse: c.accN, tangentImpulse1: c.accT1, tangentImpulse2: c.accT2 });
1067
1672
  }
1068
1673
  // `jointBodies` (built at the wake pass) keeps joint members awake — a motor or
1069
1674
  // grab can drive them below the sleep velocity threshold, and joints are step
1070
1675
  // inputs the sleeper can't see.
1071
- // 7. damping + integrate (real + pseudo velocity).
1676
+ // 7. damping (once per step) + axis-lock zeroing + sleep counters. Positions
1677
+ // were already integrated per substep in step 5.
1072
1678
  for (const b of bodies) {
1073
1679
  if (!solid(b)) {
1074
1680
  continue;
@@ -1087,10 +1693,6 @@ function solveRigidStep(bodies, options) {
1087
1693
  y: b.angularLock.y === 0 ? 0 : b.w.y,
1088
1694
  z: b.angularLock.z === 0 ? 0 : b.w.z,
1089
1695
  };
1090
- b.p = (0, physics3d_shared_js_1.addVec3)(b.p, (0, physics3d_shared_js_1.scaleVec3)((0, physics3d_shared_js_1.addVec3)(b.v, b.vb), dt));
1091
- if (b.hasOrientation) {
1092
- b.q = (0, physics3d_shared_js_1.integrateQuat)(b.q, (0, physics3d_shared_js_1.addVec3)(b.w, b.wb), dt);
1093
- }
1094
1696
  const speed2 = (0, physics3d_shared_js_1.dotVec3)(b.v, b.v) + (0, physics3d_shared_js_1.dotVec3)(b.w, b.w);
1095
1697
  b.lowVelFrames = jointBodies.has(b.index) || speed2 >= exports.SOLVER_CONSTANTS.SLEEP_VEL2 ? 0 : b.lowVelFrames + 1;
1096
1698
  }
@@ -1099,7 +1701,7 @@ function solveRigidStep(bodies, options) {
1099
1701
  // 9. per-manifold accumulated normal impulse for contact events.
1100
1702
  const impulseByManifold = new Array(manifolds.length).fill(0);
1101
1703
  for (const c of constraints) {
1102
- impulseByManifold[c.manifoldIndex] += c.accN;
1704
+ impulseByManifold[c.manifoldIndex] += c.accNStep;
1103
1705
  }
1104
1706
  const contactImpulses = manifolds.map((m, index) => ({
1105
1707
  a: m.a,
@@ -1151,6 +1753,14 @@ function shapeHalfExtent(shape, q) {
1151
1753
  const reach = shape.radius + shape.halfHeight;
1152
1754
  return { x: reach, y: reach, z: reach };
1153
1755
  }
1756
+ if (shape.kind === 'hull') {
1757
+ let ext = { x: 0, y: 0, z: 0 };
1758
+ for (const vertex of shape.data.vertices) {
1759
+ const rotated = (0, physics3d_shared_js_1.rotatePointByQuat)(q, vertex);
1760
+ ext = { x: Math.max(ext.x, Math.abs(rotated.x)), y: Math.max(ext.y, Math.abs(rotated.y)), z: Math.max(ext.z, Math.abs(rotated.z)) };
1761
+ }
1762
+ return ext;
1763
+ }
1154
1764
  if (shape.kind === 'mesh') {
1155
1765
  let ext = { x: 0, y: 0, z: 0 };
1156
1766
  for (const tri of shape.triangles) {
@@ -1174,15 +1784,62 @@ function shapeHalfExtent(shape, q) {
1174
1784
  }
1175
1785
  return ext;
1176
1786
  }
1177
- function speculativeMarginFor(a, b, dt) {
1178
- const relSpeed = (0, physics3d_shared_js_1.solverSqrt)(Math.max((0, physics3d_shared_js_1.dotVec3)(a.v, a.v), (0, physics3d_shared_js_1.dotVec3)(b.v, b.v)));
1179
- return relSpeed * dt + exports.SOLVER_CONSTANTS.SLOP;
1787
+ /**
1788
+ * Smallest world half-extent of a shape (the box3d fast-flag denominator). Uses
1789
+ * the orientation-aware `shapeHalfExtent`, so a rotated thin box reports its true
1790
+ * thin axis. Returns 0 for a degenerate (flat/zero-extent) shape; callers treat a
1791
+ * non-positive minExtent as "never fast" (Req 10, no divide).
1792
+ */
1793
+ function minExtentOf(shape, q) {
1794
+ const h = shapeHalfExtent(shape, q);
1795
+ return Math.min(h.x, h.y, h.z);
1796
+ }
1797
+ /**
1798
+ * Rotation-invariant bounding radius (max distance from the shape center to any
1799
+ * surface point). Used as a conservative sphere for the fast-body TOI cast: a
1800
+ * bounding sphere contacts a target no later than the true shape would, so the
1801
+ * cast never lets a fast body tunnel (it stops it short, never past first contact).
1802
+ */
1803
+ function boundingRadiusOf(shape) {
1804
+ if (shape.kind === 'box') {
1805
+ return (0, physics3d_shared_js_1.solverSqrt)(shape.half.x * shape.half.x + shape.half.y * shape.half.y + shape.half.z * shape.half.z);
1806
+ }
1807
+ if (shape.kind === 'sphere') {
1808
+ return shape.radius;
1809
+ }
1810
+ if (shape.kind === 'capsule') {
1811
+ return shape.radius + shape.halfHeight;
1812
+ }
1813
+ if (shape.kind === 'hull') {
1814
+ let max2 = 0;
1815
+ for (const v of shape.data.vertices) {
1816
+ max2 = Math.max(max2, v.x * v.x + v.y * v.y + v.z * v.z);
1817
+ }
1818
+ return (0, physics3d_shared_js_1.solverSqrt)(max2);
1819
+ }
1820
+ if (shape.kind === 'mesh') {
1821
+ let max2 = 0;
1822
+ for (const tri of shape.triangles) {
1823
+ for (const v of tri) {
1824
+ max2 = Math.max(max2, v.x * v.x + v.y * v.y + v.z * v.z);
1825
+ }
1826
+ }
1827
+ return (0, physics3d_shared_js_1.solverSqrt)(max2);
1828
+ }
1829
+ let max = 0;
1830
+ for (const child of shape.children) {
1831
+ const offset = (0, physics3d_shared_js_1.solverSqrt)(child.offset.x * child.offset.x + child.offset.y * child.offset.y + child.offset.z * child.offset.z);
1832
+ max = Math.max(max, offset + boundingRadiusOf(child.shape));
1833
+ }
1834
+ return max;
1180
1835
  }
1181
1836
  function toGeom(b) {
1182
1837
  return { index: b.index, p: b.p, q: b.q, shape: b.shape };
1183
1838
  }
1184
1839
  function wakeIfContacted(sleeper, other) {
1185
- if (sleeper.isDynamic && sleeper.sleeping && other.isDynamic && !other.sleeping
1840
+ // A kinematic mover carries no impulse but does displace what it touches, so
1841
+ // it has to wake a sleeping pile the same way an awake dynamic neighbour does.
1842
+ if (sleeper.isDynamic && sleeper.sleeping && (other.isDynamic || other.isKinematic) && !other.sleeping
1186
1843
  && (0, physics3d_shared_js_1.dotVec3)(other.v, other.v) + (0, physics3d_shared_js_1.dotVec3)(other.w, other.w) > exports.SOLVER_CONSTANTS.WAKE_VEL2) {
1187
1844
  sleeper.sleeping = false;
1188
1845
  sleeper.lowVelFrames = 0;
@@ -1223,7 +1880,7 @@ function relativeBiasVelocity(A, B, rA, rB) {
1223
1880
  * order as the vector-helper form (bit-identical results), with zero
1224
1881
  * intermediate allocations. This runs per contact per velocity iteration.
1225
1882
  */
1226
- function applyImpulseScalar(bodies, invMass, invInertia, a, b, rA, rB, px, py, pz, bias) {
1883
+ function applyImpulseScalar(bodies, invMass, invInertia, a, b, rA, rB, px, py, pz) {
1227
1884
  const A = bodies[a];
1228
1885
  const B = bodies[b];
1229
1886
  if (solid(A)) {
@@ -1232,8 +1889,8 @@ function applyImpulseScalar(bodies, invMass, invInertia, a, b, rA, rB, px, py, p
1232
1889
  const cz = rA.x * py - rA.y * px;
1233
1890
  const m = invInertia[a];
1234
1891
  const inv = invMass[a];
1235
- const lin = bias ? A.vb : A.v;
1236
- const ang = bias ? A.wb : A.w;
1892
+ const lin = A.v;
1893
+ const ang = A.w;
1237
1894
  lin.x = lin.x - px * inv.x;
1238
1895
  lin.y = lin.y - py * inv.y;
1239
1896
  lin.z = lin.z - pz * inv.z;
@@ -1247,8 +1904,8 @@ function applyImpulseScalar(bodies, invMass, invInertia, a, b, rA, rB, px, py, p
1247
1904
  const cz = rB.x * py - rB.y * px;
1248
1905
  const m = invInertia[b];
1249
1906
  const inv = invMass[b];
1250
- const lin = bias ? B.vb : B.v;
1251
- const ang = bias ? B.wb : B.w;
1907
+ const lin = B.v;
1908
+ const ang = B.w;
1252
1909
  lin.x = lin.x + px * inv.x;
1253
1910
  lin.y = lin.y + py * inv.y;
1254
1911
  lin.z = lin.z + pz * inv.z;
@@ -1269,38 +1926,38 @@ function solveTangentImpulse(bodies, invMass, invInertia, c, A, B, t, mass, acc,
1269
1926
  let dF = mass * -vt;
1270
1927
  const clamped = (0, physics3d_shared_js_1.clampNumber)(acc + dF, -maxF, maxF);
1271
1928
  dF = clamped - acc;
1272
- applyImpulseScalar(bodies, invMass, invInertia, c.a, c.b, c.rA, c.rB, t.x * dF, t.y * dF, t.z * dF, false);
1929
+ applyImpulseScalar(bodies, invMass, invInertia, c.a, c.b, c.rA, c.rB, t.x * dF, t.y * dF, t.z * dF);
1273
1930
  return clamped;
1274
1931
  }
1275
- function solveContactConstraint(bodies, invMass, invInertia, c, dt) {
1932
+ /**
1933
+ * One soft-constraint contact solve (box2d SoftStep). `useBias` on the bias pass
1934
+ * folds the soft position bias into real velocity; the bias-free relax pass
1935
+ * (`useBias=false`) removes that transient energy after position integration.
1936
+ */
1937
+ function solveContactSoft(bodies, invMass, invInertia, c, h, useBias) {
1276
1938
  const A = bodies[c.a];
1277
1939
  const B = bodies[c.b];
1278
- // normal (no Baumgarte — position error handled by split impulse)
1279
1940
  const vn = relativeVelocityAlong(A, B, c.rA, c.rB, c.normal.x, c.normal.y, c.normal.z);
1280
- const restThreshold = exports.SOLVER_CONSTANTS.RESTITUTION_THRESHOLD;
1281
- const restBias = vn < -restThreshold ? -c.restitution * vn : 0;
1282
- // speculative points: allow closing up to the gap, never push apart.
1283
- const speculativeBias = c.speculative ? c.penetration / dt : 0;
1284
- let dLambda = c.massN * (-(vn) + restBias + speculativeBias);
1941
+ let bias = 0;
1942
+ let massScale = 1;
1943
+ let impulseScale = 0;
1944
+ if (c.penetration < 0) {
1945
+ // speculative / separated: allow closing exactly up to the gap, never push apart.
1946
+ bias = c.penetration / h;
1947
+ }
1948
+ else if (useBias) {
1949
+ const raw = c.biasRate * (c.penetration - exports.SOLVER_CONSTANTS.SLOP);
1950
+ bias = (0, physics3d_shared_js_1.clampNumber)(raw, 0, exports.SOLVER_CONSTANTS.MAX_CONTACT_PUSHOUT / h);
1951
+ massScale = c.massScale;
1952
+ impulseScale = c.impulseScale;
1953
+ }
1954
+ let dLambda = massScale * c.massN * (-(vn) + bias) - impulseScale * c.accN;
1285
1955
  const newAcc = Math.max(0, c.accN + dLambda);
1286
1956
  dLambda = newAcc - c.accN;
1287
1957
  c.accN = newAcc;
1288
- applyImpulseScalar(bodies, invMass, invInertia, c.a, c.b, c.rA, c.rB, c.normal.x * dLambda, c.normal.y * dLambda, c.normal.z * dLambda, false);
1289
- // split-impulse position bias (skip speculative points).
1290
- if (!c.speculative) {
1291
- const relbX = (B.vb.x + (B.wb.y * c.rB.z - B.wb.z * c.rB.y)) - (A.vb.x + (A.wb.y * c.rA.z - A.wb.z * c.rA.y));
1292
- const relbY = (B.vb.y + (B.wb.z * c.rB.x - B.wb.x * c.rB.z)) - (A.vb.y + (A.wb.z * c.rA.x - A.wb.x * c.rA.z));
1293
- const relbZ = (B.vb.z + (B.wb.x * c.rB.y - B.wb.y * c.rB.x)) - (A.vb.z + (A.wb.x * c.rA.y - A.wb.y * c.rA.x));
1294
- const vnb = relbX * c.normal.x + relbY * c.normal.y + relbZ * c.normal.z;
1295
- let dLb = c.massN * (c.bias - vnb);
1296
- const newAccNb = Math.max(0, c.accNb + dLb);
1297
- dLb = newAccNb - c.accNb;
1298
- c.accNb = newAccNb;
1299
- applyImpulseScalar(bodies, invMass, invInertia, c.a, c.b, c.rA, c.rB, c.normal.x * dLb, c.normal.y * dLb, c.normal.z * dLb, true);
1300
- }
1301
- // friction (skip for pure speculative points).
1302
- if (c.speculative) {
1303
- return;
1958
+ applyImpulseScalar(bodies, invMass, invInertia, c.a, c.b, c.rA, c.rB, c.normal.x * dLambda, c.normal.y * dLambda, c.normal.z * dLambda);
1959
+ if (c.penetration < 0) {
1960
+ return; // friction skipped for separated/speculative points (unchanged policy)
1304
1961
  }
1305
1962
  const maxF = c.mu * c.accN;
1306
1963
  c.accT1 = solveTangentImpulse(bodies, invMass, invInertia, c, A, B, c.t1, c.massT1, c.accT1, maxF);