@woosh/meep-engine 2.139.0 → 2.140.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 (172) hide show
  1. package/package.json +1 -1
  2. package/src/core/bvh2/bvh3/query/bvh_query_user_data_overlaps_aabb.d.ts +3 -3
  3. package/src/core/bvh2/bvh3/query/bvh_query_user_data_overlaps_aabb.d.ts.map +1 -1
  4. package/src/core/bvh2/bvh3/query/bvh_query_user_data_overlaps_aabb.js +4 -4
  5. package/src/{engine/physics/broadphase/aabb_transform_oriented.d.ts → core/geom/3d/aabb/aabb3_transform_oriented.d.ts} +2 -2
  6. package/src/core/geom/3d/aabb/aabb3_transform_oriented.d.ts.map +1 -0
  7. package/src/{engine/physics/broadphase/aabb_transform_oriented.js → core/geom/3d/aabb/aabb3_transform_oriented.js} +1 -1
  8. package/src/core/geom/3d/quaternion/quat3_to_matrix3.d.ts +54 -0
  9. package/src/core/geom/3d/quaternion/quat3_to_matrix3.d.ts.map +1 -0
  10. package/src/core/geom/3d/quaternion/quat3_to_matrix3.js +69 -0
  11. package/src/core/geom/3d/shape/AbstractShape3D.d.ts +24 -2
  12. package/src/core/geom/3d/shape/AbstractShape3D.d.ts.map +1 -1
  13. package/src/core/geom/3d/shape/AbstractShape3D.js +24 -1
  14. package/src/core/geom/3d/shape/HeightMapShape3D.d.ts +148 -0
  15. package/src/core/geom/3d/shape/HeightMapShape3D.d.ts.map +1 -0
  16. package/src/core/geom/3d/shape/HeightMapShape3D.js +451 -0
  17. package/src/core/geom/3d/shape/MeshShape3D.d.ts +210 -0
  18. package/src/core/geom/3d/shape/MeshShape3D.d.ts.map +1 -0
  19. package/src/core/geom/3d/shape/MeshShape3D.js +593 -0
  20. package/src/core/geom/3d/shape/TransformedShape3D.d.ts.map +1 -1
  21. package/src/core/geom/3d/shape/TransformedShape3D.js +46 -2
  22. package/src/core/geom/3d/shape/Triangle3D.d.ts +95 -0
  23. package/src/core/geom/3d/shape/Triangle3D.d.ts.map +1 -0
  24. package/src/core/geom/3d/shape/Triangle3D.js +318 -0
  25. package/src/core/geom/3d/shape/UnionShape3D.js +13 -0
  26. package/src/core/geom/3d/shape/shape_mesh_from_geometry.d.ts +30 -0
  27. package/src/core/geom/3d/shape/shape_mesh_from_geometry.d.ts.map +1 -0
  28. package/src/core/geom/3d/shape/shape_mesh_from_geometry.js +64 -0
  29. package/src/core/geom/3d/tetrahedra/prototype_tetrahedrize_mesh.js +9 -11
  30. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_build_vertex_to_tets_map.d.ts +28 -0
  31. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_build_vertex_to_tets_map.d.ts.map +1 -0
  32. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_build_vertex_to_tets_map.js +48 -0
  33. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_improve_quality.d.ts.map +1 -1
  34. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_improve_quality.js +40 -18
  35. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_smooth_vertex.d.ts +9 -5
  36. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_smooth_vertex.d.ts.map +1 -1
  37. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_smooth_vertex.js +38 -10
  38. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_vertex_is_boundary.d.ts +14 -5
  39. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_vertex_is_boundary.d.ts.map +1 -1
  40. package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_vertex_is_boundary.js +47 -5
  41. package/src/core/geom/3d/topology/struct/binary/BinaryElementPool.d.ts +19 -0
  42. package/src/core/geom/3d/topology/struct/binary/BinaryElementPool.d.ts.map +1 -1
  43. package/src/core/geom/3d/topology/struct/binary/BinaryElementPool.js +75 -13
  44. package/src/core/geom/3d/triangle/v3_compute_triangle_normal.d.ts +2 -2
  45. package/src/core/geom/3d/triangle/v3_compute_triangle_normal.d.ts.map +1 -1
  46. package/src/core/geom/3d/triangle/v3_compute_triangle_normal.js +1 -1
  47. package/src/core/geom/vec3/v3_dot_array_array.d.ts +3 -3
  48. package/src/core/geom/vec3/v3_dot_array_array.d.ts.map +1 -1
  49. package/src/core/geom/vec3/v3_dot_array_array.js +2 -2
  50. package/src/core/geom/vec3/v3_negate_array.d.ts +3 -3
  51. package/src/core/geom/vec3/v3_negate_array.d.ts.map +1 -1
  52. package/src/core/geom/vec3/v3_negate_array.js +2 -2
  53. package/src/core/geom/vec3/v3_quat3_apply.d.ts +29 -0
  54. package/src/core/geom/vec3/v3_quat3_apply.d.ts.map +1 -0
  55. package/src/core/geom/vec3/v3_quat3_apply.js +39 -0
  56. package/src/core/geom/vec3/v3_quat3_apply_inverse.d.ts +30 -0
  57. package/src/core/geom/vec3/v3_quat3_apply_inverse.d.ts.map +1 -0
  58. package/src/core/geom/vec3/v3_quat3_apply_inverse.js +41 -0
  59. package/src/core/geom/vec3/v3_triple_cross_product.d.ts +32 -0
  60. package/src/core/geom/vec3/v3_triple_cross_product.d.ts.map +1 -0
  61. package/src/core/geom/vec3/v3_triple_cross_product.js +45 -0
  62. package/src/engine/control/first-person/FirstPersonPlayerController.d.ts +16 -3
  63. package/src/engine/control/first-person/FirstPersonPlayerController.d.ts.map +1 -1
  64. package/src/engine/control/first-person/FirstPersonPlayerController.js +211 -211
  65. package/src/engine/control/first-person/FirstPersonPlayerControllerConfig.d.ts +72 -8
  66. package/src/engine/control/first-person/FirstPersonPlayerControllerConfig.d.ts.map +1 -1
  67. package/src/engine/control/first-person/FirstPersonPlayerControllerConfig.js +37 -5
  68. package/src/engine/control/first-person/FirstPersonPlayerControllerSystem.d.ts +101 -3
  69. package/src/engine/control/first-person/FirstPersonPlayerControllerSystem.d.ts.map +1 -1
  70. package/src/engine/control/first-person/FirstPersonPlayerControllerSystem.js +1789 -1416
  71. package/src/engine/control/first-person/TODO.md +173 -127
  72. package/src/engine/control/first-person/abilities/Slide.d.ts.map +1 -1
  73. package/src/engine/control/first-person/abilities/Slide.js +9 -1
  74. package/src/engine/control/first-person/prototype_first_person_controller.js +88 -2
  75. package/src/engine/control/first-person/test/buildTestPlayer.d.ts.map +1 -1
  76. package/src/engine/control/first-person/test/buildTestPlayer.js +9 -1
  77. package/src/engine/graphics/geometry/CapsuleGeometry.d.ts +42 -0
  78. package/src/engine/graphics/geometry/CapsuleGeometry.d.ts.map +1 -0
  79. package/src/engine/graphics/geometry/CapsuleGeometry.js +171 -0
  80. package/src/engine/physics/BULLET_REVIEW.md +945 -0
  81. package/src/engine/physics/CANNON_REVIEW.md +1300 -0
  82. package/src/engine/physics/JOLT_REVIEW.md +913 -0
  83. package/src/engine/physics/PLAN.md +461 -236
  84. package/src/engine/physics/RAPIER_REVIEW.md +934 -0
  85. package/src/engine/physics/REVIEW_001_ACTION_PLAN.md +642 -0
  86. package/src/engine/physics/broadphase/compute_fat_world_aabb.js +2 -2
  87. package/src/engine/physics/contact/ManifoldStore.d.ts +83 -10
  88. package/src/engine/physics/contact/ManifoldStore.d.ts.map +1 -1
  89. package/src/engine/physics/contact/ManifoldStore.js +608 -499
  90. package/src/engine/physics/ecs/ColliderObserverSystem.d.ts +2 -2
  91. package/src/engine/physics/ecs/ColliderObserverSystem.d.ts.map +1 -1
  92. package/src/engine/physics/ecs/PhysicsSystem.d.ts +128 -20
  93. package/src/engine/physics/ecs/PhysicsSystem.d.ts.map +1 -1
  94. package/src/engine/physics/ecs/PhysicsSystem.js +1301 -1159
  95. package/src/engine/physics/fluid/FluidSimulator.d.ts.map +1 -1
  96. package/src/engine/physics/fluid/FluidSimulator.js +2 -1
  97. package/src/engine/physics/fluid/solver/v3_grid_subtract_pressure_gradient.d.ts +28 -6
  98. package/src/engine/physics/fluid/solver/v3_grid_subtract_pressure_gradient.d.ts.map +1 -1
  99. package/src/engine/physics/fluid/solver/v3_grid_subtract_pressure_gradient.js +39 -17
  100. package/src/engine/physics/gjk/expanding_polytope_algorithm.d.ts +6 -6
  101. package/src/engine/physics/gjk/expanding_polytope_algorithm.d.ts.map +1 -1
  102. package/src/engine/physics/gjk/expanding_polytope_algorithm.js +68 -22
  103. package/src/engine/physics/gjk/gjk.d.ts +28 -2
  104. package/src/engine/physics/gjk/gjk.d.ts.map +1 -1
  105. package/src/engine/physics/gjk/gjk.js +421 -378
  106. package/src/engine/physics/gjk/minkowski_support.d.ts +37 -0
  107. package/src/engine/physics/gjk/minkowski_support.d.ts.map +1 -0
  108. package/src/engine/physics/gjk/minkowski_support.js +75 -0
  109. package/src/engine/physics/gjk/mpr.d.ts +56 -0
  110. package/src/engine/physics/gjk/mpr.d.ts.map +1 -0
  111. package/src/engine/physics/gjk/mpr.js +344 -0
  112. package/src/engine/physics/inertia/world_inverse_inertia.d.ts +20 -5
  113. package/src/engine/physics/inertia/world_inverse_inertia.d.ts.map +1 -1
  114. package/src/engine/physics/inertia/world_inverse_inertia.js +36 -38
  115. package/src/engine/physics/integration/integrate_position.d.ts +25 -7
  116. package/src/engine/physics/integration/integrate_position.d.ts.map +1 -1
  117. package/src/engine/physics/integration/integrate_position.js +43 -12
  118. package/src/engine/physics/integration/integrate_velocity.d.ts +30 -0
  119. package/src/engine/physics/integration/integrate_velocity.d.ts.map +1 -1
  120. package/src/engine/physics/integration/integrate_velocity.js +82 -1
  121. package/src/engine/physics/narrowphase/PosedShape.d.ts +0 -8
  122. package/src/engine/physics/narrowphase/PosedShape.d.ts.map +1 -1
  123. package/src/engine/physics/narrowphase/PosedShape.js +28 -30
  124. package/src/engine/physics/narrowphase/box_box_manifold.d.ts.map +1 -1
  125. package/src/engine/physics/narrowphase/box_box_manifold.js +113 -17
  126. package/src/engine/physics/narrowphase/box_triangle_contact.d.ts +30 -0
  127. package/src/engine/physics/narrowphase/box_triangle_contact.d.ts.map +1 -0
  128. package/src/engine/physics/narrowphase/box_triangle_contact.js +811 -0
  129. package/src/engine/physics/narrowphase/capsule_contacts.d.ts.map +1 -1
  130. package/src/engine/physics/narrowphase/capsule_contacts.js +10 -56
  131. package/src/engine/physics/narrowphase/capsule_triangle_contact.d.ts +71 -0
  132. package/src/engine/physics/narrowphase/capsule_triangle_contact.d.ts.map +1 -0
  133. package/src/engine/physics/narrowphase/capsule_triangle_contact.js +375 -0
  134. package/src/engine/physics/narrowphase/compute_penetration.d.ts +91 -0
  135. package/src/engine/physics/narrowphase/compute_penetration.d.ts.map +1 -0
  136. package/src/engine/physics/narrowphase/compute_penetration.js +396 -0
  137. package/src/engine/physics/narrowphase/decomposition/aabb_world_to_local.d.ts +35 -0
  138. package/src/engine/physics/narrowphase/decomposition/aabb_world_to_local.d.ts.map +1 -0
  139. package/src/engine/physics/narrowphase/decomposition/aabb_world_to_local.js +80 -0
  140. package/src/engine/physics/narrowphase/decomposition/decompose_to_triangles.d.ts +31 -0
  141. package/src/engine/physics/narrowphase/decomposition/decompose_to_triangles.d.ts.map +1 -0
  142. package/src/engine/physics/narrowphase/decomposition/decompose_to_triangles.js +55 -0
  143. package/src/engine/physics/narrowphase/decomposition/heightmap_enumerate_triangles.d.ts +42 -0
  144. package/src/engine/physics/narrowphase/decomposition/heightmap_enumerate_triangles.d.ts.map +1 -0
  145. package/src/engine/physics/narrowphase/decomposition/heightmap_enumerate_triangles.js +204 -0
  146. package/src/engine/physics/narrowphase/decomposition/mesh_enumerate_triangles.d.ts +42 -0
  147. package/src/engine/physics/narrowphase/decomposition/mesh_enumerate_triangles.d.ts.map +1 -0
  148. package/src/engine/physics/narrowphase/decomposition/mesh_enumerate_triangles.js +94 -0
  149. package/src/engine/physics/narrowphase/decomposition/triangle_buffer_layout.d.ts +37 -0
  150. package/src/engine/physics/narrowphase/decomposition/triangle_buffer_layout.d.ts.map +1 -0
  151. package/src/engine/physics/narrowphase/decomposition/triangle_buffer_layout.js +37 -0
  152. package/src/engine/physics/narrowphase/narrowphase_step.d.ts +8 -2
  153. package/src/engine/physics/narrowphase/narrowphase_step.d.ts.map +1 -1
  154. package/src/engine/physics/narrowphase/narrowphase_step.js +1422 -382
  155. package/src/engine/physics/narrowphase/sphere_box_contact.d.ts.map +1 -1
  156. package/src/engine/physics/narrowphase/sphere_box_contact.js +16 -23
  157. package/src/engine/physics/narrowphase/sphere_triangle_contact.d.ts +48 -0
  158. package/src/engine/physics/narrowphase/sphere_triangle_contact.d.ts.map +1 -0
  159. package/src/engine/physics/narrowphase/sphere_triangle_contact.js +143 -0
  160. package/src/engine/physics/queries/overlap_shape.d.ts +51 -0
  161. package/src/engine/physics/queries/overlap_shape.d.ts.map +1 -0
  162. package/src/engine/physics/queries/overlap_shape.js +183 -0
  163. package/src/engine/physics/queries/shape_cast.d.ts +56 -0
  164. package/src/engine/physics/queries/shape_cast.d.ts.map +1 -0
  165. package/src/engine/physics/queries/shape_cast.js +387 -0
  166. package/src/engine/physics/solver/solve_contacts.d.ts +116 -30
  167. package/src/engine/physics/solver/solve_contacts.d.ts.map +1 -1
  168. package/src/engine/physics/solver/solve_contacts.js +641 -223
  169. package/src/engine/physics/broadphase/aabb_transform_oriented.d.ts.map +0 -1
  170. package/src/engine/physics/fluid/solver/v3_grid_solve_pressure_unmasked_legacy.d.ts +0 -20
  171. package/src/engine/physics/fluid/solver/v3_grid_solve_pressure_unmasked_legacy.d.ts.map +0 -1
  172. package/src/engine/physics/fluid/solver/v3_grid_solve_pressure_unmasked_legacy.js +0 -83
@@ -3,8 +3,49 @@ import { ColliderFlags } from "../ecs/ColliderFlags.js";
3
3
  import { CONTACT_STRIDE } from "../contact/ManifoldStore.js";
4
4
  import { RigidBodyFlags } from "../ecs/RigidBodyFlags.js";
5
5
  import { world_inverse_inertia_apply } from "../inertia/world_inverse_inertia.js";
6
+ import { v3_quat3_apply } from "../../../core/geom/vec3/v3_quat3_apply.js";
7
+ import { v3_quat3_apply_inverse } from "../../../core/geom/vec3/v3_quat3_apply_inverse.js";
6
8
  import { friction_cone_clamp } from "./friction_cone.js";
7
9
 
10
+ /**
11
+ * # TGS split-impulse contact solver (staged)
12
+ *
13
+ * Temporal Gauss-Seidel with Catto-2018 split impulse. The solver runs as a
14
+ * sequence of stages driven by `PhysicsSystem.fixedUpdate`, which owns the
15
+ * substep loop (it has to — velocity/position integration spans every awake
16
+ * body, not just contacts):
17
+ *
18
+ * prepare_contacts(manifolds, system, h) // once per outer step
19
+ * for each substep:
20
+ * (system integrates gravity by h)
21
+ * refresh_contacts(manifolds, system) // re-derive geometry
22
+ * warm_start_contacts(manifolds, system) // replay impulse — per substep!
23
+ * solve_velocity(manifolds, system, iters) // non-penetration + friction
24
+ * solve_position(manifolds, system, pos_iters)
25
+ * (system integrates position by h, folding pseudo-velocity)
26
+ * apply_restitution(manifolds, system) // once, after the loop
27
+ *
28
+ * The three concerns are fully decoupled (Phases 1–2) and substepping
29
+ * (Phase 3) re-runs only the velocity + position solve per substep —
30
+ * narrowphase runs once per outer step. Each substep re-derives the
31
+ * current penetration analytically from the bodies' moved poses and the
32
+ * contact anchors captured at prepare time, so the position correction
33
+ * adapts as the stack settles (the mechanism behind TGS stack stability)
34
+ * without paying for narrowphase N times.
35
+ *
36
+ * Why staged module functions sharing module scratch, rather than one call:
37
+ * the substep loop interleaves solver stages with whole-body integration
38
+ * that lives in the system. The stages communicate through module-scoped
39
+ * scratch + `g_*` state set by `prepare_contacts`; they must be called in
40
+ * order, prepare first, within a single outer step. Single-threaded and
41
+ * deterministic, so the shared state is safe.
42
+ *
43
+ * Contacts from all islands are flattened into one scratch array. Islands
44
+ * are independent (they share no bodies), so a single flat Gauss-Seidel
45
+ * sweep gives the same result as per-island sweeps — the island partition
46
+ * is still built and used by the sleep test, just not needed here.
47
+ */
48
+
8
49
  /**
9
50
  * A pair is "sensor-only" when either body OR either body's primary
10
51
  * collider carries the IsSensor flag. The manifold still exists (so
@@ -25,29 +66,18 @@ function pair_is_sensor(rbA, colA, rbB, colB) {
25
66
  }
26
67
 
27
68
  /**
28
- * Default velocity-iteration count per `solve_contacts` call. With TGS
29
- * substepping driven by the PhysicsSystem, each tick now produces multiple
30
- * solver calls (one per substep), so the per-call iter count is set lower
31
- * than the classic PGS default of 10. Total iterations per tick =
32
- * substeps × iters_per_substep; the recommended pairing is 4 × 2 = 8,
33
- * which matches Box2D's TGS defaults and outperforms 1 × 10 on tall
34
- * stacks (impulses get position-integrated between substeps, so info
35
- * propagation through a contact chain compounds across substeps rather
36
- * than fighting against accumulating position drift inside one solve).
37
- *
38
- * Direct callers that want classic PGS can still pass `iters = 10` and
39
- * skip substepping by calling once with the full tick `dt`.
69
+ * Velocity-iteration count per substep. With substepping the per-substep
70
+ * count can be lower than a single-step PGS solver would need, because the
71
+ * outer loop revisits the contact set `substeps` times.
40
72
  * @type {number}
41
73
  */
42
- const DEFAULT_VELOCITY_ITERATIONS = 2;
74
+ const DEFAULT_VELOCITY_ITERATIONS = 10;
43
75
 
44
76
  /**
45
- * Baumgarte position-correction gain. Smaller values are "softer" (slower to
46
- * push bodies apart but more stable); larger values can introduce jitter.
47
- * 0.2 is the Catto / Box2D default.
77
+ * Position-iteration count per substep (split-impulse pseudo-velocity pass).
48
78
  * @type {number}
49
79
  */
50
- const BAUMGARTE_BETA = 0.2;
80
+ const DEFAULT_POSITION_ITERATIONS = 2;
51
81
 
52
82
  /**
53
83
  * Penetration allowed without applying position correction. Eliminates
@@ -57,32 +87,107 @@ const BAUMGARTE_BETA = 0.2;
57
87
  const PENETRATION_SLOP = 0.005;
58
88
 
59
89
  /**
60
- * Velocity below which restitution is suppressed (no "buzz" of micro-bounces
61
- * when bodies are nearly resting on each other).
90
+ * SPOOK contact stiffness `k`. Effectively infinite for rigid-body
91
+ * contact: what the solver actually sees is the regularization
92
+ * `eps = 4 / (h² · k · (1 + 4d))`, negligible at `k = 1e12` but in place
93
+ * as a continuous compliance dial for future soft contacts. Lacoursière
94
+ * 2007; same formulation as cannon-es / AgX.
95
+ * @type {number}
96
+ */
97
+ const CONTACT_STIFFNESS = 1e12;
98
+
99
+ /**
100
+ * SPOOK contact relaxation `d`. Chosen so `a = 4 / (h(1 + 4d)) = 0.2 / h`,
101
+ * numerically matching the prior Baumgarte β = 0.2 gain. `4/(1+4d)=0.2` →
102
+ * `d = 4.75`. Note `h` here is the SUBSTEP size: position correction is
103
+ * applied per substep, so the gain is derived from the substep `dt`.
104
+ * @type {number}
105
+ */
106
+ const CONTACT_RELAXATION = 4.75;
107
+
108
+ /**
109
+ * Maximum magnitude of the position-correction velocity bias, in m/s.
110
+ * Belt-and-braces against inflated EPA depths (PLAN.md caveat) driving the
111
+ * bias to tens of m/s. Removed once closed-form triangle solvers land.
112
+ * @type {number}
113
+ */
114
+ const MAX_POSITION_BIAS = 3;
115
+
116
+ /**
117
+ * Velocity below which restitution is suppressed (no micro-bounce buzz on
118
+ * resting stacks).
62
119
  * @type {number}
63
120
  */
64
121
  const RESTITUTION_VELOCITY_THRESHOLD = 1.0;
65
122
 
66
123
  /**
67
- * Pre-step scratch stride per contact: 16 doubles
68
- * 0..2 : rA (contact point bodyA position)
69
- * 3..5 : rB (contact point bodyB position)
70
- * 6..8 : t1 (tangent 1, unit, world)
71
- * 9..11: t2 (tangent 2, unit, world)
72
- * 12 : m_eff_n
73
- * 13 : m_eff_t1
74
- * 14 : m_eff_t2
75
- * 15 : bias_n (combined Baumgarte + restitution target velocity)
124
+ * Per-contact pre-step scratch stride: 23 doubles.
125
+ * 0..2 : localWA (A's contact witness, in A's LOCAL frame — constant)
126
+ * 3..5 : localWB (B's contact witness, in B's LOCAL frame — constant)
127
+ * 6..8 : rA (lever from A's COM to the contact midpoint — refreshed)
128
+ * 9..11: rB (lever from B's COM to the contact midpoint — refreshed)
129
+ * 12..14: t1 (tangent 1, unit, world — constant)
130
+ * 15..17: t2 (tangent 2, unit, world — constant)
131
+ * 18 : m_eff_n
132
+ * 19 : m_eff_t1
133
+ * 20 : m_eff_t2
134
+ * 21 : rest_bias (restitution: `e · vn_approach`, ≤ 0; 0 if not bouncing)
135
+ * 22 : bias_position (depth-correction bias — refreshed each substep)
136
+ *
137
+ * `localWA` / `localWB` are the per-body contact witnesses expressed in body
138
+ * local frames at prepare time. Each substep, `refresh_contacts` rotates
139
+ * them back to world by the body's current pose to recover the moved contact
140
+ * points, re-derives the current penetration depth (anchored on the trusted
141
+ * prepare-time depth via a delta, so it's sign-robust), and rebuilds the
142
+ * impulse lever arms `rA` / `rB` and the position bias.
76
143
  * @type {number}
77
144
  */
78
- const PRE_STRIDE = 16;
145
+ const PRE_STRIDE = 23;
79
146
 
80
147
  /** Per-contact index list — 4 uint32 per contact: slot, idx, idxA, idxB. */
81
148
  const INDEX_STRIDE = 4;
82
149
 
150
+ /**
151
+ * Per-body pseudo-velocity stride: 3 linear + 3 angular doubles. Owned by
152
+ * PhysicsSystem (`system.__pseudo_velocity`); zeroed each substep before
153
+ * the position pass and folded into the pose by `integrate_position`.
154
+ * @type {number}
155
+ */
156
+ const PSEUDO_STRIDE = 6;
157
+
83
158
  let scratch_pre = new Float64Array(64 * PRE_STRIDE);
84
159
  let scratch_idx = new Uint32Array(64 * INDEX_STRIDE);
85
160
  let scratch_mu = new Float64Array(64);
161
+ let scratch_pos_jn = new Float64Array(64);
162
+
163
+ /**
164
+ * Per-contact maximum normal impulse seen across the whole outer step's
165
+ * velocity solving (all substeps, all iterations). Reset in
166
+ * {@link prepare_contacts}, updated in {@link solve_velocity}, read by
167
+ * {@link apply_restitution}.
168
+ *
169
+ * Restitution must fire whenever a contact *was* compressive at some point
170
+ * in the step, even if its accumulated impulse later relaxes back to ~0 — a
171
+ * transient collision (ball bouncing, head-on hit) under per-substep
172
+ * warm-start ends the loop with `j_n ≈ 0` because there's no sustained load
173
+ * to hold the impulse up. Gating on this running max (Box2D-v3
174
+ * `maxNormalImpulse`) rather than the end-of-loop `j_n` is what makes
175
+ * bounces fire.
176
+ * @type {Float64Array}
177
+ */
178
+ let scratch_max_jn = new Float64Array(64);
179
+
180
+ /**
181
+ * Shared cross-stage state, set by {@link prepare_contacts} and read by the
182
+ * per-substep stages within the same outer step. Single-threaded, so plain
183
+ * module variables are safe.
184
+ */
185
+ let g_contact_count = 0;
186
+ let g_spook_a = 0;
187
+ let g_spook_eps = 0;
188
+
189
+ /** Scratch for re-deriving contact world points in {@link refresh_contacts}. */
190
+ const scratch_cp = new Float64Array(6);
86
191
 
87
192
  function ensure_capacity(n) {
88
193
  if (scratch_pre.length < n * PRE_STRIDE) {
@@ -94,12 +199,16 @@ function ensure_capacity(n) {
94
199
  if (scratch_mu.length < n) {
95
200
  scratch_mu = new Float64Array(n * 2);
96
201
  }
202
+ if (scratch_pos_jn.length < n) {
203
+ scratch_pos_jn = new Float64Array(n * 2);
204
+ }
205
+ if (scratch_max_jn.length < n) {
206
+ scratch_max_jn = new Float64Array(n * 2);
207
+ }
97
208
  }
98
209
 
99
210
  /**
100
- * Build an orthonormal tangent basis perpendicular to a unit normal. Standard
101
- * "least-aligned axis" trick: pick the world axis least aligned with `n`,
102
- * cross with `n`, then cross again for the second tangent.
211
+ * Build an orthonormal tangent basis perpendicular to a unit normal.
103
212
  *
104
213
  * @param {Float64Array} out 6 floats: t1.xyz then t2.xyz
105
214
  * @param {number} off
@@ -108,7 +217,6 @@ function ensure_capacity(n) {
108
217
  * @param {number} nz
109
218
  */
110
219
  function build_tangents(out, off, nx, ny, nz) {
111
- // Pick the world axis least aligned with n.
112
220
  const ax = nx < 0 ? -nx : nx;
113
221
  const ay = ny < 0 ? -ny : ny;
114
222
  const az = nz < 0 ? -nz : nz;
@@ -121,14 +229,12 @@ function build_tangents(out, off, nx, ny, nz) {
121
229
  } else {
122
230
  rx = 0; ry = 0; rz = 1;
123
231
  }
124
- // t1 = normalize(n × r)
125
232
  let t1x = ny * rz - nz * ry;
126
233
  let t1y = nz * rx - nx * rz;
127
234
  let t1z = nx * ry - ny * rx;
128
235
  const inv = 1 / Math.sqrt(t1x * t1x + t1y * t1y + t1z * t1z);
129
236
  t1x *= inv; t1y *= inv; t1z *= inv;
130
237
 
131
- // t2 = n × t1 (already unit since n and t1 are orthogonal unit vectors)
132
238
  const t2x = ny * t1z - nz * t1y;
133
239
  const t2y = nz * t1x - nx * t1z;
134
240
  const t2z = nx * t1y - ny * t1x;
@@ -147,8 +253,7 @@ function inv_mass_of(rb) {
147
253
  }
148
254
 
149
255
  /**
150
- * Combine two friction coefficients. Geometric mean — matches the Bullet /
151
- * PhysX default and never goes beyond the smaller of the two.
256
+ * Combine two friction coefficients (geometric mean — Bullet / PhysX default).
152
257
  * @param {number} a
153
258
  * @param {number} b
154
259
  * @returns {number}
@@ -158,8 +263,7 @@ function combine_friction(a, b) {
158
263
  }
159
264
 
160
265
  /**
161
- * Combine two restitution coefficients. Maximumvisually bounciest pair
162
- * wins; matches Unity / common game-engine default.
266
+ * Combine two restitution coefficients (maximumUnity / common default).
163
267
  * @param {number} a
164
268
  * @param {number} b
165
269
  * @returns {number}
@@ -173,28 +277,17 @@ const scratch_inertia_a = new Float64Array(3);
173
277
  const scratch_inertia_b = new Float64Array(3);
174
278
 
175
279
  /**
176
- * Apply an impulse `P` at body-relative offset `r`, advancing both the linear
177
- * velocity (Δv = P/m) and angular velocity (Δω = I⁻¹·(r × P)).
178
- *
179
- * Writes go directly to the typed-array backing of `linearVelocity` and
180
- * `angularVelocity` — bypassing {@link Vector3#set} on purpose. Each `set()`
181
- * runs a hasHandlers / sendN dispatch path which is dead weight in the
182
- * solver inner loop (no observer is subscribed to a body's velocity in
183
- * normal use, and even when one is, fanning out per-iteration writes is
184
- * useless — callers want the post-step value).
280
+ * Apply an impulse `P` at body-relative offset `r` to persistent velocity
281
+ * (Δv = P/m, Δω = I⁻¹·(r × P)). Direct typed-array writes bypass
282
+ * {@link Vector3#set}'s observer dispatch (dead weight in the solver loop).
185
283
  *
186
284
  * @param {RigidBody} rb
187
285
  * @param {Transform} transform
188
286
  * @param {number} invM
189
- * @param {number} rx
190
- * @param {number} ry
191
- * @param {number} rz
192
- * @param {number} Px
193
- * @param {number} Py
194
- * @param {number} Pz
195
- * @param {number} sign +1 for body A, -1 for body B (the contact normal points
196
- * from B → A, so A receives +P and B receives −P).
197
- * @param {Float64Array} scratch_inertia 3-vector scratch for the angular term
287
+ * @param {number} rx @param {number} ry @param {number} rz
288
+ * @param {number} Px @param {number} Py @param {number} Pz
289
+ * @param {number} sign +1 for body A, -1 for body B
290
+ * @param {Float64Array} scratch_inertia
198
291
  */
199
292
  function apply_impulse_to_body(
200
293
  rb, transform, invM,
@@ -214,12 +307,11 @@ function apply_impulse_to_body(
214
307
  lv[1] += sPy * invM;
215
308
  lv[2] += sPz * invM;
216
309
 
217
- // r × P (already-signed)
218
310
  const tx = ry * sPz - rz * sPy;
219
311
  const ty = rz * sPx - rx * sPz;
220
312
  const tz = rx * sPy - ry * sPx;
221
313
 
222
- world_inverse_inertia_apply(scratch_inertia, 0, rb, transform, tx, ty, tz);
314
+ world_inverse_inertia_apply(scratch_inertia, 0, rb.inverseInertiaLocal, transform.rotation, tx, ty, tz);
223
315
 
224
316
  const av = rb.angularVelocity;
225
317
  av[0] += scratch_inertia[0];
@@ -227,18 +319,56 @@ function apply_impulse_to_body(
227
319
  av[2] += scratch_inertia[2];
228
320
  }
229
321
 
322
+ /**
323
+ * Apply a position-pass impulse `P` at body-relative offset `r` to the
324
+ * body's pseudo-velocity (linear + angular) in the `pseudo_velocity` flat
325
+ * buffer. Mirrors {@link apply_impulse_to_body} but the result is consumed
326
+ * by `integrate_position` the same substep and never persists.
327
+ *
328
+ * @param {Float64Array} pseudo_velocity stride = {@link PSEUDO_STRIDE}
329
+ * @param {number} base offset = `body_index * PSEUDO_STRIDE`
330
+ * @param {RigidBody} rb @param {Transform} transform @param {number} invM
331
+ * @param {number} rx @param {number} ry @param {number} rz
332
+ * @param {number} Px @param {number} Py @param {number} Pz
333
+ * @param {number} sign +1 for body A, -1 for body B
334
+ * @param {Float64Array} scratch_inertia
335
+ */
336
+ function apply_impulse_to_pseudo(
337
+ pseudo_velocity, base,
338
+ rb, transform, invM,
339
+ rx, ry, rz,
340
+ Px, Py, Pz,
341
+ sign,
342
+ scratch_inertia
343
+ ) {
344
+ if (invM === 0) return;
345
+
346
+ const sPx = sign * Px;
347
+ const sPy = sign * Py;
348
+ const sPz = sign * Pz;
349
+
350
+ pseudo_velocity[base] += sPx * invM;
351
+ pseudo_velocity[base + 1] += sPy * invM;
352
+ pseudo_velocity[base + 2] += sPz * invM;
353
+
354
+ const tx = ry * sPz - rz * sPy;
355
+ const ty = rz * sPx - rx * sPz;
356
+ const tz = rx * sPy - ry * sPx;
357
+
358
+ world_inverse_inertia_apply(scratch_inertia, 0, rb.inverseInertiaLocal, transform.rotation, tx, ty, tz);
359
+
360
+ pseudo_velocity[base + 3] += scratch_inertia[0];
361
+ pseudo_velocity[base + 4] += scratch_inertia[1];
362
+ pseudo_velocity[base + 5] += scratch_inertia[2];
363
+ }
364
+
230
365
  /**
231
366
  * Quadratic-form contribution of one body to the constraint effective mass
232
- * along a unit axis. Computes `(r × axis)^T · I⁻¹_world · (r × axis)`.
367
+ * along a unit axis: `(r × axis)^T · I⁻¹_world · (r × axis)`.
233
368
  *
234
- * @param {RigidBody} rb
235
- * @param {Transform} transform
236
- * @param {number} rx
237
- * @param {number} ry
238
- * @param {number} rz
239
- * @param {number} ax axis x
240
- * @param {number} ay
241
- * @param {number} az
369
+ * @param {RigidBody} rb @param {Transform} transform
370
+ * @param {number} rx @param {number} ry @param {number} rz
371
+ * @param {number} ax @param {number} ay @param {number} az
242
372
  * @param {Float64Array} scratch_inertia
243
373
  * @returns {number}
244
374
  */
@@ -256,7 +386,7 @@ function angular_jacobian_contribution(
256
386
  const rxay = rz * ax - rx * az;
257
387
  const rxaz = rx * ay - ry * ax;
258
388
 
259
- world_inverse_inertia_apply(scratch_inertia, 0, rb, transform, rxax, rxay, rxaz);
389
+ world_inverse_inertia_apply(scratch_inertia, 0, ii, transform.rotation, rxax, rxay, rxaz);
260
390
 
261
391
  return rxax * scratch_inertia[0]
262
392
  + rxay * scratch_inertia[1]
@@ -264,97 +394,66 @@ function angular_jacobian_contribution(
264
394
  }
265
395
 
266
396
  /**
267
- * Sequential-impulse contact solver. Partitions the touched contact set into
268
- * islands (via {@link IslandBuilder}, owned by the PhysicsSystem and rebuilt
269
- * upstream in the pipeline), then iterates each island independently.
270
- *
271
- * Per-island iteration matters for two reasons:
272
- * 1. Impulse propagation converges inside an island without waiting for
273
- * unrelated bodies' Gauss-Seidel updates from previous outer loops.
274
- * 2. Disconnected awake bodies don't pay each other's solver cost — adding
275
- * an unrelated active body to the scene scales O(island_size) rather than
276
- * O(global_active).
277
- *
278
- * Coulomb friction is applied as a disk clamp in the contact tangent plane.
279
- * Position correction is folded into the velocity solve via Baumgarte bias.
280
- *
281
- * The `apply_warm_start` flag gates the warm-start impulse application.
282
- * In a classic PGS call (one solve per tick), pass `true` (default) — the
283
- * cached impulses from the previous tick get replayed onto the velocity,
284
- * seeding the iterations near the converged answer. In TGS substepping,
285
- * the PhysicsSystem calls this once per substep; only substep 0 should
286
- * apply warm-start, because subsequent substeps continue from the solver
287
- * state left by the previous substep (the impulses in `data` already
288
- * reflect the current velocity, so re-applying them would double-count).
397
+ * Flat count of all touched contacts across every island, plus the slot
398
+ * list to iterate. Islands are concatenated densely in `contact_data`;
399
+ * `contact_offsets[island_count]` is the end of the last island.
289
400
  *
290
- * Inertial: angular response uses the full world-frame inverse inertia
291
- * `R · diag(I⁻¹_local) · R^T` via {@link world_inverse_inertia_apply},
292
- * correct for arbitrary rotations + diagonal local inertia.
293
- *
294
- * @param {ManifoldStore} manifolds
295
- * @param {PhysicsSystem} system PhysicsSystem; reads `system.islands`.
296
- * @param {number} dt step duration (full tick `dt` for PGS, sub-tick for TGS)
297
- * @param {number} [iters]
298
- * @param {boolean} [apply_warm_start]
401
+ * @param {PhysicsSystem} system
402
+ * @returns {{slot_list: Uint32Array, total_slots: number}|null}
299
403
  */
300
- export function solve_contacts(manifolds, system, dt, iters = DEFAULT_VELOCITY_ITERATIONS, apply_warm_start = true) {
301
- if (dt <= 0) return;
302
-
404
+ function island_slot_range(system) {
303
405
  const islands = system.islands;
304
- if (islands === undefined || islands === null) return;
305
-
406
+ if (islands === undefined || islands === null) return null;
306
407
  const island_count = islands.island_count;
307
- if (island_count === 0) return;
308
-
309
- const slot_list = islands.contact_data;
310
- const slot_offsets = islands.contact_offsets;
311
-
312
- for (let i = 0; i < island_count; i++) {
313
- const start = slot_offsets[i];
314
- const end = slot_offsets[i + 1];
315
- if (end === start) continue;
316
- solve_island(manifolds, system, dt, iters, slot_list, start, end, apply_warm_start);
317
- }
408
+ if (island_count === 0) return null;
409
+ return {
410
+ slot_list: islands.contact_data,
411
+ total_slots: islands.contact_offsets[island_count],
412
+ };
318
413
  }
319
414
 
320
415
  /**
321
- * Solve a single island's contact constraints. Operates on the slice
322
- * `slot_list[slot_start..slot_end)` of manifold slot ids. Module-scoped
323
- * scratch buffers are reused across islands (resized by doubling when an
324
- * island has more contacts than seen before in the same step).
416
+ * Stage 1 prepare the contact constraints for an outer step.
417
+ *
418
+ * Packs every touched, non-sensor contact into the flat scratch arrays:
419
+ * local-frame witness anchors, tangent basis, effective masses, friction,
420
+ * the restitution approach velocity, and the warm-start replay (applied
421
+ * once here, not per substep). Computes the SPOOK gains from the SUBSTEP
422
+ * size `dt_sub` because the position correction runs once per substep.
325
423
  *
326
424
  * @param {ManifoldStore} manifolds
327
425
  * @param {PhysicsSystem} system
328
- * @param {number} dt
329
- * @param {number} iters
330
- * @param {Uint32Array} slot_list
331
- * @param {number} slot_start
332
- * @param {number} slot_end
333
- * @param {boolean} apply_warm_start whether to replay cached impulses (see
334
- * {@link solve_contacts})
426
+ * @param {number} dt_sub substep size `dt / substeps`
427
+ * @returns {number} number of contacts prepared (also stored module-side)
335
428
  */
336
- function solve_island(manifolds, system, dt, iters, slot_list, slot_start, slot_end, apply_warm_start) {
337
- // 1. Count contacts in this island so we can size scratch.
338
- // Stale-but-cached slots (the island builder skips them upstream) and
339
- // sensor-only slots contribute zero impulse but we still want exact
340
- // sizing sensor slots are filtered at the per-pair check below and
341
- // contribute zero to `contact_total`.
429
+ export function prepare_contacts(manifolds, system, dt_sub) {
430
+ g_contact_count = 0;
431
+ if (dt_sub <= 0) return 0;
432
+
433
+ const range = island_slot_range(system);
434
+ if (range === null) return 0;
435
+ const slot_list = range.slot_list;
436
+ const total_slots = range.total_slots;
437
+
342
438
  let contact_total = 0;
343
- for (let i = slot_start; i < slot_end; i++) {
344
- const slot = slot_list[i];
345
- contact_total += manifolds.contact_count(slot);
439
+ for (let i = 0; i < total_slots; i++) {
440
+ contact_total += manifolds.contact_count(slot_list[i]);
346
441
  }
347
- if (contact_total === 0) return;
442
+ if (contact_total === 0) return 0;
348
443
  ensure_capacity(contact_total);
349
444
 
445
+ const denom = 1 + 4 * CONTACT_RELAXATION;
446
+ g_spook_a = 4 / (dt_sub * denom);
447
+ g_spook_eps = 4 / (dt_sub * dt_sub * CONTACT_STIFFNESS * denom);
448
+
350
449
  const data = manifolds.data_buffer;
351
450
  const pre = scratch_pre;
352
451
  const idx = scratch_idx;
353
452
  const mus = scratch_mu;
453
+ const pos_jn = scratch_pos_jn;
354
454
 
355
- // 2. Pre-step + warm-start.
356
455
  let c = 0;
357
- for (let i = slot_start; i < slot_end; i++) {
456
+ for (let i = 0; i < total_slots; i++) {
358
457
  const slot = slot_list[i];
359
458
  const idxA = system.__index_of(manifolds.bodyA(slot));
360
459
  const idxB = system.__index_of(manifolds.bodyB(slot));
@@ -362,16 +461,9 @@ function solve_island(manifolds, system, dt, iters, slot_list, slot_start, slot_
362
461
  const rbB = system.__bodies[idxB];
363
462
  const trA = system.__transforms[idxA];
364
463
  const trB = system.__transforms[idxB];
365
- // Material parameters come from the "primary" collider of each body
366
- // — for single-collider bodies this is the only one; for compound
367
- // bodies it's a v1 approximation (per-contact source-collider
368
- // tracking is a follow-up). Fall back to defaults if a body has no
369
- // attached colliders.
370
464
  const colA = system.__primary_collider(idxA);
371
465
  const colB = system.__primary_collider(idxB);
372
466
  if (colA === null || colB === null) continue;
373
- // Sensor pairs: contacts persist in the manifold (for events) but
374
- // produce no impulse. Skip the solver work for this pair entirely.
375
467
  if (pair_is_sensor(rbA, colA, rbB, colB)) continue;
376
468
 
377
469
  const invMA = inv_mass_of(rbA);
@@ -382,35 +474,40 @@ function solve_island(manifolds, system, dt, iters, slot_list, slot_start, slot_
382
474
  const restitution_combined = combine_restitution(colA.restitution, colB.restitution);
383
475
  const friction_combined = combine_friction(colA.friction, colB.friction);
384
476
 
477
+ const pAx = trA.position.x, pAy = trA.position.y, pAz = trA.position.z;
478
+ const pBx = trB.position.x, pBy = trB.position.y, pBz = trB.position.z;
479
+ const qA = trA.rotation, qB = trB.rotation;
480
+
385
481
  for (let k = 0; k < cc; k++) {
386
482
  const off = slot_off + k * CONTACT_STRIDE;
387
483
 
388
484
  const wax = data[off], way = data[off + 1], waz = data[off + 2];
389
485
  const wbx = data[off + 3], wby = data[off + 4], wbz = data[off + 5];
390
486
  const nx = data[off + 6], ny = data[off + 7], nz = data[off + 8];
391
- const depth = data[off + 9];
392
487
 
393
- // Application point: midpoint of the two surface witnesses.
394
488
  const px = (wax + wbx) * 0.5;
395
489
  const py = (way + wby) * 0.5;
396
490
  const pz = (waz + wbz) * 0.5;
397
491
 
398
- const rax = px - trA.position.x;
399
- const ray = py - trA.position.y;
400
- const raz = pz - trA.position.z;
401
- const rbx = px - trB.position.x;
402
- const rby = py - trB.position.y;
403
- const rbz = pz - trB.position.z;
492
+ const rax = px - pAx, ray = py - pAy, raz = pz - pAz;
493
+ const rbx = px - pBx, rby = py - pBy, rbz = pz - pBz;
404
494
 
405
495
  const pre_off = c * PRE_STRIDE;
406
- pre[pre_off] = rax; pre[pre_off + 1] = ray; pre[pre_off + 2] = raz;
407
- pre[pre_off + 3] = rbx; pre[pre_off + 4] = rby; pre[pre_off + 5] = rbz;
408
- build_tangents(pre, pre_off + 6, nx, ny, nz);
409
496
 
410
- const t1x = pre[pre_off + 6], t1y = pre[pre_off + 7], t1z = pre[pre_off + 8];
411
- const t2x = pre[pre_off + 9], t2y = pre[pre_off + 10], t2z = pre[pre_off + 11];
497
+ // Per-body witness anchors in LOCAL frame (constant across the
498
+ // outer step). localW = R⁻¹ · (witness COM).
499
+ v3_quat3_apply_inverse(pre, pre_off, wax - pAx, way - pAy, waz - pAz, qA[0], qA[1], qA[2], qA[3]);
500
+ v3_quat3_apply_inverse(pre, pre_off + 3, wbx - pBx, wby - pBy, wbz - pBz, qB[0], qB[1], qB[2], qB[3]);
501
+
502
+ // Lever arms from COM to the contact midpoint (refreshed each
503
+ // substep; seeded here from prepare-time pose).
504
+ pre[pre_off + 6] = rax; pre[pre_off + 7] = ray; pre[pre_off + 8] = raz;
505
+ pre[pre_off + 9] = rbx; pre[pre_off + 10] = rby; pre[pre_off + 11] = rbz;
506
+
507
+ build_tangents(pre, pre_off + 12, nx, ny, nz);
508
+ const t1x = pre[pre_off + 12], t1y = pre[pre_off + 13], t1z = pre[pre_off + 14];
509
+ const t2x = pre[pre_off + 15], t2y = pre[pre_off + 16], t2z = pre[pre_off + 17];
412
510
 
413
- // K = invMA + invMB + (rA×axis)^T·I_w⁻¹_A·(rA×axis) + (rB×axis)^T·I_w⁻¹_B·(rB×axis)
414
511
  const k_n = invMA + invMB
415
512
  + angular_jacobian_contribution(rbA, trA, rax, ray, raz, nx, ny, nz, scratch_inertia_a)
416
513
  + angular_jacobian_contribution(rbB, trB, rbx, rby, rbz, nx, ny, nz, scratch_inertia_b);
@@ -421,15 +518,13 @@ function solve_island(manifolds, system, dt, iters, slot_list, slot_start, slot_
421
518
  + angular_jacobian_contribution(rbA, trA, rax, ray, raz, t2x, t2y, t2z, scratch_inertia_a)
422
519
  + angular_jacobian_contribution(rbB, trB, rbx, rby, rbz, t2x, t2y, t2z, scratch_inertia_b);
423
520
 
424
- pre[pre_off + 12] = k_n > 0 ? 1 / k_n : 0;
425
- pre[pre_off + 13] = k_t1 > 0 ? 1 / k_t1 : 0;
426
- pre[pre_off + 14] = k_t2 > 0 ? 1 / k_t2 : 0;
521
+ const k_n_eff = k_n + g_spook_eps;
522
+ pre[pre_off + 18] = k_n_eff > 0 ? 1 / k_n_eff : 0;
523
+ pre[pre_off + 19] = k_t1 > 0 ? 1 / k_t1 : 0;
524
+ pre[pre_off + 20] = k_t2 > 0 ? 1 / k_t2 : 0;
427
525
 
428
- // Pre-impulse relative velocity at the contact point, including
429
- // angular contribution. Convention: n is from B toward A;
430
- // dv = vA_at_contact − vB_at_contact; vn = dv · n.
431
- // Cache the typed-array views once — Vector3 extends Float64Array,
432
- // so `lv[0]` is a direct memory read, no signal observer chain.
526
+ // Restitution approach velocity (captured once, this is the
527
+ // closing speed entering the step). n is B A; vn < 0 closing.
433
528
  const lvA = rbA.linearVelocity, avA = rbA.angularVelocity;
434
529
  const lvB = rbB.linearVelocity, avB = rbB.angularVelocity;
435
530
  const vAx_at = lvA[0] + avA[1] * raz - avA[2] * ray;
@@ -438,62 +533,195 @@ function solve_island(manifolds, system, dt, iters, slot_list, slot_start, slot_
438
533
  const vBx_at = lvB[0] + avB[1] * rbz - avB[2] * rby;
439
534
  const vBy_at = lvB[1] + avB[2] * rbx - avB[0] * rbz;
440
535
  const vBz_at = lvB[2] + avB[0] * rby - avB[1] * rbx;
441
- const dvx = vAx_at - vBx_at;
442
- const dvy = vAy_at - vBy_at;
443
- const dvz = vAz_at - vBz_at;
444
- const vn_pre = dvx * nx + dvy * ny + dvz * nz;
445
-
446
- // Baumgarte position correction: gentle separation push when depth
447
- // exceeds the slop tolerance.
448
- let bias = 0;
449
- if (depth > PENETRATION_SLOP) {
450
- bias = -BAUMGARTE_BETA / dt * (depth - PENETRATION_SLOP);
451
- // negative because the solver formula is `lambda = m_eff * (-vn + bias_pos)`
452
- // → bias_pos = beta/dt * pen → we'll flip below by using `lambda = -m_eff * (vn + bias)`
453
- // For clarity: target vn_new = -bias_pos (i.e. positive separation velocity).
454
- // Stored sign convention: lambda = -m_eff * (vn + bias) with bias < 0 to push apart.
455
- }
456
- // Restitution: if closing fast, push the post-impulse vn to -e * vn_pre.
536
+ const vn_pre = (vAx_at - vBx_at) * nx + (vAy_at - vBy_at) * ny + (vAz_at - vBz_at) * nz;
537
+
538
+ let rest_bias = 0;
457
539
  if (vn_pre < -RESTITUTION_VELOCITY_THRESHOLD) {
458
- bias += restitution_combined * vn_pre;
459
- // restitution_combined > 0, vn_pre < 0 → contribution is negative,
460
- // reinforcing separation.
540
+ rest_bias = restitution_combined * vn_pre;
461
541
  }
462
- pre[pre_off + 15] = bias;
542
+ pre[pre_off + 21] = rest_bias;
543
+ pre[pre_off + 22] = 0; // bias_position — filled by refresh_contacts
463
544
 
464
545
  mus[c] = friction_combined;
546
+ pos_jn[c] = 0;
547
+ scratch_max_jn[c] = 0;
465
548
 
466
549
  idx[c * INDEX_STRIDE] = slot;
467
550
  idx[c * INDEX_STRIDE + 1] = k;
468
551
  idx[c * INDEX_STRIDE + 2] = idxA;
469
552
  idx[c * INDEX_STRIDE + 3] = idxB;
470
553
 
471
- if (apply_warm_start) {
472
- // Warm-start: replay cached impulses immediately so the
473
- // iteration loop starts close to the answer. With TGS
474
- // substepping, this only fires on substep 0 later
475
- // substeps continue from the impulses left by the
476
- // previous substep's iterations (those are already
477
- // reflected in the current velocity, re-applying would
478
- // double-count).
479
- const j_n = data[off + 10];
480
- const j_t1 = data[off + 11];
481
- const j_t2 = data[off + 12];
482
- const Px = nx * j_n + t1x * j_t1 + t2x * j_t2;
483
- const Py = ny * j_n + t1y * j_t1 + t2y * j_t2;
484
- const Pz = nz * j_n + t1z * j_t1 + t2z * j_t2;
485
-
486
- apply_impulse_to_body(rbA, trA, invMA, rax, ray, raz, Px, Py, Pz, +1, scratch_inertia_a);
487
- apply_impulse_to_body(rbB, trB, invMB, rbx, rby, rbz, Px, Py, Pz, -1, scratch_inertia_b);
488
- }
554
+ // Warm-start is NOT applied here: under TGS it must be replayed
555
+ // *every substep* (see warm_start_contacts) so that, per substep,
556
+ // the cached impulse balances exactly one substep of gravity.
557
+ // Applying the cached impulse once against a full frame of
558
+ // gravity (the non-substepped pattern) over-pushes resting
559
+ // contacts and jitters / explodes deep stacks.
489
560
 
490
561
  c++;
491
562
  }
492
563
  }
493
564
 
494
- const contact_count = c;
565
+ g_contact_count = c;
566
+ return c;
567
+ }
568
+
569
+ /**
570
+ * Stage 1b (per substep) — warm-start: replay the cached accumulated
571
+ * impulses `(j_n, j_t1, j_t2)` onto persistent velocity using the current
572
+ * (refreshed) lever arms and tangents. Run once per substep, after
573
+ * {@link refresh_contacts} and before {@link solve_velocity}.
574
+ *
575
+ * Per-substep warm-start is the crux of stable TGS: the stored impulse is a
576
+ * per-substep quantity (≈ the impulse to counter one substep of gravity), so
577
+ * replaying it each substep balances that substep's `integrate_velocity_gravity`
578
+ * and a resting contact holds at zero velocity. `solve_velocity` then only
579
+ * has to correct the residual, which converges in a few iterations even for
580
+ * deep chains because each substep carries just `h` of gravity.
581
+ *
582
+ * @param {ManifoldStore} manifolds
583
+ * @param {PhysicsSystem} system
584
+ */
585
+ export function warm_start_contacts(manifolds, system) {
586
+ const count = g_contact_count;
587
+ if (count === 0) return;
588
+
589
+ const data = manifolds.data_buffer;
590
+ const pre = scratch_pre;
591
+ const idx = scratch_idx;
592
+
593
+ for (let ci = 0; ci < count; ci++) {
594
+ const slot = idx[ci * INDEX_STRIDE];
595
+ const cidx = idx[ci * INDEX_STRIDE + 1];
596
+ const idxA = idx[ci * INDEX_STRIDE + 2];
597
+ const idxB = idx[ci * INDEX_STRIDE + 3];
598
+ const rbA = system.__bodies[idxA];
599
+ const rbB = system.__bodies[idxB];
600
+ const trA = system.__transforms[idxA];
601
+ const trB = system.__transforms[idxB];
602
+ const invMA = inv_mass_of(rbA);
603
+ const invMB = inv_mass_of(rbB);
604
+
605
+ const pre_off = ci * PRE_STRIDE;
606
+ const rax = pre[pre_off + 6], ray = pre[pre_off + 7], raz = pre[pre_off + 8];
607
+ const rbx = pre[pre_off + 9], rby = pre[pre_off + 10], rbz = pre[pre_off + 11];
608
+ const t1x = pre[pre_off + 12], t1y = pre[pre_off + 13], t1z = pre[pre_off + 14];
609
+ const t2x = pre[pre_off + 15], t2y = pre[pre_off + 16], t2z = pre[pre_off + 17];
610
+
611
+ const slot_off = manifolds.slot_data_offset(slot);
612
+ const off = slot_off + cidx * CONTACT_STRIDE;
613
+ const nx = data[off + 6], ny = data[off + 7], nz = data[off + 8];
614
+
615
+ const j_n = data[off + 10];
616
+ const j_t1 = data[off + 11];
617
+ const j_t2 = data[off + 12];
618
+ const Px = nx * j_n + t1x * j_t1 + t2x * j_t2;
619
+ const Py = ny * j_n + t1y * j_t1 + t2y * j_t2;
620
+ const Pz = nz * j_n + t1z * j_t1 + t2z * j_t2;
621
+ apply_impulse_to_body(rbA, trA, invMA, rax, ray, raz, Px, Py, Pz, +1, scratch_inertia_a);
622
+ apply_impulse_to_body(rbB, trB, invMB, rbx, rby, rbz, Px, Py, Pz, -1, scratch_inertia_b);
623
+ }
624
+ }
625
+
626
+ /**
627
+ * Stage 2 (per substep) — re-derive each contact's geometry from the
628
+ * bodies' current poses and the local witness anchors captured at prepare.
629
+ *
630
+ * For each contact:
631
+ * - rotate the stored local witnesses back to world by the current pose to
632
+ * get the moved contact points `cpA`, `cpB`;
633
+ * - current penetration `depth_now = depth0 − Δseparation`, where
634
+ * `Δseparation = (cpA − wA − (cpB − wB)) · n` is the change since prepare.
635
+ * Anchoring on the trusted prepare-time depth makes the sign convention
636
+ * irrelevant — only the analytic delta uses the anchors;
637
+ * - rebuild the impulse levers `rA`/`rB` from the moved midpoint;
638
+ * - recompute the position-correction bias from `depth_now`.
639
+ *
640
+ * The contact normal and tangents are held fixed for the outer step (valid
641
+ * for the small per-step rotation), so they are not recomputed here.
642
+ *
643
+ * @param {ManifoldStore} manifolds
644
+ * @param {PhysicsSystem} system
645
+ */
646
+ export function refresh_contacts(manifolds, system) {
647
+ const count = g_contact_count;
648
+ if (count === 0) return;
649
+
650
+ const data = manifolds.data_buffer;
651
+ const pre = scratch_pre;
652
+ const idx = scratch_idx;
653
+ const cp = scratch_cp;
654
+ const spook_a = g_spook_a;
655
+
656
+ for (let ci = 0; ci < count; ci++) {
657
+ const slot = idx[ci * INDEX_STRIDE];
658
+ const cidx = idx[ci * INDEX_STRIDE + 1];
659
+ const idxA = idx[ci * INDEX_STRIDE + 2];
660
+ const idxB = idx[ci * INDEX_STRIDE + 3];
661
+ const trA = system.__transforms[idxA];
662
+ const trB = system.__transforms[idxB];
663
+
664
+ const slot_off = manifolds.slot_data_offset(slot);
665
+ const off = slot_off + cidx * CONTACT_STRIDE;
666
+
667
+ const wax = data[off], way = data[off + 1], waz = data[off + 2];
668
+ const wbx = data[off + 3], wby = data[off + 4], wbz = data[off + 5];
669
+ const nx = data[off + 6], ny = data[off + 7], nz = data[off + 8];
670
+ const depth0 = data[off + 9];
671
+
672
+ const pre_off = ci * PRE_STRIDE;
673
+
674
+ const pAx = trA.position.x, pAy = trA.position.y, pAz = trA.position.z;
675
+ const pBx = trB.position.x, pBy = trB.position.y, pBz = trB.position.z;
676
+ const qA = trA.rotation, qB = trB.rotation;
677
+
678
+ // Current world contact points: COM + R · localWitness.
679
+ v3_quat3_apply(cp, 0, pre[pre_off], pre[pre_off + 1], pre[pre_off + 2], qA[0], qA[1], qA[2], qA[3]);
680
+ v3_quat3_apply(cp, 3, pre[pre_off + 3], pre[pre_off + 4], pre[pre_off + 5], qB[0], qB[1], qB[2], qB[3]);
681
+ const cpAx = pAx + cp[0], cpAy = pAy + cp[1], cpAz = pAz + cp[2];
682
+ const cpBx = pBx + cp[3], cpBy = pBy + cp[4], cpBz = pBz + cp[5];
683
+
684
+ // Penetration re-derived as a delta from the trusted prepare depth.
685
+ // Δsep = ((cpA − wA) − (cpB − wB)) · n.
686
+ const dsep = ((cpAx - wax) - (cpBx - wbx)) * nx
687
+ + ((cpAy - way) - (cpBy - wby)) * ny
688
+ + ((cpAz - waz) - (cpBz - wbz)) * nz;
689
+ const depth_now = depth0 - dsep;
690
+
691
+ // Impulse levers from each COM to the current contact midpoint.
692
+ const mx = (cpAx + cpBx) * 0.5;
693
+ const my = (cpAy + cpBy) * 0.5;
694
+ const mz = (cpAz + cpBz) * 0.5;
695
+ pre[pre_off + 6] = mx - pAx; pre[pre_off + 7] = my - pAy; pre[pre_off + 8] = mz - pAz;
696
+ pre[pre_off + 9] = mx - pBx; pre[pre_off + 10] = my - pBy; pre[pre_off + 11] = mz - pBz;
697
+
698
+ let bias_position = 0;
699
+ if (depth_now > PENETRATION_SLOP) {
700
+ bias_position = -spook_a * (depth_now - PENETRATION_SLOP);
701
+ if (bias_position < -MAX_POSITION_BIAS) bias_position = -MAX_POSITION_BIAS;
702
+ }
703
+ pre[pre_off + 22] = bias_position;
704
+ }
705
+ }
706
+
707
+ /**
708
+ * Stage 3 (per substep) — velocity iterations enforcing pure
709
+ * non-penetration (`vn → 0`) plus Coulomb-disk friction. No bias: depth
710
+ * correction is the position pass, restitution is the one-shot pass.
711
+ *
712
+ * @param {ManifoldStore} manifolds
713
+ * @param {PhysicsSystem} system
714
+ * @param {number} iters
715
+ */
716
+ export function solve_velocity(manifolds, system, iters) {
717
+ const contact_count = g_contact_count;
718
+ if (contact_count === 0) return;
719
+
720
+ const data = manifolds.data_buffer;
721
+ const pre = scratch_pre;
722
+ const idx = scratch_idx;
723
+ const mus = scratch_mu;
495
724
 
496
- // 3. Velocity iterations.
497
725
  for (let iter = 0; iter < iters; iter++) {
498
726
  for (let ci = 0; ci < contact_count; ci++) {
499
727
  const slot = idx[ci * INDEX_STRIDE];
@@ -508,22 +736,18 @@ function solve_island(manifolds, system, dt, iters, slot_list, slot_start, slot_
508
736
  const invMB = inv_mass_of(rbB);
509
737
 
510
738
  const pre_off = ci * PRE_STRIDE;
511
- const rax = pre[pre_off], ray = pre[pre_off + 1], raz = pre[pre_off + 2];
512
- const rbx = pre[pre_off + 3], rby = pre[pre_off + 4], rbz = pre[pre_off + 5];
513
- const t1x = pre[pre_off + 6], t1y = pre[pre_off + 7], t1z = pre[pre_off + 8];
514
- const t2x = pre[pre_off + 9], t2y = pre[pre_off + 10], t2z = pre[pre_off + 11];
515
- const m_eff_n = pre[pre_off + 12];
516
- const m_eff_t1 = pre[pre_off + 13];
517
- const m_eff_t2 = pre[pre_off + 14];
518
- const bias_n = pre[pre_off + 15];
739
+ const rax = pre[pre_off + 6], ray = pre[pre_off + 7], raz = pre[pre_off + 8];
740
+ const rbx = pre[pre_off + 9], rby = pre[pre_off + 10], rbz = pre[pre_off + 11];
741
+ const t1x = pre[pre_off + 12], t1y = pre[pre_off + 13], t1z = pre[pre_off + 14];
742
+ const t2x = pre[pre_off + 15], t2y = pre[pre_off + 16], t2z = pre[pre_off + 17];
743
+ const m_eff_n = pre[pre_off + 18];
744
+ const m_eff_t1 = pre[pre_off + 19];
745
+ const m_eff_t2 = pre[pre_off + 20];
519
746
 
520
747
  const slot_off = manifolds.slot_data_offset(slot);
521
748
  const off = slot_off + cidx * CONTACT_STRIDE;
522
749
  const nx = data[off + 6], ny = data[off + 7], nz = data[off + 8];
523
750
 
524
- // --- Current relative velocity at contact (linear + angular) ---
525
- // Cache typed-array views once; direct indexing avoids the
526
- // accessor chain on Vector3.x/y/z.
527
751
  const lvA = rbA.linearVelocity, avA = rbA.angularVelocity;
528
752
  const lvB = rbB.linearVelocity, avB = rbB.angularVelocity;
529
753
 
@@ -537,14 +761,15 @@ function solve_island(manifolds, system, dt, iters, slot_list, slot_start, slot_
537
761
  const dvy = vAy_at - vBy_at;
538
762
  const dvz = vAz_at - vBz_at;
539
763
 
540
- // --- Normal impulse ---
764
+ // --- Normal impulse (non-penetration: drive vn → 0) ---
541
765
  const vn = dvx * nx + dvy * ny + dvz * nz;
542
766
  const j_n_accum = data[off + 10];
543
- const lambda_n = -m_eff_n * (vn + bias_n);
767
+ const lambda_n = -m_eff_n * vn;
544
768
  const sum_n = j_n_accum + lambda_n;
545
769
  const new_j_n = sum_n > 0 ? sum_n : 0;
546
770
  const delta_j_n = new_j_n - j_n_accum;
547
771
  data[off + 10] = new_j_n;
772
+ if (new_j_n > scratch_max_jn[ci]) scratch_max_jn[ci] = new_j_n;
548
773
 
549
774
  if (delta_j_n !== 0) {
550
775
  const Pnx = nx * delta_j_n;
@@ -555,8 +780,6 @@ function solve_island(manifolds, system, dt, iters, slot_list, slot_start, slot_
555
780
  }
556
781
 
557
782
  // --- Friction impulse (Coulomb disk in tangent plane) ---
558
- // Recompute relative velocity at contact after the normal impulse.
559
- // lvA/avA/lvB/avB still alias the same arrays, so we just re-read.
560
783
  const vAx2 = lvA[0] + avA[1] * raz - avA[2] * ray;
561
784
  const vAy2 = lvA[1] + avA[2] * rax - avA[0] * raz;
562
785
  const vAz2 = lvA[2] + avA[0] * ray - avA[1] * rax;
@@ -596,3 +819,198 @@ function solve_island(manifolds, system, dt, iters, slot_list, slot_start, slot_
596
819
  }
597
820
  }
598
821
  }
822
+
823
+ /**
824
+ * Stage 4 (once, after the substep loop) — one-shot restitution
825
+ * (Box2D-v3 `b2ApplyRestitution`, Catto 2018). Drives `vn → -e · vn_approach`
826
+ * exactly once per closing contact, gated on (a) the contact having been
827
+ * closing faster than the threshold at prepare, and (b) a compressive
828
+ * normal impulse having formed during the velocity solve. The added impulse
829
+ * accumulates into the same normal-impulse slot so it composes with
830
+ * warm-start next frame.
831
+ *
832
+ * @param {ManifoldStore} manifolds
833
+ * @param {PhysicsSystem} system
834
+ */
835
+ export function apply_restitution(manifolds, system) {
836
+ const contact_count = g_contact_count;
837
+ if (contact_count === 0) return;
838
+
839
+ const data = manifolds.data_buffer;
840
+ const pre = scratch_pre;
841
+ const idx = scratch_idx;
842
+
843
+ for (let ci = 0; ci < contact_count; ci++) {
844
+ const pre_off = ci * PRE_STRIDE;
845
+ const rest_bias = pre[pre_off + 21];
846
+ if (rest_bias === 0) continue;
847
+
848
+ const slot = idx[ci * INDEX_STRIDE];
849
+ const cidx = idx[ci * INDEX_STRIDE + 1];
850
+ // Gate on the running max normal impulse, not the end-of-loop value:
851
+ // a transient collision relaxes back to j_n ≈ 0 under per-substep
852
+ // warm-start, but it WAS compressive, so it should still bounce.
853
+ if (scratch_max_jn[ci] <= 0) continue;
854
+
855
+ const slot_off = manifolds.slot_data_offset(slot);
856
+ const off = slot_off + cidx * CONTACT_STRIDE;
857
+
858
+ const j_n_accum = data[off + 10];
859
+
860
+ const idxA = idx[ci * INDEX_STRIDE + 2];
861
+ const idxB = idx[ci * INDEX_STRIDE + 3];
862
+ const rbA = system.__bodies[idxA];
863
+ const rbB = system.__bodies[idxB];
864
+ const trA = system.__transforms[idxA];
865
+ const trB = system.__transforms[idxB];
866
+ const invMA = inv_mass_of(rbA);
867
+ const invMB = inv_mass_of(rbB);
868
+
869
+ const rax = pre[pre_off + 6], ray = pre[pre_off + 7], raz = pre[pre_off + 8];
870
+ const rbx = pre[pre_off + 9], rby = pre[pre_off + 10], rbz = pre[pre_off + 11];
871
+ const m_eff_n = pre[pre_off + 18];
872
+ const nx = data[off + 6], ny = data[off + 7], nz = data[off + 8];
873
+
874
+ const lvA = rbA.linearVelocity, avA = rbA.angularVelocity;
875
+ const lvB = rbB.linearVelocity, avB = rbB.angularVelocity;
876
+ const vAx_at = lvA[0] + avA[1] * raz - avA[2] * ray;
877
+ const vAy_at = lvA[1] + avA[2] * rax - avA[0] * raz;
878
+ const vAz_at = lvA[2] + avA[0] * ray - avA[1] * rax;
879
+ const vBx_at = lvB[0] + avB[1] * rbz - avB[2] * rby;
880
+ const vBy_at = lvB[1] + avB[2] * rbx - avB[0] * rbz;
881
+ const vBz_at = lvB[2] + avB[0] * rby - avB[1] * rbx;
882
+ const vn = (vAx_at - vBx_at) * nx + (vAy_at - vBy_at) * ny + (vAz_at - vBz_at) * nz;
883
+
884
+ const lambda = -m_eff_n * (vn + rest_bias);
885
+ const new_j_n = (j_n_accum + lambda) > 0 ? (j_n_accum + lambda) : 0;
886
+ const delta = new_j_n - j_n_accum;
887
+ data[off + 10] = new_j_n;
888
+
889
+ if (delta !== 0) {
890
+ const Px = nx * delta, Py = ny * delta, Pz = nz * delta;
891
+ apply_impulse_to_body(rbA, trA, invMA, rax, ray, raz, Px, Py, Pz, +1, scratch_inertia_a);
892
+ apply_impulse_to_body(rbB, trB, invMB, rbx, rby, rbz, Px, Py, Pz, -1, scratch_inertia_b);
893
+ }
894
+ }
895
+ }
896
+
897
+ /**
898
+ * Stage 5 (per substep) — split-impulse position correction. Normal-only
899
+ * (friction in the pseudo-velocity pass is ill-defined). Reads the per-body
900
+ * pseudo-velocity, applies a clamped normal impulse driven by the refreshed
901
+ * `bias_position`, and writes the increment back into the pseudo buffer. The
902
+ * pose integrator folds pseudo-velocity into `pos += v · dt` and discards it.
903
+ *
904
+ * The pseudo buffer must be zeroed by the caller before this stage each
905
+ * substep (it's a per-substep correction). The position accumulator
906
+ * `scratch_pos_jn` is likewise reset here per substep.
907
+ *
908
+ * @param {ManifoldStore} manifolds
909
+ * @param {PhysicsSystem} system
910
+ * @param {number} pos_iters
911
+ */
912
+ export function solve_position(manifolds, system, pos_iters) {
913
+ const contact_count = g_contact_count;
914
+ if (contact_count === 0) return;
915
+
916
+ const data = manifolds.data_buffer;
917
+ const pre = scratch_pre;
918
+ const idx = scratch_idx;
919
+ const pos_jn = scratch_pos_jn;
920
+ const pseudoVel = system.__pseudo_velocity;
921
+
922
+ // Reset the position-impulse accumulator for this substep.
923
+ for (let ci = 0; ci < contact_count; ci++) pos_jn[ci] = 0;
924
+
925
+ for (let iter = 0; iter < pos_iters; iter++) {
926
+ for (let ci = 0; ci < contact_count; ci++) {
927
+ const pre_off = ci * PRE_STRIDE;
928
+ const bias_p = pre[pre_off + 22];
929
+ if (bias_p === 0) continue;
930
+
931
+ const slot = idx[ci * INDEX_STRIDE];
932
+ const cidx = idx[ci * INDEX_STRIDE + 1];
933
+ const idxA = idx[ci * INDEX_STRIDE + 2];
934
+ const idxB = idx[ci * INDEX_STRIDE + 3];
935
+ const rbA = system.__bodies[idxA];
936
+ const rbB = system.__bodies[idxB];
937
+ const trA = system.__transforms[idxA];
938
+ const trB = system.__transforms[idxB];
939
+ const invMA = inv_mass_of(rbA);
940
+ const invMB = inv_mass_of(rbB);
941
+
942
+ const rax = pre[pre_off + 6], ray = pre[pre_off + 7], raz = pre[pre_off + 8];
943
+ const rbx = pre[pre_off + 9], rby = pre[pre_off + 10], rbz = pre[pre_off + 11];
944
+ const m_eff_n = pre[pre_off + 18];
945
+
946
+ const slot_off = manifolds.slot_data_offset(slot);
947
+ const off = slot_off + cidx * CONTACT_STRIDE;
948
+ const nx = data[off + 6], ny = data[off + 7], nz = data[off + 8];
949
+
950
+ const baseA = idxA * PSEUDO_STRIDE;
951
+ const baseB = idxB * PSEUDO_STRIDE;
952
+
953
+ const pslA_x = pseudoVel[baseA], pslA_y = pseudoVel[baseA + 1], pslA_z = pseudoVel[baseA + 2];
954
+ const psaA_x = pseudoVel[baseA + 3], psaA_y = pseudoVel[baseA + 4], psaA_z = pseudoVel[baseA + 5];
955
+ const pslB_x = pseudoVel[baseB], pslB_y = pseudoVel[baseB + 1], pslB_z = pseudoVel[baseB + 2];
956
+ const psaB_x = pseudoVel[baseB + 3], psaB_y = pseudoVel[baseB + 4], psaB_z = pseudoVel[baseB + 5];
957
+
958
+ const psvAx_at = pslA_x + psaA_y * raz - psaA_z * ray;
959
+ const psvAy_at = pslA_y + psaA_z * rax - psaA_x * raz;
960
+ const psvAz_at = pslA_z + psaA_x * ray - psaA_y * rax;
961
+ const psvBx_at = pslB_x + psaB_y * rbz - psaB_z * rby;
962
+ const psvBy_at = pslB_y + psaB_z * rbx - psaB_x * rbz;
963
+ const psvBz_at = pslB_z + psaB_x * rby - psaB_y * rbx;
964
+
965
+ const psdvx = psvAx_at - psvBx_at;
966
+ const psdvy = psvAy_at - psvBy_at;
967
+ const psdvz = psvAz_at - psvBz_at;
968
+ const psvn = psdvx * nx + psdvy * ny + psdvz * nz;
969
+
970
+ const jn_accum = pos_jn[ci];
971
+ const lambda = -m_eff_n * (psvn + bias_p);
972
+ const sum = jn_accum + lambda;
973
+ const new_jn = sum > 0 ? sum : 0;
974
+ const delta = new_jn - jn_accum;
975
+ pos_jn[ci] = new_jn;
976
+
977
+ if (delta !== 0) {
978
+ const Px = nx * delta;
979
+ const Py = ny * delta;
980
+ const Pz = nz * delta;
981
+ apply_impulse_to_pseudo(pseudoVel, baseA, rbA, trA, invMA, rax, ray, raz, Px, Py, Pz, +1, scratch_inertia_a);
982
+ apply_impulse_to_pseudo(pseudoVel, baseB, rbB, trB, invMB, rbx, rby, rbz, Px, Py, Pz, -1, scratch_inertia_b);
983
+ }
984
+ }
985
+ }
986
+ }
987
+
988
+ /**
989
+ * Convenience single-step driver: prepare → refresh → velocity →
990
+ * restitution → position, all at the full `dt` (one substep). Equivalent to
991
+ * the Phase-2 solver. The substepped path in `PhysicsSystem.fixedUpdate`
992
+ * calls the stages directly; this entry point exists for callers/tests that
993
+ * want a one-shot solve.
994
+ *
995
+ * The position pass writes `system.__pseudo_velocity`; the caller must zero
996
+ * that buffer before this call and fold it into the pose afterwards.
997
+ *
998
+ * @param {ManifoldStore} manifolds
999
+ * @param {PhysicsSystem} system
1000
+ * @param {number} dt
1001
+ * @param {number} [iters]
1002
+ * @param {number} [pos_iters]
1003
+ */
1004
+ export function solve_contacts(manifolds, system, dt,
1005
+ iters = DEFAULT_VELOCITY_ITERATIONS,
1006
+ pos_iters = DEFAULT_POSITION_ITERATIONS) {
1007
+ if (dt <= 0) return;
1008
+ if (prepare_contacts(manifolds, system, dt) === 0) return;
1009
+ refresh_contacts(manifolds, system);
1010
+ warm_start_contacts(manifolds, system);
1011
+ solve_velocity(manifolds, system, iters);
1012
+ apply_restitution(manifolds, system);
1013
+ solve_position(manifolds, system, pos_iters);
1014
+ }
1015
+
1016
+ export { DEFAULT_VELOCITY_ITERATIONS, DEFAULT_POSITION_ITERATIONS };