@series-inc/rundot-syncplay 5.23.0 → 5.24.0-beta.1

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 (131) hide show
  1. package/README.md +53 -12
  2. package/dist/authority-room.d.ts +11 -0
  3. package/dist/authority-room.js +43 -0
  4. package/dist/browser.d.ts +11 -1
  5. package/dist/browser.js +5 -0
  6. package/dist/checksum-basis.d.ts +4 -0
  7. package/dist/checksum-basis.js +17 -0
  8. package/dist/cjs/authority-room.js +43 -0
  9. package/dist/cjs/browser.js +18 -2
  10. package/dist/cjs/checksum-basis.js +22 -0
  11. package/dist/cjs/collider-cooking.js +834 -0
  12. package/dist/cjs/deterministic-session.js +2 -0
  13. package/dist/cjs/engine-complete.js +1 -4
  14. package/dist/cjs/engine-parity-matrix.js +41 -9
  15. package/dist/cjs/engine-parity.js +2 -2
  16. package/dist/cjs/index.js +24 -2
  17. package/dist/cjs/input-authority.js +34 -2
  18. package/dist/cjs/instant-replay.js +4 -9
  19. package/dist/cjs/local-authority-server-runner.js +3 -1
  20. package/dist/cjs/local-authority-wire.js +20 -1
  21. package/dist/cjs/match-log.js +27 -0
  22. package/dist/cjs/movement3d.js +17 -1
  23. package/dist/cjs/networked-client.js +70 -20
  24. package/dist/cjs/node.js +23 -1
  25. package/dist/cjs/offline-session.js +52 -24
  26. package/dist/cjs/physics-cert.js +8 -3
  27. package/dist/cjs/physics3d-heightfield.js +51 -0
  28. package/dist/cjs/physics3d-hull.js +381 -0
  29. package/dist/cjs/physics3d-joints.js +255 -49
  30. package/dist/cjs/physics3d-mass.js +131 -0
  31. package/dist/cjs/physics3d-recipes.js +197 -0
  32. package/dist/cjs/physics3d-shared.js +11 -1
  33. package/dist/cjs/physics3d-solver.js +845 -98
  34. package/dist/cjs/physics3d-stacking.js +154 -0
  35. package/dist/cjs/physics3d-toi.js +274 -0
  36. package/dist/cjs/physics3d-vehicle.js +35 -10
  37. package/dist/cjs/physics3d.js +601 -38
  38. package/dist/cjs/replay.js +25 -1
  39. package/dist/cjs/runtime-support.js +71 -0
  40. package/dist/cjs/sdk-offline.js +9 -1
  41. package/dist/cjs/sdk-session.js +8 -0
  42. package/dist/cjs/secret-authority.js +647 -0
  43. package/dist/cjs/secret-client.js +615 -0
  44. package/dist/cjs/secret-command-classification.js +8 -0
  45. package/dist/cjs/secret-config.js +211 -0
  46. package/dist/cjs/secret-crypto.js +208 -0
  47. package/dist/cjs/secret-protocol.js +238 -0
  48. package/dist/cjs/session-build.js +7 -4
  49. package/dist/cjs/session-promotion.js +5 -5
  50. package/dist/cjs/synctest.js +49 -0
  51. package/dist/cjs/testing.js +4 -1
  52. package/dist/cjs/tools/static-checker.js +61 -13
  53. package/dist/cli.js +193 -48
  54. package/dist/collider-cooking.d.ts +141 -0
  55. package/dist/collider-cooking.js +829 -0
  56. package/dist/deterministic-primitives.d.ts +1 -0
  57. package/dist/deterministic-session.js +2 -0
  58. package/dist/engine-complete.js +1 -4
  59. package/dist/engine-parity-matrix.js +41 -9
  60. package/dist/engine-parity.js +2 -2
  61. package/dist/index.d.ts +12 -1
  62. package/dist/index.js +6 -0
  63. package/dist/input-authority.d.ts +3 -0
  64. package/dist/input-authority.js +35 -3
  65. package/dist/instant-replay.d.ts +2 -2
  66. package/dist/instant-replay.js +4 -9
  67. package/dist/local-authority-server-runner.d.ts +1 -0
  68. package/dist/local-authority-server-runner.js +2 -1
  69. package/dist/local-authority-wire.js +20 -1
  70. package/dist/match-log.d.ts +4 -1
  71. package/dist/match-log.js +27 -0
  72. package/dist/movement3d.js +18 -2
  73. package/dist/networked-client.d.ts +8 -0
  74. package/dist/networked-client.js +70 -20
  75. package/dist/node.d.ts +7 -0
  76. package/dist/node.js +6 -0
  77. package/dist/offline-session.js +53 -25
  78. package/dist/physics-cert.js +8 -3
  79. package/dist/physics3d-heightfield.d.ts +17 -0
  80. package/dist/physics3d-heightfield.js +47 -0
  81. package/dist/physics3d-hull.d.ts +53 -0
  82. package/dist/physics3d-hull.js +376 -0
  83. package/dist/physics3d-joints.d.ts +13 -1
  84. package/dist/physics3d-joints.js +256 -50
  85. package/dist/physics3d-mass.d.ts +11 -0
  86. package/dist/physics3d-mass.js +126 -0
  87. package/dist/physics3d-recipes.d.ts +29 -0
  88. package/dist/physics3d-recipes.js +193 -0
  89. package/dist/physics3d-shared.d.ts +1 -0
  90. package/dist/physics3d-shared.js +10 -1
  91. package/dist/physics3d-solver.d.ts +78 -2
  92. package/dist/physics3d-solver.js +842 -98
  93. package/dist/physics3d-stacking.d.ts +26 -0
  94. package/dist/physics3d-stacking.js +147 -0
  95. package/dist/physics3d-toi.d.ts +79 -0
  96. package/dist/physics3d-toi.js +267 -0
  97. package/dist/physics3d-vehicle.js +35 -10
  98. package/dist/physics3d.d.ts +28 -3
  99. package/dist/physics3d.js +601 -40
  100. package/dist/replay.d.ts +5 -0
  101. package/dist/replay.js +25 -1
  102. package/dist/reports.d.ts +6 -1
  103. package/dist/runtime-support.d.ts +60 -0
  104. package/dist/runtime-support.js +66 -0
  105. package/dist/sdk-offline.d.ts +4 -0
  106. package/dist/sdk-offline.js +9 -1
  107. package/dist/sdk-session.d.ts +4 -0
  108. package/dist/sdk-session.js +8 -0
  109. package/dist/secret-authority.d.ts +90 -0
  110. package/dist/secret-authority.js +643 -0
  111. package/dist/secret-client.d.ts +89 -0
  112. package/dist/secret-client.js +611 -0
  113. package/dist/secret-command-classification.d.ts +1 -0
  114. package/dist/secret-command-classification.js +5 -0
  115. package/dist/secret-config.d.ts +45 -0
  116. package/dist/secret-config.js +206 -0
  117. package/dist/secret-crypto.d.ts +29 -0
  118. package/dist/secret-crypto.js +190 -0
  119. package/dist/secret-protocol.d.ts +181 -0
  120. package/dist/secret-protocol.js +230 -0
  121. package/dist/session-build.d.ts +16 -0
  122. package/dist/session-build.js +7 -4
  123. package/dist/session-promotion.js +5 -5
  124. package/dist/synctest.d.ts +1 -1
  125. package/dist/synctest.js +49 -0
  126. package/dist/testing.d.ts +2 -0
  127. package/dist/testing.js +1 -0
  128. package/dist/tools/static-checker.d.ts +11 -0
  129. package/dist/tools/static-checker.js +60 -13
  130. package/dist/types.d.ts +21 -2
  131. package/package.json +8 -3
@@ -14,8 +14,13 @@
14
14
  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
+ exports.combinedContactFriction = combinedContactFriction;
17
18
  exports.solveRigidStep = solveRigidStep;
19
+ exports.minExtentOf = minExtentOf;
20
+ exports.boundingRadiusOf = boundingRadiusOf;
18
21
  const physics3d_shared_js_1 = require("./physics3d-shared.js");
22
+ const physics3d_hull_js_1 = require("./physics3d-hull.js");
23
+ const physics3d_heightfield_js_1 = require("./physics3d-heightfield.js");
19
24
  const ZERO = { x: 0, y: 0, z: 0 };
20
25
  /** Expand a compound body into primitive sub-bodies in world space. */
21
26
  function primitiveViews(body) {
@@ -29,7 +34,7 @@ function primitiveViews(body) {
29
34
  }));
30
35
  }
31
36
  /* istanbul ignore next -- mesh bodies are routed to meshManifold before primitiveViews is ever reached; this branch only satisfies the SolverShape union. */
32
- if (shape.kind === 'mesh') {
37
+ if (shape.kind === 'mesh' || shape.kind === 'heightfield') {
33
38
  return [];
34
39
  }
35
40
  return [{ index: body.index, p: body.p, q: body.q, shape }];
@@ -43,7 +48,7 @@ function primitiveViews(body) {
43
48
  */
44
49
  function asPrimitive(shape) {
45
50
  /* istanbul ignore next -- callers guard compound, and mesh bodies are routed to meshManifold first; type guard only. */
46
- if (shape.kind === 'compound' || shape.kind === 'mesh') {
51
+ if (shape.kind === 'compound' || shape.kind === 'mesh' || shape.kind === 'heightfield') {
47
52
  throw new Error('asPrimitive requires a primitive shape');
48
53
  }
49
54
  return shape;
@@ -51,7 +56,7 @@ function asPrimitive(shape) {
51
56
  function generateManifold(a, b, speculativeMargin = 0) {
52
57
  // mesh is one-sided static geometry; the other shape's primitives collide
53
58
  // against each triangle.
54
- if (a.shape.kind === 'mesh' || b.shape.kind === 'mesh') {
59
+ if (a.shape.kind === 'mesh' || b.shape.kind === 'mesh' || a.shape.kind === 'heightfield' || b.shape.kind === 'heightfield') {
55
60
  return meshManifold(a, b, speculativeMargin);
56
61
  }
57
62
  if (a.shape.kind !== 'compound' && b.shape.kind !== 'compound') {
@@ -128,17 +133,38 @@ function collidePrimitives(a, b, centerA, centerB, speculativeMargin, featureBas
128
133
  if (ka === 'capsule' && kb === 'box') {
129
134
  return capsuleBox(a, b, centerA, centerB, speculativeMargin, featureBase, false);
130
135
  }
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
136
  if (ka === 'box' && kb === 'capsule') {
133
137
  return capsuleBox(b, a, centerB, centerA, speculativeMargin, featureBase, true);
134
138
  }
135
- /* istanbul ignore next -- every primitive shape pairing is enumerated above; this is an exhaustiveness guard. */
139
+ if (ka === 'hull' && kb === 'hull') {
140
+ return collideHulls(a, b, centerA, centerB, speculativeMargin, featureBase);
141
+ }
142
+ if (ka === 'hull' && kb === 'box') {
143
+ return collideHulls(a, boxAsHull(b), centerA, centerB, speculativeMargin, featureBase);
144
+ }
145
+ if (ka === 'box' && kb === 'hull') {
146
+ return collideHulls(boxAsHull(a), b, centerA, centerB, speculativeMargin, featureBase);
147
+ }
148
+ if (ka === 'hull' && kb === 'sphere') {
149
+ return sphereHull(b, a, centerB, centerA, speculativeMargin, featureBase, true);
150
+ }
151
+ if (ka === 'sphere' && kb === 'hull') {
152
+ return sphereHull(a, b, centerA, centerB, speculativeMargin, featureBase, false);
153
+ }
154
+ if (ka === 'hull' && kb === 'capsule') {
155
+ return capsuleHull(b, a, centerB, centerA, speculativeMargin, featureBase, true);
156
+ }
157
+ /* istanbul ignore else -- every primitive shape pairing is enumerated above; the fallthrough is an exhaustiveness guard. */
158
+ if (ka === 'capsule' && kb === 'hull') {
159
+ return capsuleHull(a, b, centerA, centerB, speculativeMargin, featureBase, false);
160
+ }
161
+ /* istanbul ignore next -- unreachable exhaustiveness guard. */
136
162
  return undefined;
137
163
  }
138
164
  // ---------- sphere / capsule closed forms ----------
139
165
  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;
166
+ /* istanbul ignore next -- radiusOf is only called for sphere/capsule operands; the non-radius arm is defensive. */
167
+ return shape.kind === 'sphere' || shape.kind === 'capsule' ? shape.radius : 0;
142
168
  }
143
169
  /**
144
170
  * Build a single contact point from two world points on the surfaces of two
@@ -617,6 +643,7 @@ function collideBoxes(A, B, centerA, centerB, speculativeMargin, featureBase) {
617
643
  swap = poly;
618
644
  poly = spare;
619
645
  spare = swap;
646
+ /* 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
647
  if (polyCount === 0) {
621
648
  return undefined;
622
649
  }
@@ -648,12 +675,411 @@ function collideBoxes(A, B, centerA, centerB, speculativeMargin, featureBase) {
648
675
  }
649
676
  return { a: 0, b: 0, normal, points };
650
677
  }
678
+ // ---------- convex hull SAT + face clipping ----------
679
+ /** Outward bias on reference side planes so boundary-incident corners survive fp clipping. */
680
+ const CLIP_BOUNDARY_TOL = 1e-4;
681
+ // Box-as-hull view: the 8 corners of a box are a valid convex hull, so hull×box
682
+ // reuses the generalized hull path. Keyed by the box shape object identity (pure
683
+ // derived data, never canonical state — same rationale as quatMat3Cache).
684
+ // rdm-ignore-next-line determinism/no-module-mutable-state: pure derived box-hull view keyed by immutable box shape identity
685
+ const boxHullViewCache = new WeakMap();
686
+ function boxAsHull(box) {
687
+ const shape = box.shape;
688
+ /* istanbul ignore next -- boxAsHull is only called for box operands; type guard only. */
689
+ if (shape.kind !== 'box') {
690
+ throw new Error('boxAsHull requires a box');
691
+ }
692
+ let data = boxHullViewCache.get(shape);
693
+ if (data === undefined) {
694
+ const h = shape.half;
695
+ const verts = [];
696
+ for (const sx of [-1, 1]) {
697
+ for (const sy of [-1, 1]) {
698
+ for (const sz of [-1, 1]) {
699
+ verts.push({ x: sx * h.x, y: sy * h.y, z: sz * h.z });
700
+ }
701
+ }
702
+ }
703
+ data = (0, physics3d_hull_js_1.buildSolverHull)(verts);
704
+ boxHullViewCache.set(shape, data);
705
+ }
706
+ return { index: box.index, p: box.p, q: box.q, shape: { kind: 'hull', data } };
707
+ }
708
+ function hullData(shape) {
709
+ /* istanbul ignore next -- callers guard hull; type guard only. */
710
+ if (shape.kind !== 'hull') {
711
+ throw new Error('hullData requires a hull');
712
+ }
713
+ return shape.data;
714
+ }
715
+ /** World-space hull vertices (support samples for mesh + queries). */
716
+ function hullWorldVertices(hull) {
717
+ const data = hullData(hull.shape);
718
+ return data.vertices.map((v) => (0, physics3d_shared_js_1.addVec3)(hull.p, (0, physics3d_shared_js_1.rotatePointByQuat)(hull.q, v)));
719
+ }
720
+ function hullWorldFaces(hull, worldVerts) {
721
+ const data = hullData(hull.shape);
722
+ const rot = quatToMat3Cached(hull.q);
723
+ return data.faces.map((face) => {
724
+ const nx = rot[0] * face.normal.x + rot[1] * face.normal.y + rot[2] * face.normal.z;
725
+ const ny = rot[3] * face.normal.x + rot[4] * face.normal.y + rot[5] * face.normal.z;
726
+ const nz = rot[6] * face.normal.x + rot[7] * face.normal.y + rot[8] * face.normal.z;
727
+ const v0 = worldVerts[face.verts[0]];
728
+ const offset = nx * v0.x + ny * v0.y + nz * v0.z;
729
+ return { nx, ny, nz, offset, verts: face.verts };
730
+ });
731
+ }
732
+ function hullWorldEdgeDirs(hull) {
733
+ const data = hullData(hull.shape);
734
+ return data.edgeDirs.map((d) => (0, physics3d_shared_js_1.rotatePointByQuat)(hull.q, d));
735
+ }
736
+ /**
737
+ * Project a hull (given its world vertices) onto an axis, returning the max
738
+ * support value dot(n, v). Used for SAT separation along arbitrary axes.
739
+ */
740
+ function hullSupportMax(worldVerts, nx, ny, nz) {
741
+ let max = -Infinity;
742
+ for (const v of worldVerts) {
743
+ const d = nx * v.x + ny * v.y + nz * v.z;
744
+ if (d > max) {
745
+ max = d;
746
+ }
747
+ }
748
+ return max;
749
+ }
750
+ function hullSupportMin(worldVerts, nx, ny, nz) {
751
+ let min = Infinity;
752
+ for (const v of worldVerts) {
753
+ const d = nx * v.x + ny * v.y + nz * v.z;
754
+ if (d < min) {
755
+ min = d;
756
+ }
757
+ }
758
+ return min;
759
+ }
760
+ /** Separation of B beyond A along axis n (positive = separated). */
761
+ function axisSeparation(vertsA, vertsB, nx, ny, nz) {
762
+ const maxA = hullSupportMax(vertsA, nx, ny, nz);
763
+ const minB = hullSupportMin(vertsB, nx, ny, nz);
764
+ return minB - maxA;
765
+ }
766
+ function collideHulls(A, B, centerA, centerB, speculativeMargin, featureBase) {
767
+ const vertsA = hullWorldVertices(A);
768
+ const vertsB = hullWorldVertices(B);
769
+ const facesA = hullWorldFaces(A, vertsA);
770
+ const facesB = hullWorldFaces(B, vertsB);
771
+ const dx = B.p.x - A.p.x;
772
+ const dy = B.p.y - A.p.y;
773
+ const dz = B.p.z - A.p.z;
774
+ // Face SAT: use each face's outward normal directly (already oriented).
775
+ let faceSep = -Infinity;
776
+ let faceRefIsA = true;
777
+ let faceIndex = -1;
778
+ let faceNx = 0;
779
+ let faceNy = 0;
780
+ let faceNz = 0;
781
+ for (let i = 0; i < facesA.length; i += 1) {
782
+ const f = facesA[i];
783
+ // Reference face outward normal points away from A. All A verts satisfy
784
+ // dot(n,v) <= offset; separation = min_B(dot(n,v)) - offset (>0 = gap).
785
+ // The best reference face maximizes separation (shallowest penetration).
786
+ // Strict-greater keeps the lowest sorted-index face on near-ties (stable).
787
+ const s = hullSupportMin(vertsB, f.nx, f.ny, f.nz) - f.offset;
788
+ if (s > faceSep + (i === 0 ? 0 : 1e-9)) {
789
+ faceSep = s;
790
+ faceRefIsA = true;
791
+ faceIndex = i;
792
+ faceNx = f.nx;
793
+ faceNy = f.ny;
794
+ faceNz = f.nz;
795
+ }
796
+ }
797
+ for (let i = 0; i < facesB.length; i += 1) {
798
+ const f = facesB[i];
799
+ const s = hullSupportMin(vertsA, f.nx, f.ny, f.nz) - f.offset;
800
+ // Hysteresis: B's face wins only when strictly deeper, so a resting stack
801
+ // keeps a stable reference frame-to-frame (warm-start anchors don't jump).
802
+ if (s > faceSep + 1e-9) {
803
+ faceSep = s;
804
+ faceRefIsA = false;
805
+ faceIndex = i;
806
+ faceNx = f.nx;
807
+ faceNy = f.ny;
808
+ faceNz = f.nz;
809
+ }
810
+ }
811
+ if (faceSep > speculativeMargin) {
812
+ return undefined;
813
+ }
814
+ // Edge SAT: cross products of edge-direction pairs.
815
+ const edgesA = hullWorldEdgeDirs(A);
816
+ const edgesB = hullWorldEdgeDirs(B);
817
+ let edgeSep = -Infinity;
818
+ let edgeNx = 0;
819
+ let edgeNy = 0;
820
+ let edgeNz = 0;
821
+ let edgeFound = false;
822
+ let edgePairIndex = -1;
823
+ for (let i = 0; i < edgesA.length; i += 1) {
824
+ const ea = edgesA[i];
825
+ for (let j = 0; j < edgesB.length; j += 1) {
826
+ const eb = edgesB[j];
827
+ let cx = ea.y * eb.z - ea.z * eb.y;
828
+ let cy = ea.z * eb.x - ea.x * eb.z;
829
+ let cz = ea.x * eb.y - ea.y * eb.x;
830
+ const l2 = cx * cx + cy * cy + cz * cz;
831
+ if (l2 < 1e-8) {
832
+ continue;
833
+ }
834
+ const inv = 1 / (0, physics3d_shared_js_1.solverSqrt)(l2);
835
+ cx *= inv;
836
+ cy *= inv;
837
+ cz *= inv;
838
+ // orient axis from A toward B
839
+ if (cx * dx + cy * dy + cz * dz < 0) {
840
+ cx = -cx;
841
+ cy = -cy;
842
+ cz = -cz;
843
+ }
844
+ const sep = axisSeparation(vertsA, vertsB, cx, cy, cz);
845
+ if (sep > speculativeMargin) {
846
+ return undefined;
847
+ }
848
+ if (sep > edgeSep + 1e-9) {
849
+ edgeSep = sep;
850
+ edgeNx = cx;
851
+ edgeNy = cy;
852
+ edgeNz = cz;
853
+ edgeFound = true;
854
+ edgePairIndex = i * edgesB.length + j;
855
+ }
856
+ }
857
+ }
858
+ // qu3e-style hysteresis: prefer the edge axis only when decisively shallower
859
+ // separation (deeper penetration) than the face axis, and only for real overlaps.
860
+ const facePen = -faceSep;
861
+ const edgePen = -edgeSep;
862
+ const edgePreferred = edgeFound && facePen > 0 && edgePen < facePen * 0.9 - 5e-3;
863
+ if (edgePreferred) {
864
+ return hullEdgeContact(A, B, edgesA, edgesB, edgePairIndex, edgeNx, edgeNy, edgeNz, edgePen, centerA, centerB, featureBase);
865
+ }
866
+ 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);
867
+ }
868
+ function hullEdgeContact(A, B, edgesA, edgesB, pairIndex, nx, ny, nz, penetration, centerA, centerB, featureBase) {
869
+ const i = Math.trunc(pairIndex / edgesB.length);
870
+ const j = pairIndex % edgesB.length;
871
+ const vertsA = hullWorldVertices(A);
872
+ const vertsB = hullWorldVertices(B);
873
+ // support vertices of A along +n and B along -n approximate the contacting edges
874
+ const pA = supportVertex(vertsA, nx, ny, nz);
875
+ const pB = supportVertex(vertsB, -nx, -ny, -nz);
876
+ const eA = edgesA[i];
877
+ const eB = edgesB[j];
878
+ 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)));
879
+ const mid = (0, physics3d_shared_js_1.scaleVec3)((0, physics3d_shared_js_1.addVec3)(seg.c1, seg.c2), 0.5);
880
+ const normal = { x: nx, y: ny, z: nz };
881
+ return {
882
+ a: 0,
883
+ b: 0,
884
+ normal,
885
+ points: [{ rA: (0, physics3d_shared_js_1.subVec3)(mid, centerA), rB: (0, physics3d_shared_js_1.subVec3)(mid, centerB), penetration, feature: featureBase + 4096 + pairIndex }],
886
+ };
887
+ }
888
+ function supportVertex(worldVerts, nx, ny, nz) {
889
+ let best = worldVerts[0];
890
+ let bestD = -Infinity;
891
+ for (const v of worldVerts) {
892
+ const d = nx * v.x + ny * v.y + nz * v.z;
893
+ if (d > bestD) {
894
+ bestD = d;
895
+ best = v;
896
+ }
897
+ }
898
+ return best;
899
+ }
900
+ function hullFaceContact(ref, inc, refVerts, incVerts, refFaces, incFaces, refFaceIndex, nx, ny, nz, refIsA, speculativeMargin, centerA, centerB, featureBase) {
901
+ void ref;
902
+ void inc;
903
+ const refFace = refFaces[refFaceIndex];
904
+ // Incident face = the incident-hull face most anti-parallel to the reference normal.
905
+ let incFaceIndex = 0;
906
+ let minDot = Infinity;
907
+ for (let i = 0; i < incFaces.length; i += 1) {
908
+ const f = incFaces[i];
909
+ const d = f.nx * nx + f.ny * ny + f.nz * nz;
910
+ // Strict-less keeps the lowest sorted-index incident face on near-ties, so
911
+ // the clipped polygon (and its feature ids) stay stable frame-to-frame.
912
+ if (d < minDot - (i === 0 ? Infinity : 1e-9)) {
913
+ minDot = d;
914
+ incFaceIndex = i;
915
+ }
916
+ }
917
+ const incFace = incFaces[incFaceIndex];
918
+ // Load incident face polygon into scratch.
919
+ ensureClipSlots(clipScratch.a, incFace.verts.length);
920
+ let poly = clipScratch.a;
921
+ let spare = clipScratch.b;
922
+ let polyCount = 0;
923
+ for (const vi of incFace.verts) {
924
+ const v = incVerts[vi];
925
+ const slot = poly[polyCount];
926
+ slot.x = v.x;
927
+ slot.y = v.y;
928
+ slot.z = v.z;
929
+ slot.id = featureBase + refFaceIndex * 64 + polyCount + 1;
930
+ polyCount += 1;
931
+ }
932
+ // Clip the incident polygon against each reference-face side plane. Each side
933
+ // plane's normal must point OUTWARD from the reference polygon so clipPlaneScratch
934
+ // (keeps dc <= 0) retains interior points. Orientation is fixed against the
935
+ // face centroid rather than assuming a winding convention.
936
+ const rv = refFace.verts;
937
+ let rcx = 0;
938
+ let rcy = 0;
939
+ let rcz = 0;
940
+ for (const vi of rv) {
941
+ rcx += refVerts[vi].x;
942
+ rcy += refVerts[vi].y;
943
+ rcz += refVerts[vi].z;
944
+ }
945
+ const rInv = 1 / rv.length;
946
+ rcx *= rInv;
947
+ rcy *= rInv;
948
+ rcz *= rInv;
949
+ for (let e = 0; e < rv.length; e += 1) {
950
+ const a = refVerts[rv[e]];
951
+ const b = refVerts[rv[(e + 1) % rv.length]];
952
+ const edge = (0, physics3d_shared_js_1.subVec3)(b, a);
953
+ let pnx = ny * edge.z - nz * edge.y;
954
+ let pny = nz * edge.x - nx * edge.z;
955
+ let pnz = nx * edge.y - ny * edge.x;
956
+ const l2 = pnx * pnx + pny * pny + pnz * pnz;
957
+ /* 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). */
958
+ if (l2 < 1e-12) {
959
+ continue;
960
+ }
961
+ const invl = 1 / (0, physics3d_shared_js_1.solverSqrt)(l2);
962
+ pnx *= invl;
963
+ pny *= invl;
964
+ pnz *= invl;
965
+ let offset = pnx * a.x + pny * a.y + pnz * a.z;
966
+ // Flip to outward: the centroid (interior) must lie on the negative side.
967
+ /* 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. */
968
+ if (pnx * rcx + pny * rcy + pnz * rcz > offset) {
969
+ pnx = -pnx;
970
+ pny = -pny;
971
+ pnz = -pnz;
972
+ offset = -offset;
973
+ }
974
+ // Bias the side plane slightly outward so incident-face corners that lie
975
+ // exactly on a reference-face boundary survive floating-point clipping
976
+ // (dropping them mid-settle collapses a resting face manifold to 1-3 points
977
+ // on one side and injects drift). Box clipping avoids this via exact axis
978
+ // alignment; the generic hull path needs the tolerance.
979
+ offset += CLIP_BOUNDARY_TOL;
980
+ polyCount = clipPlaneScratch(poly, polyCount, pnx, pny, pnz, offset, e + 1, spare);
981
+ const swap = poly;
982
+ poly = spare;
983
+ spare = swap;
984
+ if (polyCount === 0) {
985
+ return undefined;
986
+ }
987
+ }
988
+ const refFaceDist = refFace.offset;
989
+ const points = [];
990
+ for (let i = 0; i < polyCount; i += 1) {
991
+ const vert = poly[i];
992
+ const depth = refFaceDist - (nx * vert.x + ny * vert.y + nz * vert.z);
993
+ if (depth >= -speculativeMargin) {
994
+ const onRefX = vert.x + nx * depth;
995
+ const onRefY = vert.y + ny * depth;
996
+ const onRefZ = vert.z + nz * depth;
997
+ const midX = (vert.x + onRefX) * 0.5;
998
+ const midY = (vert.y + onRefY) * 0.5;
999
+ const midZ = (vert.z + onRefZ) * 0.5;
1000
+ points.push({
1001
+ rA: { x: midX - centerA.x, y: midY - centerA.y, z: midZ - centerA.z },
1002
+ rB: { x: midX - centerB.x, y: midY - centerB.y, z: midZ - centerB.z },
1003
+ penetration: depth,
1004
+ feature: vert.id,
1005
+ });
1006
+ }
1007
+ }
1008
+ /* istanbul ignore next -- SAT overlap guarantees the incident face's deepest surviving vertex is within the speculative margin (20k-pair fuzz: never hit). */
1009
+ if (points.length === 0) {
1010
+ return undefined;
1011
+ }
1012
+ // Normal in caller A->B frame: reference face outward normal points ref->inc.
1013
+ // If ref is A, normal ref->inc = A->B = +n. If ref is B, A->B = -n.
1014
+ const normal = refIsA ? { x: nx, y: ny, z: nz } : { x: -nx, y: -ny, z: -nz };
1015
+ return { a: 0, b: 0, normal, points: reducePoints(points) };
1016
+ }
1017
+ function sphereHull(sphere, hull, cSphere, cHull, margin, feature, flip) {
1018
+ /* istanbul ignore next -- dispatch guarantees a (sphere, hull) pair; type guard only. */
1019
+ if (sphere.shape.kind !== 'sphere' || hull.shape.kind !== 'hull') {
1020
+ return undefined;
1021
+ }
1022
+ const radius = sphere.shape.radius;
1023
+ const data = hull.shape.data;
1024
+ const localCenter = (0, physics3d_shared_js_1.rotatePointByQuatInverse)(hull.q, (0, physics3d_shared_js_1.subVec3)(sphere.p, hull.p));
1025
+ const closest = (0, physics3d_hull_js_1.closestPointOnHullLocal)(localCenter, data);
1026
+ if (closest.inside) {
1027
+ // sphere center inside the hull: push out along the least-violated face normal.
1028
+ const nLocal = closest.normal;
1029
+ const nWorld = (0, physics3d_shared_js_1.rotatePointByQuat)(hull.q, nLocal); // outward from hull
1030
+ const penetration = -closest.distance + radius;
1031
+ const nSphereToHull = (0, physics3d_shared_js_1.scaleVec3)(nWorld, -1);
1032
+ return emitSingle(nSphereToHull, sphere.p, penetration, cSphere, cHull, feature, flip);
1033
+ }
1034
+ const closestWorld = (0, physics3d_shared_js_1.addVec3)(hull.p, (0, physics3d_shared_js_1.rotatePointByQuat)(hull.q, closest.point));
1035
+ const delta = (0, physics3d_shared_js_1.subVec3)(sphere.p, closestWorld);
1036
+ const dist = (0, physics3d_shared_js_1.solverSqrt)((0, physics3d_shared_js_1.dotVec3)(delta, delta));
1037
+ const penetration = radius - dist;
1038
+ if (penetration < -margin) {
1039
+ return undefined;
1040
+ }
1041
+ /* istanbul ignore next -- a sphere centre coincident with its closest hull point is `inside` and returned above, so dist is positive here. */
1042
+ const nHullToSphere = dist > 1e-9 ? (0, physics3d_shared_js_1.scaleVec3)(delta, 1 / dist) : (0, physics3d_shared_js_1.rotatePointByQuat)(hull.q, closest.normal);
1043
+ const nSphereToHull = (0, physics3d_shared_js_1.scaleVec3)(nHullToSphere, -1);
1044
+ 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);
1045
+ return emitSingle(nSphereToHull, mid, penetration, cSphere, cHull, feature, flip);
1046
+ }
1047
+ function capsuleHull(capsule, hull, cCapsule, cHull, margin, feature, flip) {
1048
+ const seg = capsuleSegment(capsule);
1049
+ const samples = [seg.start, seg.end, (0, physics3d_shared_js_1.scaleVec3)((0, physics3d_shared_js_1.addVec3)(seg.start, seg.end), 0.5)];
1050
+ const points = [];
1051
+ let normal;
1052
+ let deepest = -Infinity;
1053
+ samples.forEach((sample, i) => {
1054
+ const sphere = { index: capsule.index, p: sample, q: capsule.q, shape: { kind: 'sphere', radius: seg.radius } };
1055
+ const sub = sphereHull(sphere, hull, cCapsule, cHull, margin, feature + i, flip);
1056
+ if (sub === undefined) {
1057
+ return;
1058
+ }
1059
+ for (const point of sub.points) {
1060
+ points.push(point);
1061
+ }
1062
+ const d = sub.points[0].penetration;
1063
+ if (d > deepest) {
1064
+ deepest = d;
1065
+ normal = sub.normal;
1066
+ }
1067
+ });
1068
+ if (points.length === 0 || normal === undefined) {
1069
+ return undefined;
1070
+ }
1071
+ return { a: 0, b: 0, normal, points };
1072
+ }
1073
+ function hullSupportSamples(hull) {
1074
+ return hullWorldVertices(hull);
1075
+ }
651
1076
  // ---------- mesh (one-sided static triangle) ----------
652
1077
  function meshManifold(a, b, speculativeMargin) {
653
- const meshIsA = a.shape.kind === 'mesh';
1078
+ const meshIsA = a.shape.kind === 'mesh' || a.shape.kind === 'heightfield';
654
1079
  const meshBody = meshIsA ? a : b;
655
1080
  const otherBody = meshIsA ? b : a;
656
- if (meshBody.shape.kind !== 'mesh' || otherBody.shape.kind === 'mesh') {
1081
+ if ((meshBody.shape.kind !== 'mesh' && meshBody.shape.kind !== 'heightfield')
1082
+ || otherBody.shape.kind === 'mesh' || otherBody.shape.kind === 'heightfield') {
657
1083
  return undefined;
658
1084
  }
659
1085
  const prims = primitiveViews(otherBody);
@@ -661,35 +1087,71 @@ function meshManifold(a, b, speculativeMargin) {
661
1087
  let normal;
662
1088
  let deepest = -Infinity;
663
1089
  let feature = 0;
664
- const triangles = meshBody.shape.triangles.map((tri) => [
1090
+ const otherHalf = shapeHalfExtent(otherBody.shape, otherBody.q);
1091
+ const localTriangles = meshBody.shape.kind === 'mesh'
1092
+ ? meshBody.shape.triangles
1093
+ : (0, physics3d_heightfield_js_1.heightfieldLocalTriangles)(meshBody.shape, {
1094
+ minX: otherBody.p.x - otherHalf.x - speculativeMargin - meshBody.p.x,
1095
+ maxX: otherBody.p.x + otherHalf.x + speculativeMargin - meshBody.p.x,
1096
+ minY: otherBody.p.y - otherHalf.y - speculativeMargin - meshBody.p.y,
1097
+ maxY: otherBody.p.y + otherHalf.y + speculativeMargin - meshBody.p.y,
1098
+ minZ: otherBody.p.z - otherHalf.z - speculativeMargin - meshBody.p.z,
1099
+ maxZ: otherBody.p.z + otherHalf.z + speculativeMargin - meshBody.p.z,
1100
+ });
1101
+ const triangles = localTriangles.map((tri) => [
665
1102
  (0, physics3d_shared_js_1.addVec3)(meshBody.p, (0, physics3d_shared_js_1.rotatePointByQuat)(meshBody.q, tri[0])),
666
1103
  (0, physics3d_shared_js_1.addVec3)(meshBody.p, (0, physics3d_shared_js_1.rotatePointByQuat)(meshBody.q, tri[1])),
667
1104
  (0, physics3d_shared_js_1.addVec3)(meshBody.p, (0, physics3d_shared_js_1.rotatePointByQuat)(meshBody.q, tri[2])),
668
1105
  ]);
669
1106
  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') {
1107
+ /* istanbul ignore next -- primitiveViews only yields box/sphere/capsule/hull sub-bodies; this skip is unreachable. */
1108
+ if (prim.shape.kind !== 'sphere' && prim.shape.kind !== 'capsule' && prim.shape.kind !== 'box' && prim.shape.kind !== 'hull') {
672
1109
  continue;
673
1110
  }
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).
1111
+ const radius = prim.shape.kind === 'sphere' || prim.shape.kind === 'capsule'
1112
+ ? prim.shape.radius
1113
+ : 0;
1114
+ // Boxes/hulls sample their support vertices against triangles (like dynamic
1115
+ // box-vs-mesh); sphere/capsule use center/segment samples.
679
1116
  const samplePoints = prim.shape.kind === 'box'
680
1117
  ? boxSupportSamples(prim)
681
- : prim.shape.kind === 'capsule'
682
- ? capsuleSamples(prim)
683
- : [prim.p];
1118
+ : prim.shape.kind === 'hull'
1119
+ ? hullSupportSamples(prim)
1120
+ : prim.shape.kind === 'capsule'
1121
+ ? capsuleSamples(prim)
1122
+ : [prim.p];
684
1123
  for (const triangle of triangles) {
685
1124
  for (const sample of samplePoints) {
686
1125
  const closest = closestPointOnTriangle(sample, triangle[0], triangle[1], triangle[2]);
687
1126
  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
1127
  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) {
1128
+ const planeDist = (0, physics3d_shared_js_1.dotVec3)((0, physics3d_shared_js_1.subVec3)(sample, triangle[0]), triNormal);
1129
+ const centerPlaneDist = (0, physics3d_shared_js_1.dotVec3)((0, physics3d_shared_js_1.subVec3)(prim.p, triangle[0]), triNormal);
1130
+ const relativeNormalVelocity = (0, physics3d_shared_js_1.dotVec3)((0, physics3d_shared_js_1.subVec3)(otherBody.v ?? ZERO, meshBody.v ?? ZERO), triNormal);
1131
+ // delta = perp + planeDist·n, so perp² = |delta|² - planeDist². A zero
1132
+ // perp means the sample projects onto the face interior (or an edge),
1133
+ // where the signed plane distance — not the unsigned closest-point
1134
+ // distance — carries the penetration. Zero-radius shapes (box, hull)
1135
+ // have no other source of depth, so this is the branch that lets them
1136
+ // rest on and be pushed by a mesh at all.
1137
+ const delta2 = (0, physics3d_shared_js_1.dotVec3)(delta, delta);
1138
+ const perp2 = delta2 - planeDist * planeDist;
1139
+ let penetration;
1140
+ if (perp2 <= exports.SOLVER_CONSTANTS.MESH_FACE_PROJECTION_TOL2) {
1141
+ if (planeDist < -exports.SOLVER_CONSTANTS.MESH_BEHIND_FACE_RECOVERY
1142
+ && (centerPlaneDist < 0 || relativeNormalVelocity > 0)) {
1143
+ continue;
1144
+ }
1145
+ penetration = radius - planeDist;
1146
+ }
1147
+ else {
1148
+ // Edge/vertex region: one-sided, front-face only.
1149
+ if (planeDist < 0) {
1150
+ continue;
1151
+ }
1152
+ penetration = radius - (0, physics3d_shared_js_1.solverSqrt)(delta2);
1153
+ }
1154
+ if (penetration < -speculativeMargin) {
693
1155
  continue;
694
1156
  }
695
1157
  const contactNormal = triNormal; // one-sided: mesh -> other
@@ -787,7 +1249,7 @@ function closestPointOnTriangle(p, a, b, c) {
787
1249
  // Sequential-impulse velocity solver + split impulse + islands + sleep
788
1250
  // ============================================================================
789
1251
  exports.SOLVER_CONSTANTS = {
790
- BAUMGARTE: 0.2,
1252
+ BAUMGARTE: 0.2, // joints only (split impulse) — contacts now soft
791
1253
  SLOP: 0.005,
792
1254
  WAKE_VEL2: 0.005,
793
1255
  SLEEP_VEL2: 0.005,
@@ -796,7 +1258,50 @@ exports.SOLVER_CONSTANTS = {
796
1258
  WARM_MATCH_TOL2: 0.01,
797
1259
  LINEAR_DAMPING: 0.995,
798
1260
  ANGULAR_DAMPING: 0.98,
1261
+ SUBSTEPS: 6,
1262
+ /**
1263
+ * Relax passes per substep = RELAX_ITERATION_SCALE × velocityIterations. The
1264
+ * bias-free relax must drain the warm-start over-push that multiple bias-solve
1265
+ * iterations accumulate into `accN`; under-draining lets weak-gravity stacks
1266
+ * ratchet apart. 2× fully settles the tall-stack / mass-ratio batteries.
1267
+ */
1268
+ RELAX_ITERATION_SCALE: 2,
1269
+ CONTACT_ZETA: 10,
1270
+ /** hertz = CONTACT_HERTZ_FRACTION · substeps/dt (dimensionless ω·h = π/2). */
1271
+ CONTACT_HERTZ_FRACTION: 0.25,
1272
+ /** Static/kinematic-involved pairs solve 2× stiffer (box2d staticSoftness). */
1273
+ STATIC_HERTZ_SCALE: 2,
1274
+ /** Clamp on soft-bias velocity, length units per tick. */
1275
+ MAX_CONTACT_PUSHOUT: 0.05,
1276
+ /**
1277
+ * Squared in-plane distance under which a mesh support sample counts as
1278
+ * projecting onto the triangle face rather than its edge/vertex region.
1279
+ * This absolute tolerance equals the squared distance quantum and is reliable
1280
+ * for the supported game-coordinate range below 1e4.
1281
+ */
1282
+ MESH_FACE_PROJECTION_TOL2: 1e-12,
1283
+ /** Maximum solver-creep depth recoverable behind a one-sided mesh face. */
1284
+ MESH_BEHIND_FACE_RECOVERY: 0.02,
1285
+ /**
1286
+ * A ccd body is "fast" (TOI-bound: pre-advanced to first contact with its approach
1287
+ * velocity arrested) when its intended per-step center motion exceeds this fraction
1288
+ * of its smallest world half-extent (box3d b3_isFast). Below it, the in-loop
1289
+ * speculative margin already suffices.
1290
+ */
1291
+ CCD_FAST_MOTION_FRACTION: 0.5,
1292
+ /**
1293
+ * box3d B3_SPECULATIVE_DISTANCE (4·SLOP): the small fixed speculative margin for ccd
1294
+ * contacts. It only bridges the exact-TOI clamp's residual separation so a clamped
1295
+ * fast body forms its contact next step; a wide velocity-scaled margin instead
1296
+ * generates diverging far-gap contacts (#2821). Fast bodies are kept from tunneling
1297
+ * by the exact-TOI position clamp (runRigidSolver), not by a wide margin.
1298
+ */
1299
+ CCD_SPECULATIVE_MARGIN: 0.02,
799
1300
  };
1301
+ const TWO_PI = 6.283185307179586;
1302
+ function combinedContactFriction(frictionA10, frictionB10) {
1303
+ return (0, physics3d_shared_js_1.solverSqrt)((frictionA10 / 10) * (frictionB10 / 10));
1304
+ }
800
1305
  /**
801
1306
  * Warm-cache maps pack the canonical index pair (a<b) into one exact integer
802
1307
  * `a * WARM_KEY_BASE + b` — string pair keys were measurable churn on large piles.
@@ -805,6 +1310,14 @@ exports.WARM_KEY_BASE = 2 ** 20;
805
1310
  function solid(b) {
806
1311
  return b.isDynamic && !b.sleeping;
807
1312
  }
1313
+ /**
1314
+ * Bodies whose pose advances this step. Kinematic bodies integrate but are
1315
+ * never solved: `solid` still gates every impulse, gravity, damping and sleep
1316
+ * path, so a kinematic body's authored velocity survives the step untouched.
1317
+ */
1318
+ function integrates(b) {
1319
+ return b.isKinematic || solid(b);
1320
+ }
808
1321
  function worldInvInertiaLocked(b) {
809
1322
  if (!solid(b)) {
810
1323
  return [0, 0, 0, 0, 0, 0, 0, 0, 0];
@@ -871,11 +1384,12 @@ function takeConstraint() {
871
1384
  if (constraintPool.high === constraintPool.slots.length) {
872
1385
  constraintPool.slots.push({
873
1386
  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,
878
- mu: 0, restitution: 0,
1387
+ normal: ZERO, t1: ZERO, t2: ZERO, rA: ZERO, rB: ZERO, rA0: ZERO,
1388
+ penetration: 0,
1389
+ massN: 0, massT1: 0, massT2: 0,
1390
+ biasRate: 0, massScale: 1, impulseScale: 0, vn0: 0,
1391
+ accN: 0, accT1: 0, accT2: 0, accR1: 0, accR2: 0, accNStep: 0,
1392
+ mu: 0, rolling: 0, restitution: 0,
879
1393
  });
880
1394
  }
881
1395
  const slot = constraintPool.slots[constraintPool.high];
@@ -890,19 +1404,30 @@ function solveRigidStep(bodies, options) {
890
1404
  }
891
1405
  constraintPool.high = 0;
892
1406
  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.
1407
+ const h = dt / options.substeps;
1408
+ // 1. reset pseudo-velocities; copy v/w so the solver owns them (callers may
1409
+ // pass aliased vectors — canonical angularVel, shared zero constants and the
1410
+ // hot scalar impulse path mutates these objects in place). Gravity + damping +
1411
+ // position integration now happen inside the sub-step loop. Locked axes are
1412
+ // zeroed here so residual input velocity on a locked axis never integrates.
897
1413
  for (const b of bodies) {
898
1414
  b.vb = { x: 0, y: 0, z: 0 };
899
1415
  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
1416
  if (!solid(b)) {
1417
+ b.v = { x: b.v.x, y: b.v.y, z: b.v.z };
1418
+ b.w = { x: b.w.x, y: b.w.y, z: b.w.z };
903
1419
  continue;
904
1420
  }
905
- b.v.y = b.v.y + options.gravityY * b.gravityScale * dt;
1421
+ b.v = {
1422
+ x: b.invMassVec.x === 0 ? 0 : b.v.x,
1423
+ y: b.invMassVec.y === 0 ? 0 : b.v.y,
1424
+ z: b.invMassVec.z === 0 ? 0 : b.v.z,
1425
+ };
1426
+ b.w = {
1427
+ x: b.angularLock.x === 0 ? 0 : b.w.x,
1428
+ y: b.angularLock.y === 0 ? 0 : b.w.y,
1429
+ z: b.angularLock.z === 0 ? 0 : b.w.z,
1430
+ };
906
1431
  }
907
1432
  // 2. narrow-phase manifolds (canonical order i<j).
908
1433
  const manifolds = [];
@@ -921,7 +1446,7 @@ function solveRigidStep(bodies, options) {
921
1446
  if ((A.mask & B.layer) === 0 || (B.mask & A.layer) === 0) {
922
1447
  return;
923
1448
  }
924
- const margin = A.ccd || B.ccd ? speculativeMarginFor(A, B, dt) : 0;
1449
+ const margin = A.ccd || B.ccd ? exports.SOLVER_CONSTANTS.CCD_SPECULATIVE_MARGIN : 0;
925
1450
  const manifold = generateManifold(toGeom(A), toGeom(B), margin);
926
1451
  if (manifold !== undefined) {
927
1452
  manifolds.push(manifold);
@@ -984,11 +1509,40 @@ function solveRigidStep(bodies, options) {
984
1509
  const A = bodies[m.a];
985
1510
  const B = bodies[m.b];
986
1511
  const [t1, t2] = tangentBasis(m.normal);
987
- const combinedMu = Math.floor((toInt10(A.matFriction) + toInt10(B.matFriction)) / 2) / 10;
1512
+ const combinedMu = combinedContactFriction(toInt10(A.matFriction), toInt10(B.matFriction));
988
1513
  const combinedRest = Math.max(toInt10(A.matRestitution), toInt10(B.matRestitution)) / 10;
1514
+ const combinedRolling = Math.max(A.matRolling, B.matRolling);
1515
+ // Soft-constraint coefficients (box2d SoftStep): static/kinematic-involved
1516
+ // pairs solve stiffer. hertz = fraction · substeps/dt keeps ω·h = π/2.
1517
+ const softStatic = !A.isDynamic || !B.isDynamic;
1518
+ const hertz = (exports.SOLVER_CONSTANTS.CONTACT_HERTZ_FRACTION * options.substeps / dt)
1519
+ * (softStatic ? exports.SOLVER_CONSTANTS.STATIC_HERTZ_SCALE : 1);
1520
+ const omega = TWO_PI * hertz;
1521
+ const a1 = 2 * exports.SOLVER_CONSTANTS.CONTACT_ZETA + omega * h;
1522
+ const a2 = h * omega * a1;
1523
+ const a3 = 1 / (1 + a2);
1524
+ const biasRate = omega / a1;
1525
+ const massScale = a2 * a3;
1526
+ const impulseScale = a3;
989
1527
  const warmPts = options.warm.get(m.a * exports.WARM_KEY_BASE + m.b);
990
1528
  for (const pt of m.points) {
991
- const matched = matchWarm(warmPts, pt.rA);
1529
+ // A separated (speculative) contact anchors at the gap midpoint, so a wide margin
1530
+ // over a large gap yields anchors far from BOTH body centers (moment arms ≈ half
1531
+ // the gap), whose ω×r terms blow up the coupled relax at extreme speed (the #2821
1532
+ // cluster divergence). Pull each anchor back to its own body's surface witness
1533
+ // point along the normal by half the separation; a touching/penetrating contact
1534
+ // (penetration ≥ 0) is untouched, so resting stacks and the golden batteries are
1535
+ // byte-identical.
1536
+ let rA = pt.rA;
1537
+ let rB = pt.rB;
1538
+ if (pt.penetration < 0) {
1539
+ const half = -pt.penetration * 0.5;
1540
+ 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 };
1541
+ 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 };
1542
+ }
1543
+ rA = (0, physics3d_shared_js_1.subVec3)(rA, (0, physics3d_shared_js_1.rotatePointByQuat)(A.q, A.localCenter));
1544
+ rB = (0, physics3d_shared_js_1.subVec3)(rB, (0, physics3d_shared_js_1.rotatePointByQuat)(B.q, B.localCenter));
1545
+ const matched = matchWarm(warmPts, rA);
992
1546
  const constraint = takeConstraint();
993
1547
  constraint.manifoldIndex = manifoldIndex;
994
1548
  constraint.a = m.a;
@@ -996,22 +1550,29 @@ function solveRigidStep(bodies, options) {
996
1550
  constraint.normal = m.normal;
997
1551
  constraint.t1 = t1;
998
1552
  constraint.t2 = t2;
999
- constraint.rA = pt.rA;
1000
- constraint.rB = pt.rB;
1553
+ // Working anchors are fresh copies (rotated per substep); rA0 keeps the
1554
+ // frame-start anchor for the cache.
1555
+ constraint.rA = { x: rA.x, y: rA.y, z: rA.z };
1556
+ constraint.rB = { x: rB.x, y: rB.y, z: rB.z };
1557
+ constraint.rA0 = rA;
1001
1558
  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);
1559
+ constraint.massN = effectiveMass(m.normal, rA, rB, invMass[m.a], invMass[m.b], invInertia[m.a], invInertia[m.b]);
1560
+ constraint.massT1 = effectiveMass(t1, rA, rB, invMass[m.a], invMass[m.b], invInertia[m.a], invInertia[m.b]);
1561
+ constraint.massT2 = effectiveMass(t2, rA, rB, invMass[m.a], invMass[m.b], invInertia[m.a], invInertia[m.b]);
1562
+ constraint.biasRate = biasRate;
1563
+ constraint.massScale = massScale;
1564
+ constraint.impulseScale = impulseScale;
1565
+ // Approach velocity captured pre-substep (before any gravity) for restitution.
1566
+ constraint.vn0 = relativeVelocityAlong(A, B, rA, rB, m.normal.x, m.normal.y, m.normal.z);
1007
1567
  constraint.accN = matched?.normalImpulse ?? 0;
1008
1568
  constraint.accT1 = matched?.tangentImpulse1 ?? 0;
1009
1569
  constraint.accT2 = matched?.tangentImpulse2 ?? 0;
1010
- constraint.accNb = 0;
1570
+ constraint.accR1 = 0;
1571
+ constraint.accR2 = 0;
1572
+ constraint.accNStep = 0;
1011
1573
  constraint.mu = combinedMu;
1574
+ constraint.rolling = combinedRolling;
1012
1575
  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
1576
  constraints.push(constraint);
1016
1577
  }
1017
1578
  });
@@ -1020,7 +1581,7 @@ function solveRigidStep(bodies, options) {
1020
1581
  bodies,
1021
1582
  invMass,
1022
1583
  invInertia,
1023
- dt,
1584
+ dt: h,
1024
1585
  apply: (a, b, rA, rB, impulse) => applyImpulse(bodies, invMass, invInertia, a, b, rA, rB, impulse),
1025
1586
  applyAngular: (a, b, angularImpulse) => {
1026
1587
  const A = bodies[a];
@@ -1043,17 +1604,90 @@ function solveRigidStep(bodies, options) {
1043
1604
  applyBias: (a, b, rA, rB, impulse) => applyBiasImpulseAt(bodies, invMass, invInertia, a, b, rA, rB, impulse),
1044
1605
  biasRelativeVelocity: (a, b, rA, rB) => relativeBiasVelocity(bodies[a], bodies[b], rA, rB),
1045
1606
  };
1046
- for (const joint of joints) {
1047
- joint.prepare(jointCtx);
1048
- }
1049
- for (let iter = 0; iter < options.velocityIterations; iter += 1) {
1607
+ // 5. TGS-Soft sub-step loop: gravity(h) joint prepare + contact warm-start →
1608
+ // soft-bias iterations → position integration(h) + anchor/penetration update →
1609
+ // bias-free relax → per-substep joint-angle integration. One restitution pass
1610
+ // follows all substeps, driven by the pre-step approach velocity.
1611
+ for (let substep = 0; substep < options.substeps; substep += 1) {
1612
+ for (const b of bodies) {
1613
+ if (solid(b) && b.invMassVec.y !== 0) {
1614
+ b.v.y = b.v.y + options.gravityY * b.gravityScale * h;
1615
+ }
1616
+ }
1050
1617
  for (const joint of joints) {
1051
- joint.iterate(jointCtx);
1618
+ joint.prepare(jointCtx);
1619
+ }
1620
+ for (const c of constraints) {
1621
+ 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));
1622
+ }
1623
+ for (let iter = 0; iter < options.velocityIterations; iter += 1) {
1624
+ for (const joint of joints) {
1625
+ joint.iterate(jointCtx);
1626
+ }
1627
+ for (const c of constraints) {
1628
+ solveContactSoft(bodies, invMass, invInertia, c, h, true);
1629
+ }
1630
+ }
1631
+ for (const b of bodies) {
1632
+ if (!integrates(b)) {
1633
+ continue;
1634
+ }
1635
+ b.c = (0, physics3d_shared_js_1.addVec3)(b.c, (0, physics3d_shared_js_1.scaleVec3)((0, physics3d_shared_js_1.addVec3)(b.v, b.vb), h));
1636
+ if (b.hasOrientation) {
1637
+ b.q = (0, physics3d_shared_js_1.integrateQuat)(b.q, (0, physics3d_shared_js_1.addVec3)(b.w, b.wb), h);
1638
+ }
1639
+ b.p = (0, physics3d_shared_js_1.subVec3)(b.c, (0, physics3d_shared_js_1.rotatePointByQuat)(b.q, b.localCenter));
1052
1640
  }
1053
1641
  for (const c of constraints) {
1054
- solveContactConstraint(bodies, invMass, invInertia, c, dt);
1642
+ const A = bodies[c.a];
1643
+ const B = bodies[c.b];
1644
+ const dpA = integrates(A) ? (0, physics3d_shared_js_1.scaleVec3)((0, physics3d_shared_js_1.addVec3)(A.v, A.vb), h) : ZERO;
1645
+ const dpB = integrates(B) ? (0, physics3d_shared_js_1.scaleVec3)((0, physics3d_shared_js_1.addVec3)(B.v, B.vb), h) : ZERO;
1646
+ 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;
1647
+ 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;
1648
+ const rel = (0, physics3d_shared_js_1.subVec3)((0, physics3d_shared_js_1.addVec3)(dpB, rotB), (0, physics3d_shared_js_1.addVec3)(dpA, rotA));
1649
+ c.penetration -= (0, physics3d_shared_js_1.dotVec3)(rel, c.normal);
1650
+ c.rA = (0, physics3d_shared_js_1.addVec3)(c.rA, rotA);
1651
+ c.rB = (0, physics3d_shared_js_1.addVec3)(c.rB, rotB);
1652
+ }
1653
+ for (const b of bodies) {
1654
+ b.vb = { x: 0, y: 0, z: 0 };
1655
+ b.wb = { x: 0, y: 0, z: 0 };
1656
+ }
1657
+ const relaxIterations = options.velocityIterations * exports.SOLVER_CONSTANTS.RELAX_ITERATION_SCALE;
1658
+ for (const c of constraints) {
1659
+ c.accR1 = 0;
1660
+ c.accR2 = 0;
1661
+ }
1662
+ for (let iter = 0; iter < relaxIterations; iter += 1) {
1663
+ for (const c of constraints) {
1664
+ solveContactSoft(bodies, invMass, invInertia, c, h, false);
1665
+ }
1666
+ }
1667
+ // Accumulate the per-substep normal impulse so contact events report the
1668
+ // full-step reaction (the raw accN converges to the sustaining per-substep value).
1669
+ for (const c of constraints) {
1670
+ c.accNStep += c.accN;
1671
+ }
1672
+ for (const joint of joints) {
1673
+ joint.postSubstep?.(jointCtx);
1055
1674
  }
1056
1675
  }
1676
+ // 5b. restitution: replay the pre-step approach velocity for bouncy contacts.
1677
+ for (const c of constraints) {
1678
+ if (c.restitution <= 0 || c.vn0 >= -exports.SOLVER_CONSTANTS.RESTITUTION_THRESHOLD || c.accN <= 0) {
1679
+ continue;
1680
+ }
1681
+ const A = bodies[c.a];
1682
+ const B = bodies[c.b];
1683
+ const vn = relativeVelocityAlong(A, B, c.rA, c.rB, c.normal.x, c.normal.y, c.normal.z);
1684
+ let dLambda = c.massN * (-c.restitution * c.vn0 - vn);
1685
+ const newAcc = Math.max(0, c.accN + dLambda);
1686
+ dLambda = newAcc - c.accN;
1687
+ c.accN = newAcc;
1688
+ c.accNStep += dLambda;
1689
+ applyImpulseScalar(bodies, invMass, invInertia, c.a, c.b, c.rA, c.rB, c.normal.x * dLambda, c.normal.y * dLambda, c.normal.z * dLambda);
1690
+ }
1057
1691
  // 6. store warm cache (index-pair keyed, deterministic order).
1058
1692
  const warm = new Map();
1059
1693
  for (const c of constraints) {
@@ -1063,12 +1697,13 @@ function solveRigidStep(bodies, options) {
1063
1697
  list = [];
1064
1698
  warm.set(key, list);
1065
1699
  }
1066
- list.push({ anchor: c.rA, normalImpulse: c.accN, tangentImpulse1: c.accT1, tangentImpulse2: c.accT2 });
1700
+ list.push({ anchor: c.rA0, normalImpulse: c.accN, tangentImpulse1: c.accT1, tangentImpulse2: c.accT2 });
1067
1701
  }
1068
1702
  // `jointBodies` (built at the wake pass) keeps joint members awake — a motor or
1069
1703
  // grab can drive them below the sleep velocity threshold, and joints are step
1070
1704
  // inputs the sleeper can't see.
1071
- // 7. damping + integrate (real + pseudo velocity).
1705
+ // 7. damping (once per step) + axis-lock zeroing + sleep counters. Positions
1706
+ // were already integrated per substep in step 5.
1072
1707
  for (const b of bodies) {
1073
1708
  if (!solid(b)) {
1074
1709
  continue;
@@ -1087,10 +1722,6 @@ function solveRigidStep(bodies, options) {
1087
1722
  y: b.angularLock.y === 0 ? 0 : b.w.y,
1088
1723
  z: b.angularLock.z === 0 ? 0 : b.w.z,
1089
1724
  };
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
1725
  const speed2 = (0, physics3d_shared_js_1.dotVec3)(b.v, b.v) + (0, physics3d_shared_js_1.dotVec3)(b.w, b.w);
1095
1726
  b.lowVelFrames = jointBodies.has(b.index) || speed2 >= exports.SOLVER_CONSTANTS.SLEEP_VEL2 ? 0 : b.lowVelFrames + 1;
1096
1727
  }
@@ -1099,7 +1730,7 @@ function solveRigidStep(bodies, options) {
1099
1730
  // 9. per-manifold accumulated normal impulse for contact events.
1100
1731
  const impulseByManifold = new Array(manifolds.length).fill(0);
1101
1732
  for (const c of constraints) {
1102
- impulseByManifold[c.manifoldIndex] += c.accN;
1733
+ impulseByManifold[c.manifoldIndex] += c.accNStep;
1103
1734
  }
1104
1735
  const contactImpulses = manifolds.map((m, index) => ({
1105
1736
  a: m.a,
@@ -1115,6 +1746,17 @@ function toInt10(unit) {
1115
1746
  }
1116
1747
  /** World AABB of a solver body's shape (orientation-aware), swept by v·dt when ccd. */
1117
1748
  function solverBodyAabb(body, dt) {
1749
+ if (body.shape.kind === 'heightfield') {
1750
+ const bounds = (0, physics3d_heightfield_js_1.heightfieldLocalBounds)(body.shape);
1751
+ return {
1752
+ minX: body.p.x + bounds.minX,
1753
+ maxX: body.p.x + bounds.maxX,
1754
+ minY: body.p.y + bounds.minY,
1755
+ maxY: body.p.y + bounds.maxY,
1756
+ minZ: body.p.z + bounds.minZ,
1757
+ maxZ: body.p.z + bounds.maxZ,
1758
+ };
1759
+ }
1118
1760
  const half = shapeHalfExtent(body.shape, body.q);
1119
1761
  let minX = body.p.x - half.x;
1120
1762
  let maxX = body.p.x + half.x;
@@ -1151,6 +1793,14 @@ function shapeHalfExtent(shape, q) {
1151
1793
  const reach = shape.radius + shape.halfHeight;
1152
1794
  return { x: reach, y: reach, z: reach };
1153
1795
  }
1796
+ if (shape.kind === 'hull') {
1797
+ let ext = { x: 0, y: 0, z: 0 };
1798
+ for (const vertex of shape.data.vertices) {
1799
+ const rotated = (0, physics3d_shared_js_1.rotatePointByQuat)(q, vertex);
1800
+ 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)) };
1801
+ }
1802
+ return ext;
1803
+ }
1154
1804
  if (shape.kind === 'mesh') {
1155
1805
  let ext = { x: 0, y: 0, z: 0 };
1156
1806
  for (const tri of shape.triangles) {
@@ -1161,6 +1811,14 @@ function shapeHalfExtent(shape, q) {
1161
1811
  }
1162
1812
  return ext;
1163
1813
  }
1814
+ if (shape.kind === 'heightfield') {
1815
+ const bounds = (0, physics3d_heightfield_js_1.heightfieldLocalBounds)(shape);
1816
+ return {
1817
+ x: Math.max(Math.abs(bounds.minX), Math.abs(bounds.maxX)),
1818
+ y: Math.max(Math.abs(bounds.minY), Math.abs(bounds.maxY)),
1819
+ z: Math.max(Math.abs(bounds.minZ), Math.abs(bounds.maxZ)),
1820
+ };
1821
+ }
1164
1822
  // compound: union of child extents about the body center.
1165
1823
  let ext = { x: 0, y: 0, z: 0 };
1166
1824
  for (const child of shape.children) {
@@ -1174,15 +1832,68 @@ function shapeHalfExtent(shape, q) {
1174
1832
  }
1175
1833
  return ext;
1176
1834
  }
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;
1835
+ /**
1836
+ * Smallest world half-extent of a shape (the box3d fast-flag denominator). Uses
1837
+ * the orientation-aware `shapeHalfExtent`, so a rotated thin box reports its true
1838
+ * thin axis. Returns 0 for a degenerate (flat/zero-extent) shape; callers treat a
1839
+ * non-positive minExtent as "never fast" (Req 10, no divide).
1840
+ */
1841
+ function minExtentOf(shape, q) {
1842
+ const h = shapeHalfExtent(shape, q);
1843
+ return Math.min(h.x, h.y, h.z);
1844
+ }
1845
+ /**
1846
+ * Rotation-invariant bounding radius (max distance from the shape center to any
1847
+ * surface point). Used as a conservative sphere for the fast-body TOI cast: a
1848
+ * bounding sphere contacts a target no later than the true shape would, so the
1849
+ * cast never lets a fast body tunnel (it stops it short, never past first contact).
1850
+ */
1851
+ function boundingRadiusOf(shape) {
1852
+ if (shape.kind === 'box') {
1853
+ 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);
1854
+ }
1855
+ if (shape.kind === 'sphere') {
1856
+ return shape.radius;
1857
+ }
1858
+ if (shape.kind === 'capsule') {
1859
+ return shape.radius + shape.halfHeight;
1860
+ }
1861
+ if (shape.kind === 'hull') {
1862
+ let max2 = 0;
1863
+ for (const v of shape.data.vertices) {
1864
+ max2 = Math.max(max2, v.x * v.x + v.y * v.y + v.z * v.z);
1865
+ }
1866
+ return (0, physics3d_shared_js_1.solverSqrt)(max2);
1867
+ }
1868
+ if (shape.kind === 'mesh') {
1869
+ let max2 = 0;
1870
+ for (const tri of shape.triangles) {
1871
+ for (const v of tri) {
1872
+ max2 = Math.max(max2, v.x * v.x + v.y * v.y + v.z * v.z);
1873
+ }
1874
+ }
1875
+ return (0, physics3d_shared_js_1.solverSqrt)(max2);
1876
+ }
1877
+ if (shape.kind === 'heightfield') {
1878
+ const bounds = (0, physics3d_heightfield_js_1.heightfieldLocalBounds)(shape);
1879
+ return (0, physics3d_shared_js_1.solverSqrt)(Math.max(bounds.minX * bounds.minX, bounds.maxX * bounds.maxX)
1880
+ + Math.max(bounds.minY * bounds.minY, bounds.maxY * bounds.maxY)
1881
+ + Math.max(bounds.minZ * bounds.minZ, bounds.maxZ * bounds.maxZ));
1882
+ }
1883
+ let max = 0;
1884
+ for (const child of shape.children) {
1885
+ 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);
1886
+ max = Math.max(max, offset + boundingRadiusOf(child.shape));
1887
+ }
1888
+ return max;
1180
1889
  }
1181
1890
  function toGeom(b) {
1182
- return { index: b.index, p: b.p, q: b.q, shape: b.shape };
1891
+ return { index: b.index, p: b.p, q: b.q, shape: b.shape, v: b.v };
1183
1892
  }
1184
1893
  function wakeIfContacted(sleeper, other) {
1185
- if (sleeper.isDynamic && sleeper.sleeping && other.isDynamic && !other.sleeping
1894
+ // A kinematic mover carries no impulse but does displace what it touches, so
1895
+ // it has to wake a sleeping pile the same way an awake dynamic neighbour does.
1896
+ if (sleeper.isDynamic && sleeper.sleeping && (other.isDynamic || other.isKinematic) && !other.sleeping
1186
1897
  && (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
1898
  sleeper.sleeping = false;
1188
1899
  sleeper.lowVelFrames = 0;
@@ -1223,7 +1934,7 @@ function relativeBiasVelocity(A, B, rA, rB) {
1223
1934
  * order as the vector-helper form (bit-identical results), with zero
1224
1935
  * intermediate allocations. This runs per contact per velocity iteration.
1225
1936
  */
1226
- function applyImpulseScalar(bodies, invMass, invInertia, a, b, rA, rB, px, py, pz, bias) {
1937
+ function applyImpulseScalar(bodies, invMass, invInertia, a, b, rA, rB, px, py, pz) {
1227
1938
  const A = bodies[a];
1228
1939
  const B = bodies[b];
1229
1940
  if (solid(A)) {
@@ -1232,8 +1943,8 @@ function applyImpulseScalar(bodies, invMass, invInertia, a, b, rA, rB, px, py, p
1232
1943
  const cz = rA.x * py - rA.y * px;
1233
1944
  const m = invInertia[a];
1234
1945
  const inv = invMass[a];
1235
- const lin = bias ? A.vb : A.v;
1236
- const ang = bias ? A.wb : A.w;
1946
+ const lin = A.v;
1947
+ const ang = A.w;
1237
1948
  lin.x = lin.x - px * inv.x;
1238
1949
  lin.y = lin.y - py * inv.y;
1239
1950
  lin.z = lin.z - pz * inv.z;
@@ -1247,8 +1958,8 @@ function applyImpulseScalar(bodies, invMass, invInertia, a, b, rA, rB, px, py, p
1247
1958
  const cz = rB.x * py - rB.y * px;
1248
1959
  const m = invInertia[b];
1249
1960
  const inv = invMass[b];
1250
- const lin = bias ? B.vb : B.v;
1251
- const ang = bias ? B.wb : B.w;
1961
+ const lin = B.v;
1962
+ const ang = B.w;
1252
1963
  lin.x = lin.x + px * inv.x;
1253
1964
  lin.y = lin.y + py * inv.y;
1254
1965
  lin.z = lin.z + pz * inv.z;
@@ -1269,42 +1980,78 @@ function solveTangentImpulse(bodies, invMass, invInertia, c, A, B, t, mass, acc,
1269
1980
  let dF = mass * -vt;
1270
1981
  const clamped = (0, physics3d_shared_js_1.clampNumber)(acc + dF, -maxF, maxF);
1271
1982
  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);
1983
+ applyImpulseScalar(bodies, invMass, invInertia, c.a, c.b, c.rA, c.rB, t.x * dF, t.y * dF, t.z * dF);
1273
1984
  return clamped;
1274
1985
  }
1275
- function solveContactConstraint(bodies, invMass, invInertia, c, dt) {
1986
+ function angularEffectiveMass(direction, invA, invB) {
1987
+ const response = (0, physics3d_shared_js_1.addVec3)((0, physics3d_shared_js_1.mulMat3Vec)(invA, direction), (0, physics3d_shared_js_1.mulMat3Vec)(invB, direction));
1988
+ const inverseMass = (0, physics3d_shared_js_1.dotVec3)(direction, response);
1989
+ return inverseMass > 0 ? 1 / inverseMass : 0;
1990
+ }
1991
+ function solveRollingResistance(bodies, invInertia, c, A, B) {
1992
+ const maxImpulse = c.rolling * c.accN;
1993
+ if (maxImpulse <= 0) {
1994
+ return;
1995
+ }
1996
+ const relative = (0, physics3d_shared_js_1.subVec3)(B.w, A.w);
1997
+ const massR1 = angularEffectiveMass(c.t1, invInertia[c.a], invInertia[c.b]);
1998
+ const massR2 = angularEffectiveMass(c.t2, invInertia[c.a], invInertia[c.b]);
1999
+ let nextR1 = c.accR1 - massR1 * (0, physics3d_shared_js_1.dotVec3)(relative, c.t1);
2000
+ let nextR2 = c.accR2 - massR2 * (0, physics3d_shared_js_1.dotVec3)(relative, c.t2);
2001
+ const magnitude = (0, physics3d_shared_js_1.solverSqrt)(nextR1 * nextR1 + nextR2 * nextR2);
2002
+ if (magnitude > maxImpulse) {
2003
+ const scale = maxImpulse / magnitude;
2004
+ nextR1 *= scale;
2005
+ nextR2 *= scale;
2006
+ }
2007
+ const deltaR1 = nextR1 - c.accR1;
2008
+ const deltaR2 = nextR2 - c.accR2;
2009
+ c.accR1 = nextR1;
2010
+ c.accR2 = nextR2;
2011
+ const impulse = (0, physics3d_shared_js_1.addVec3)((0, physics3d_shared_js_1.scaleVec3)(c.t1, deltaR1), (0, physics3d_shared_js_1.scaleVec3)(c.t2, deltaR2));
2012
+ if (solid(A)) {
2013
+ A.w = (0, physics3d_shared_js_1.subVec3)(A.w, (0, physics3d_shared_js_1.mulMat3Vec)(invInertia[c.a], impulse));
2014
+ }
2015
+ if (solid(B)) {
2016
+ B.w = (0, physics3d_shared_js_1.addVec3)(B.w, (0, physics3d_shared_js_1.mulMat3Vec)(invInertia[c.b], impulse));
2017
+ }
2018
+ }
2019
+ /**
2020
+ * One soft-constraint contact solve (box2d SoftStep). `useBias` on the bias pass
2021
+ * folds the soft position bias into real velocity; the bias-free relax pass
2022
+ * (`useBias=false`) removes that transient energy after position integration.
2023
+ */
2024
+ function solveContactSoft(bodies, invMass, invInertia, c, h, useBias) {
1276
2025
  const A = bodies[c.a];
1277
2026
  const B = bodies[c.b];
1278
- // normal (no Baumgarte — position error handled by split impulse)
1279
2027
  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);
2028
+ let bias = 0;
2029
+ let massScale = 1;
2030
+ let impulseScale = 0;
2031
+ if (c.penetration < 0) {
2032
+ // speculative / separated: allow closing exactly up to the gap, never push apart.
2033
+ bias = c.penetration / h;
2034
+ }
2035
+ else if (useBias) {
2036
+ const raw = c.biasRate * (c.penetration - exports.SOLVER_CONSTANTS.SLOP);
2037
+ bias = (0, physics3d_shared_js_1.clampNumber)(raw, 0, exports.SOLVER_CONSTANTS.MAX_CONTACT_PUSHOUT / h);
2038
+ massScale = c.massScale;
2039
+ impulseScale = c.impulseScale;
2040
+ }
2041
+ let dLambda = massScale * c.massN * (-(vn) + bias) - impulseScale * c.accN;
1285
2042
  const newAcc = Math.max(0, c.accN + dLambda);
1286
2043
  dLambda = newAcc - c.accN;
1287
2044
  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;
2045
+ applyImpulseScalar(bodies, invMass, invInertia, c.a, c.b, c.rA, c.rB, c.normal.x * dLambda, c.normal.y * dLambda, c.normal.z * dLambda);
2046
+ if (c.penetration < 0) {
2047
+ return; // friction skipped for separated/speculative points (unchanged policy)
1304
2048
  }
1305
2049
  const maxF = c.mu * c.accN;
1306
2050
  c.accT1 = solveTangentImpulse(bodies, invMass, invInertia, c, A, B, c.t1, c.massT1, c.accT1, maxF);
1307
2051
  c.accT2 = solveTangentImpulse(bodies, invMass, invInertia, c, A, B, c.t2, c.massT2, c.accT2, maxF);
2052
+ if (!useBias && c.rolling > 0) {
2053
+ solveRollingResistance(bodies, invInertia, c, A, B);
2054
+ }
1308
2055
  }
1309
2056
  function islandSleep(bodies, manifolds, joints) {
1310
2057
  const parent = new Map();