@codexo/exojs-physics 0.15.2 → 0.16.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 (201) hide show
  1. package/README.md +51 -23
  2. package/dist/esm/Aabb.d.ts +6 -10
  3. package/dist/esm/Aabb.d.ts.map +1 -0
  4. package/dist/esm/Aabb.js +9 -5
  5. package/dist/esm/Aabb.js.map +1 -1
  6. package/dist/esm/Collider.d.ts +62 -10
  7. package/dist/esm/Collider.d.ts.map +1 -0
  8. package/dist/esm/Collider.js +263 -157
  9. package/dist/esm/Collider.js.map +1 -1
  10. package/dist/esm/ContactGraph.d.ts +51 -5
  11. package/dist/esm/ContactGraph.d.ts.map +1 -0
  12. package/dist/esm/ContactGraph.js +282 -223
  13. package/dist/esm/ContactGraph.js.map +1 -1
  14. package/dist/esm/ContactModifier.d.ts +85 -0
  15. package/dist/esm/ContactModifier.d.ts.map +1 -0
  16. package/dist/esm/ContactModifier.js +49 -0
  17. package/dist/esm/ContactModifier.js.map +1 -0
  18. package/dist/esm/PhysicsBody.d.ts +56 -22
  19. package/dist/esm/PhysicsBody.d.ts.map +1 -0
  20. package/dist/esm/PhysicsBody.js +461 -443
  21. package/dist/esm/PhysicsBody.js.map +1 -1
  22. package/dist/esm/PhysicsWorld.d.ts +276 -43
  23. package/dist/esm/PhysicsWorld.d.ts.map +1 -0
  24. package/dist/esm/PhysicsWorld.js +890 -626
  25. package/dist/esm/PhysicsWorld.js.map +1 -1
  26. package/dist/esm/TimeStepper.d.ts +3 -2
  27. package/dist/esm/TimeStepper.d.ts.map +1 -0
  28. package/dist/esm/TimeStepper.js +56 -67
  29. package/dist/esm/TimeStepper.js.map +1 -1
  30. package/dist/esm/backend/NativePhysicsBackend.d.ts +8 -4
  31. package/dist/esm/backend/NativePhysicsBackend.d.ts.map +1 -0
  32. package/dist/esm/backend/NativePhysicsBackend.js +48 -41
  33. package/dist/esm/backend/NativePhysicsBackend.js.map +1 -1
  34. package/dist/esm/backend/PhysicsBackend.d.ts +9 -2
  35. package/dist/esm/backend/PhysicsBackend.d.ts.map +1 -0
  36. package/dist/esm/binding/BindingRegistry.d.ts +25 -4
  37. package/dist/esm/binding/BindingRegistry.d.ts.map +1 -0
  38. package/dist/esm/binding/BindingRegistry.js +70 -36
  39. package/dist/esm/binding/BindingRegistry.js.map +1 -1
  40. package/dist/esm/binding/PhysicsBinding.d.ts +22 -5
  41. package/dist/esm/binding/PhysicsBinding.d.ts.map +1 -0
  42. package/dist/esm/binding/PhysicsBinding.js +46 -21
  43. package/dist/esm/binding/PhysicsBinding.js.map +1 -1
  44. package/dist/esm/broadphase/AabbTreeBroadPhase.d.ts +68 -0
  45. package/dist/esm/broadphase/AabbTreeBroadPhase.d.ts.map +1 -0
  46. package/dist/esm/broadphase/AabbTreeBroadPhase.js +149 -0
  47. package/dist/esm/broadphase/AabbTreeBroadPhase.js.map +1 -0
  48. package/dist/esm/broadphase/BroadPhase.d.ts +7 -4
  49. package/dist/esm/broadphase/BroadPhase.d.ts.map +1 -0
  50. package/dist/esm/broadphase/index.d.ts +2 -1
  51. package/dist/esm/broadphase/index.d.ts.map +1 -0
  52. package/dist/esm/collision/CollisionProxy.d.ts +3 -2
  53. package/dist/esm/collision/CollisionProxy.d.ts.map +1 -0
  54. package/dist/esm/collision/Manifold.d.ts +5 -4
  55. package/dist/esm/collision/Manifold.d.ts.map +1 -0
  56. package/dist/esm/collision/Manifold.js +34 -25
  57. package/dist/esm/collision/Manifold.js.map +1 -1
  58. package/dist/esm/collision/chainAdjacency.d.ts +29 -0
  59. package/dist/esm/collision/chainAdjacency.d.ts.map +1 -0
  60. package/dist/esm/collision/chainAdjacency.js +44 -0
  61. package/dist/esm/collision/chainAdjacency.js.map +1 -0
  62. package/dist/esm/collision/dispatch.d.ts +35 -0
  63. package/dist/esm/collision/dispatch.d.ts.map +1 -0
  64. package/dist/esm/collision/dispatch.js +58 -0
  65. package/dist/esm/collision/dispatch.js.map +1 -0
  66. package/dist/esm/collision/index.d.ts +2 -0
  67. package/dist/esm/collision/index.d.ts.map +1 -0
  68. package/dist/esm/collision/narrowphase.d.ts +5 -2
  69. package/dist/esm/collision/narrowphase.d.ts.map +1 -0
  70. package/dist/esm/collision/narrowphase.js +524 -399
  71. package/dist/esm/collision/narrowphase.js.map +1 -1
  72. package/dist/esm/collision/segments.d.ts +19 -0
  73. package/dist/esm/collision/segments.d.ts.map +1 -0
  74. package/dist/esm/collision/segments.js +36 -0
  75. package/dist/esm/collision/segments.js.map +1 -0
  76. package/dist/esm/collision/sweep.d.ts +35 -0
  77. package/dist/esm/collision/sweep.d.ts.map +1 -0
  78. package/dist/esm/collision/sweep.js +437 -0
  79. package/dist/esm/collision/sweep.js.map +1 -0
  80. package/dist/esm/debug/PhysicsDebugDraw.d.ts +23 -4
  81. package/dist/esm/debug/PhysicsDebugDraw.d.ts.map +1 -0
  82. package/dist/esm/debug/PhysicsDebugDraw.js +230 -188
  83. package/dist/esm/debug/PhysicsDebugDraw.js.map +1 -1
  84. package/dist/esm/debug/index.d.ts +1 -0
  85. package/dist/esm/debug/index.d.ts.map +1 -0
  86. package/dist/esm/debug/index.js +3 -2
  87. package/dist/esm/events.d.ts +1 -0
  88. package/dist/esm/events.d.ts.map +1 -0
  89. package/dist/esm/index.d.ts +1 -0
  90. package/dist/esm/index.d.ts.map +1 -0
  91. package/dist/esm/index.js +24 -19
  92. package/dist/esm/joints/DistanceJoint.d.ts +4 -3
  93. package/dist/esm/joints/DistanceJoint.d.ts.map +1 -0
  94. package/dist/esm/joints/DistanceJoint.js +160 -172
  95. package/dist/esm/joints/DistanceJoint.js.map +1 -1
  96. package/dist/esm/joints/Joint.d.ts +5 -4
  97. package/dist/esm/joints/Joint.d.ts.map +1 -0
  98. package/dist/esm/joints/Joint.js +23 -21
  99. package/dist/esm/joints/Joint.js.map +1 -1
  100. package/dist/esm/joints/MouseJoint.d.ts +8 -7
  101. package/dist/esm/joints/MouseJoint.d.ts.map +1 -0
  102. package/dist/esm/joints/MouseJoint.js +132 -133
  103. package/dist/esm/joints/MouseJoint.js.map +1 -1
  104. package/dist/esm/joints/PrismaticJoint.d.ts +5 -4
  105. package/dist/esm/joints/PrismaticJoint.d.ts.map +1 -0
  106. package/dist/esm/joints/PrismaticJoint.js +211 -240
  107. package/dist/esm/joints/PrismaticJoint.js.map +1 -1
  108. package/dist/esm/joints/RevoluteJoint.d.ts +4 -3
  109. package/dist/esm/joints/RevoluteJoint.d.ts.map +1 -0
  110. package/dist/esm/joints/RevoluteJoint.js +191 -213
  111. package/dist/esm/joints/RevoluteJoint.js.map +1 -1
  112. package/dist/esm/joints/WeldJoint.d.ts +3 -2
  113. package/dist/esm/joints/WeldJoint.d.ts.map +1 -0
  114. package/dist/esm/joints/WeldJoint.js +156 -152
  115. package/dist/esm/joints/WeldJoint.js.map +1 -1
  116. package/dist/esm/joints/WheelJoint.d.ts +4 -3
  117. package/dist/esm/joints/WheelJoint.d.ts.map +1 -0
  118. package/dist/esm/joints/WheelJoint.js +228 -255
  119. package/dist/esm/joints/WheelJoint.js.map +1 -1
  120. package/dist/esm/math.d.ts +6 -9
  121. package/dist/esm/math.d.ts.map +1 -0
  122. package/dist/esm/math.js +37 -39
  123. package/dist/esm/math.js.map +1 -1
  124. package/dist/esm/physicsBuildInfo.d.ts +1 -0
  125. package/dist/esm/physicsBuildInfo.d.ts.map +1 -0
  126. package/dist/esm/physicsBuildInfo.js +6 -4
  127. package/dist/esm/physicsBuildInfo.js.map +1 -1
  128. package/dist/esm/public.d.ts +8 -3
  129. package/dist/esm/public.d.ts.map +1 -0
  130. package/dist/esm/query/QueryEngine.d.ts +56 -12
  131. package/dist/esm/query/QueryEngine.d.ts.map +1 -0
  132. package/dist/esm/query/QueryEngine.js +494 -237
  133. package/dist/esm/query/QueryEngine.js.map +1 -1
  134. package/dist/esm/query/SpatialIndex.d.ts +34 -0
  135. package/dist/esm/query/SpatialIndex.d.ts.map +1 -0
  136. package/dist/esm/shapes/AnyShape.d.ts +9 -4
  137. package/dist/esm/shapes/AnyShape.d.ts.map +1 -0
  138. package/dist/esm/shapes/BoxShape.d.ts +2 -1
  139. package/dist/esm/shapes/BoxShape.d.ts.map +1 -0
  140. package/dist/esm/shapes/BoxShape.js +37 -25
  141. package/dist/esm/shapes/BoxShape.js.map +1 -1
  142. package/dist/esm/shapes/CapsuleShape.d.ts +30 -0
  143. package/dist/esm/shapes/CapsuleShape.d.ts.map +1 -0
  144. package/dist/esm/shapes/CapsuleShape.js +88 -0
  145. package/dist/esm/shapes/CapsuleShape.js.map +1 -0
  146. package/dist/esm/shapes/ChainEdgeShape.d.ts +30 -0
  147. package/dist/esm/shapes/ChainEdgeShape.d.ts.map +1 -0
  148. package/dist/esm/shapes/ChainEdgeShape.js +66 -0
  149. package/dist/esm/shapes/ChainEdgeShape.js.map +1 -0
  150. package/dist/esm/shapes/ChainShape.d.ts +52 -0
  151. package/dist/esm/shapes/ChainShape.d.ts.map +1 -0
  152. package/dist/esm/shapes/ChainShape.js +85 -0
  153. package/dist/esm/shapes/ChainShape.js.map +1 -0
  154. package/dist/esm/shapes/CircleShape.d.ts +3 -4
  155. package/dist/esm/shapes/CircleShape.d.ts.map +1 -0
  156. package/dist/esm/shapes/CircleShape.js +27 -26
  157. package/dist/esm/shapes/CircleShape.js.map +1 -1
  158. package/dist/esm/shapes/PolygonShape.d.ts +7 -8
  159. package/dist/esm/shapes/PolygonShape.d.ts.map +1 -0
  160. package/dist/esm/shapes/PolygonShape.js +131 -162
  161. package/dist/esm/shapes/PolygonShape.js.map +1 -1
  162. package/dist/esm/shapes/SegmentShape.d.ts +31 -0
  163. package/dist/esm/shapes/SegmentShape.d.ts.map +1 -0
  164. package/dist/esm/shapes/SegmentShape.js +62 -0
  165. package/dist/esm/shapes/SegmentShape.js.map +1 -0
  166. package/dist/esm/shapes/Shape.d.ts +28 -17
  167. package/dist/esm/shapes/Shape.d.ts.map +1 -0
  168. package/dist/esm/shapes/Shape.js +12 -13
  169. package/dist/esm/shapes/Shape.js.map +1 -1
  170. package/dist/esm/shapes/convexParts.d.ts +21 -0
  171. package/dist/esm/shapes/convexParts.d.ts.map +1 -0
  172. package/dist/esm/shapes/convexParts.js +26 -0
  173. package/dist/esm/shapes/convexParts.js.map +1 -0
  174. package/dist/esm/shapes/decompose.d.ts +24 -0
  175. package/dist/esm/shapes/decompose.d.ts.map +1 -0
  176. package/dist/esm/shapes/decompose.js +272 -0
  177. package/dist/esm/shapes/decompose.js.map +1 -0
  178. package/dist/esm/shapes/index.d.ts +4 -0
  179. package/dist/esm/shapes/index.d.ts.map +1 -0
  180. package/dist/esm/solver/ContactSolver.d.ts +12 -5
  181. package/dist/esm/solver/ContactSolver.d.ts.map +1 -0
  182. package/dist/esm/solver/ContactSolver.js +425 -471
  183. package/dist/esm/solver/ContactSolver.js.map +1 -1
  184. package/dist/esm/solver/tolerances.d.ts +34 -0
  185. package/dist/esm/solver/tolerances.d.ts.map +1 -0
  186. package/dist/esm/solver/tolerances.js +38 -0
  187. package/dist/esm/solver/tolerances.js.map +1 -0
  188. package/dist/esm/sort.d.ts +5 -4
  189. package/dist/esm/sort.d.ts.map +1 -0
  190. package/dist/esm/sort.js +41 -51
  191. package/dist/esm/sort.js.map +1 -1
  192. package/dist/esm/types.d.ts +4 -8
  193. package/dist/esm/types.d.ts.map +1 -0
  194. package/dist/esm/types.js +15 -17
  195. package/dist/esm/types.js.map +1 -1
  196. package/package.json +6 -6
  197. package/dist/esm/broadphase/SweepAndPrune.d.ts +0 -15
  198. package/dist/esm/broadphase/SweepAndPrune.js +0 -77
  199. package/dist/esm/broadphase/SweepAndPrune.js.map +0 -1
  200. package/dist/esm/debug/index.js.map +0 -1
  201. package/dist/esm/index.js.map +0 -1
@@ -1,630 +1,894 @@
1
- import { Signal, Vector } from '@codexo/exojs';
2
- import { NativePhysicsBackend } from './backend/NativePhysicsBackend.js';
3
- import { BindingRegistry } from './binding/BindingRegistry.js';
4
- import { Collider } from './Collider.js';
5
- import { PhysicsBody } from './PhysicsBody.js';
6
- import { QueryEngine } from './query/QueryEngine.js';
7
- import { TimeStepper } from './TimeStepper.js';
1
+ import { aabbOverlap, createAabb } from "./Aabb.js";
2
+ import { shouldCollide } from "./types.js";
3
+ import { Collider, authoredCollider } from "./Collider.js";
4
+ import { PhysicsBody } from "./PhysicsBody.js";
5
+ import { sleepPenetrationTolerance } from "./solver/tolerances.js";
6
+ import { NativePhysicsBackend } from "./backend/NativePhysicsBackend.js";
7
+ import { BindingRegistry } from "./binding/BindingRegistry.js";
8
+ import { sweepProxies } from "./collision/sweep.js";
9
+ import { QueryEngine } from "./query/QueryEngine.js";
10
+ import { TimeStepper } from "./TimeStepper.js";
11
+ import { Signal, Vector } from "@codexo/exojs";
8
12
 
9
- /** Gap left between a clamped bullet and the surface it hit (so it does not re-hit from inside). */
10
- const ccdSkin = 0.05;
13
+ //#region src/PhysicsWorld.ts
11
14
  /**
12
- * The collision/query world: owns bodies, colliders, the detection backend,
13
- * bindings, the query engine and the fixed-step accumulator. Stepped by the
14
- * caller (commonly from a `Scene.update`), each fixed sub-step it integrates
15
- * body velocities, runs broad- and narrow-phase detection, solves contacts and
16
- * integrates positions, then fires immutable contact/sensor events and writes
17
- * bound node transforms. It holds **no module-level state**, so any number of
18
- * worlds run in isolation (gate I-1).
19
- *
20
- * The dynamics are a native, warm-started **TGS-Soft** solver (Box2D-v3 "soft
21
- * step"): each fixed step runs detection once, then several sub-steps, each
22
- * integrating gravity over the sub-step and solving contacts with a soft
23
- * position bias plus a bias-free relax pass; a 2-point block normal solve
24
- * propagates stack loads, and restitution is a separate final pass. Decoupling
25
- * stiffness from the iteration count keeps tall towers stable. The detection
26
- * backend sits behind an internal seam, so the solver is swappable without
27
- * touching this public surface.
28
- *
29
- * **Operating envelope.** The soft solver trades a little accuracy for
30
- * robustness, so it has a few documented limits each stays finite/stable and
31
- * each is pinned by a gate in `dynamics.test.ts`:
32
- * - **Mass ratio** resting stacks are slop-accurate up to ~100:1. Beyond that
33
- * the velocity-capped soft push-out (`maxBiasVelocity`) lets the lighter body
34
- * settle progressively deeper (≈6px at 500:1, fully through a thin floor by
35
- * ~5000:1) always finite, never exploding (SG-MR3).
36
- * - **No CCD** — detection runs once per fixed step with no swept test, so a
37
- * body that travels farther than an obstacle's thickness in one step tunnels
38
- * straight through it (it stays finite). Reliably stopping fast projectiles is
39
- * a future bullet-mode feature (SG-X5).
40
- * - **{@link PhysicsWorldOptions.subStepCount}** — the default `4` is
41
- * load-bearing for tall-stack stability; lowering it below `2` visibly
42
- * degrades stacking, so do not reduce it for performance.
43
- */
44
- class PhysicsWorld {
45
- /** Fires when two solid colliders begin touching. Argument is an immutable snapshot. */
46
- onCollisionStart = new Signal();
47
- /** Fires when two solid colliders stop touching (or one is destroyed). */
48
- onCollisionEnd = new Signal();
49
- /** Fires when a collider enters a sensor. */
50
- onSensorEnter = new Signal();
51
- /** Fires when a collider leaves a sensor. */
52
- onSensorExit = new Signal();
53
- /** World gravity (px/s², +Y down). Integrated each sub-step. */
54
- gravity;
55
- /** The fixed-step accumulator. */
56
- timeStepper;
57
- /** TGS-Soft sub-steps per fixed step. */
58
- subStepCount;
59
- /** Soft-contact stiffness in Hz. */
60
- contactHertz;
61
- /** Soft-contact damping ratio. */
62
- dampingRatio;
63
- /** Whether resting bodies are put to sleep. */
64
- enableSleeping;
65
- /** Linear sleep threshold (px/s). */
66
- sleepLinearVelocity;
67
- /** Angular sleep threshold (rad/s). */
68
- sleepAngularVelocity;
69
- /** Seconds below the thresholds before a body sleeps. */
70
- timeToSleep;
71
- _backend = new NativePhysicsBackend();
72
- _bodies = [];
73
- _colliders = [];
74
- _joints = [];
75
- _bindings = new BindingRegistry();
76
- _query;
77
- _commands = [];
78
- /** Pooled union-find parent array for the per-step island pass (reused; sized to the body count). */
79
- _islandParent = [];
80
- /** Pooled per-island minimum sleep time, indexed by union-find root. */
81
- _islandMinSleep = [];
82
- /** Pooled ray-hit buffer + origin/direction for the CCD swept test. */
83
- _ccdHits = [];
84
- _ccdOrigin = { x: 0, y: 0 };
85
- _ccdDir = { x: 0, y: 0 };
86
- _nextBodyId = 1;
87
- _nextColliderId = 1;
88
- _dispatching = false;
89
- _destroyed = false;
90
- constructor(options = {}) {
91
- this.gravity = new Vector(options.gravity?.x ?? 0, options.gravity?.y ?? 0);
92
- this.timeStepper = new TimeStepper({
93
- ...(options.fixedDelta !== undefined && { fixedDelta: options.fixedDelta }),
94
- ...(options.maxSubSteps !== undefined && { maxSubSteps: options.maxSubSteps }),
95
- });
96
- const subStepCount = options.subStepCount ?? 4;
97
- if (!Number.isInteger(subStepCount) || subStepCount < 1) {
98
- throw new RangeError(`PhysicsWorld: subStepCount must be an integer ≥ 1, received ${subStepCount}.`);
99
- }
100
- this.subStepCount = subStepCount;
101
- this.contactHertz = options.contactHertz ?? 30;
102
- this.dampingRatio = options.dampingRatio ?? 10;
103
- this.enableSleeping = options.enableSleeping ?? true;
104
- this.sleepLinearVelocity = options.sleepLinearVelocity ?? 5;
105
- this.sleepAngularVelocity = options.sleepAngularVelocity ?? 0.06;
106
- this.timeToSleep = options.timeToSleep ?? 0.5;
107
- this._query = new QueryEngine(this._colliders);
108
- }
109
- /** Live bodies (read-only view). */
110
- get bodies() {
111
- return this._bodies;
112
- }
113
- /** Live colliders (read-only view). */
114
- get colliders() {
115
- return this._colliders;
116
- }
117
- // ── lifecycle ──────────────────────────────────────────────────────────
118
- /**
119
- * Add a body to the world: allocates the body and its collider ids, registers
120
- * the colliders, computes the mass model and tracks the body for stepping.
121
- * Construct the body freely first (`new PhysicsBody({ … })`), then add it.
122
- * Safe to call inside an event callback the body push is deferred to the end
123
- * of the step, exactly like collider registration. Returns the body.
124
- *
125
- * @throws if the body has already been added to a world.
126
- */
127
- add(body) {
128
- this._assertAlive();
129
- if (body.attached) {
130
- throw new Error('PhysicsWorld.add: this body has already been added to a world.');
131
- }
132
- // Allocate the id + link/register colliders + aggregate mass now (matches the
133
- // old createBody, which allocated the id synchronously); only the body-list
134
- // push is deferred so it is safe inside an event dispatch.
135
- body._attachToWorld(this, this._nextBodyId++);
136
- this._defer(() => {
137
- if (!body.destroyed) {
138
- this._bodies.push(body);
139
- }
140
- });
141
- return body;
142
- }
143
- /**
144
- * Convenience: create a body carrying a single collider, add it to the world
145
- * and bind it to `node` in one call. The node tracks `body.position` after each
146
- * step. Returns the body. Equivalent to `new PhysicsBody(...)` + `add` + `bind`.
147
- */
148
- attach(node, options) {
149
- const body = new PhysicsBody({
150
- ...(options.type !== undefined && { type: options.type }),
151
- ...(options.position !== undefined && { position: options.position }),
152
- ...(options.angle !== undefined && { angle: options.angle }),
153
- ...(options.gravityScale !== undefined && { gravityScale: options.gravityScale }),
154
- ...(options.fixedRotation !== undefined && { fixedRotation: options.fixedRotation }),
155
- colliders: [
156
- new Collider({
157
- shape: options.shape,
158
- ...(options.offset !== undefined && { offset: options.offset }),
159
- ...(options.rotation !== undefined && { rotation: options.rotation }),
160
- ...(options.density !== undefined && { density: options.density }),
161
- ...(options.friction !== undefined && { friction: options.friction }),
162
- ...(options.restitution !== undefined && { restitution: options.restitution }),
163
- ...(options.isSensor !== undefined && { isSensor: options.isSensor }),
164
- ...(options.filter !== undefined && { filter: options.filter }),
165
- }),
166
- ],
167
- });
168
- this.add(body);
169
- this.bind(body, node);
170
- return body;
171
- }
172
- /** Destroy a body and its colliders. Deferred when called inside a callback. */
173
- destroyBody(body) {
174
- this._defer(() => this._removeBody(body));
175
- }
176
- /** Destroy a single collider, recomputing its body's mass. Deferred when called inside a callback. */
177
- destroyCollider(collider) {
178
- this._defer(() => this._removeCollider(collider));
179
- }
180
- /** Live joints (read-only view). */
181
- get joints() {
182
- return this._joints;
183
- }
184
- /**
185
- * Add a constraint joint. Construct it first (`new DistanceJoint({ … })`),
186
- * then add it. Wakes both bodies; safe inside a callback (registration is
187
- * deferred). Returns the joint.
188
- */
189
- addJoint(joint) {
190
- this._assertAlive();
191
- joint.bodyA.wake();
192
- joint.bodyB.wake();
193
- this._defer(() => {
194
- if (!this._joints.includes(joint)) {
195
- this._joints.push(joint);
196
- }
197
- });
198
- return joint;
199
- }
200
- /** Remove a joint, waking both bodies so they respond to the lost constraint. Deferred when called inside a callback. */
201
- removeJoint(joint) {
202
- joint.bodyA.wake();
203
- joint.bodyB.wake();
204
- this._defer(() => {
205
- const index = this._joints.indexOf(joint);
206
- if (index !== -1) {
207
- this._joints.splice(index, 1);
208
- }
209
- });
210
- }
211
- // ── stepping ───────────────────────────────────────────────────────────
212
- /**
213
- * Advance the world by `frameDeltaSeconds`. Accumulates into fixed steps; each
214
- * fixed step runs detection once, then a TGS-Soft sub-step loop (integrate
215
- * gravity, solve contacts with a soft bias, integrate positions, relax) and a
216
- * restitution pass, then writes the accumulated motion into each body. Finally
217
- * dispatches events and writes bound node transforms.
218
- */
219
- step(frameDeltaSeconds) {
220
- this._assertAlive();
221
- const steps = this.timeStepper.advance(frameDeltaSeconds);
222
- if (steps > 0) {
223
- const subStepCount = this.subStepCount;
224
- const h = this.timeStepper.fixedDelta / subStepCount;
225
- const gravityX = this.gravity.x;
226
- const gravityY = this.gravity.y;
227
- const contactHertz = this.contactHertz;
228
- const dampingRatio = this.dampingRatio;
229
- const hasJoints = this._joints.length > 0;
230
- const hasBullets = this._hasBullets();
231
- for (let step = 0; step < steps; step++) {
232
- // Detection runs once per fixed step (collider geometry is already current
233
- // from the previous frame's finalize / attach / setTransform). TGS-Soft
234
- // reuses the manifolds across the sub-steps below.
235
- this._backend.detect(this._colliders);
236
- // Sleep decision runs after detection (islands need the current contact
237
- // set) and before the solver (so sleeping contacts are skipped, and a
238
- // sleeping island touched by an awake body is woken first).
239
- if (this.enableSleeping) {
240
- this._updateSleeping(this.timeStepper.fixedDelta);
241
- }
242
- this._backend.prepareSolve(h, contactHertz, dampingRatio);
243
- if (hasJoints) {
244
- this._prepareJoints(h);
245
- }
246
- if (hasBullets) {
247
- this._recordBulletPositions();
248
- }
249
- for (let subStep = 0; subStep < subStepCount; subStep++) {
250
- // Integrate gravity/forces over the sub-step (forces persist across
251
- // sub-steps; cleared once per frame by `_finalizePosition`).
252
- for (const body of this._bodies) {
253
- body._integrateVelocity(h, gravityX, gravityY);
254
- }
255
- // Warm-start every sub-step (Box2D-v3 soft step): the relax pass leaves
256
- // each contact's normal velocity at zero, so re-applying the
257
- // accumulated impulse re-balances exactly this sub-step's gravity the
258
- // impulse converges to the per-sub-step load (m·h·g), not the per-frame
259
- // load, which is what keeps tall stacks from pumping energy.
260
- this._backend.warmStart();
261
- if (hasJoints) {
262
- this._warmStartJoints();
263
- }
264
- // Main soft-bias velocity solve, integrate positions (accumulating
265
- // per-body delta), then the bias-free relax pass. Joints solve right
266
- // after the contacts in each pass (contacts are the stiffer constraint).
267
- this._backend.solveVelocities(true);
268
- if (hasJoints) {
269
- this._solveJoints(true);
270
- }
271
- for (const body of this._bodies) {
272
- body._integratePosition(h);
273
- }
274
- this._backend.solveVelocities(false);
275
- if (hasJoints) {
276
- this._solveJoints(false);
277
- }
278
- }
279
- // Separate restitution pass, then write the accumulated delta into each
280
- // body's transform and re-sync collider geometry.
281
- this._backend.applyRestitution();
282
- for (const body of this._bodies) {
283
- body._finalizePosition();
284
- }
285
- if (hasBullets) {
286
- this._advanceBullets();
287
- }
288
- }
289
- this._dispatchEvents();
290
- }
291
- this._bindings.sync();
292
- this._drainCommands();
293
- }
294
- // ── binding ────────────────────────────────────────────────────────────
295
- /** Link a body to a scene node; the node tracks the body after each step. */
296
- bind(body, node) {
297
- return this._bindings.bind(body, node);
298
- }
299
- /** Remove a body↔node link. */
300
- unbind(body) {
301
- this._bindings.unbind(body);
302
- }
303
- // ── queries ────────────────────────────────────────────────────────────
304
- /** Colliders containing `point`. Fresh array. */
305
- queryPoint(point, filter) {
306
- return this._query.queryPoint(point, filter);
307
- }
308
- /** Colliders whose AABB overlaps `bounds`. Writes into `out` (cleared) if given. */
309
- queryAabb(bounds, filter, out) {
310
- return this._query.queryAabb(bounds, filter, out);
311
- }
312
- /** Invoke `callback` for each collider whose AABB overlaps `bounds`. Allocation-free. */
313
- forEachAabbHit(bounds, filter, callback) {
314
- this._query.forEachAabbHit(bounds, filter, callback);
315
- }
316
- /** Nearest collider hit by the ray, or `null`. */
317
- rayCast(origin, direction, filter, maxDistance) {
318
- return this._query.rayCast(origin, direction, filter, maxDistance);
319
- }
320
- /** All collider hits along the ray, sorted by distance. Writes into `out` (cleared) if given. */
321
- rayCastAll(origin, direction, filter, out, maxDistance) {
322
- return this._query.rayCastAll(origin, direction, filter, out, maxDistance);
323
- }
324
- /** Colliders overlapping `shape` placed at `position`/`angle`. Fresh array. */
325
- overlapShape(shape, position, filter, angle) {
326
- return this._query.overlapShape(shape, position, filter, angle);
327
- }
328
- /** Release every body, collider, binding and backend resource. */
329
- destroy() {
330
- if (this._destroyed) {
331
- return;
332
- }
333
- this._destroyed = true;
334
- for (const body of this._bodies) {
335
- body._markDestroyed();
336
- }
337
- this._bodies.length = 0;
338
- this._colliders.length = 0;
339
- this._joints.length = 0;
340
- this._commands.length = 0;
341
- this._bindings.clear();
342
- this._backend.destroy();
343
- this.onCollisionStart.destroy();
344
- this.onCollisionEnd.destroy();
345
- this.onSensorEnter.destroy();
346
- this.onSensorExit.destroy();
347
- }
348
- // ── BodyOwner ──────────────────────────────────────────────────────────
349
- _allocateColliderId() {
350
- return this._nextColliderId++;
351
- }
352
- _registerCollider(collider) {
353
- this._defer(() => {
354
- if (!collider.destroyed) {
355
- this._colliders.push(collider);
356
- }
357
- });
358
- }
359
- // ── internals ──────────────────────────────────────────────────────────
360
- /** The detection backend (internal; consumed by the debug draw layer). */
361
- get backend() {
362
- return this._backend;
363
- }
364
- _dispatchEvents() {
365
- const graph = this._backend.contactGraph;
366
- this._dispatching = true;
367
- for (const event of graph.collisionEnd) {
368
- this.onCollisionEnd.dispatch(event);
369
- }
370
- for (const event of graph.sensorExit) {
371
- this.onSensorExit.dispatch(event);
372
- }
373
- for (const event of graph.collisionStart) {
374
- this.onCollisionStart.dispatch(event);
375
- }
376
- for (const event of graph.sensorEnter) {
377
- this.onSensorEnter.dispatch(event);
378
- }
379
- this._dispatching = false;
380
- }
381
- /**
382
- * Accumulate per-body sleep timers and put/keep islands of resting bodies
383
- * asleep so a stack sleeps and wakes as one unit. An island is a connected
384
- * component of dynamic bodies joined by touching solid contacts (static and
385
- * kinematic bodies are boundaries, not nodes); it sleeps once every member has
386
- * stayed below the sleep thresholds for `timeToSleep`, and wakes the instant
387
- * any member does (e.g. an awake body merges into it via a new contact).
388
- * Deterministic: union-find roots break ties by lower index and the contact
389
- * set is id-sorted.
390
- */
391
- _updateSleeping(dt) {
392
- const bodies = this._bodies;
393
- const count = bodies.length;
394
- const parent = this._islandParent;
395
- const minSleep = this._islandMinSleep;
396
- // Assign dense indices, reset the union-find, and accumulate sleep timers for
397
- // awake dynamic bodies (a sleeping body's timer stays frozen ≥ timeToSleep).
398
- for (let i = 0; i < count; i++) {
399
- const body = bodies[i];
400
- body._islandIndex = i;
401
- parent[i] = i;
402
- minSleep[i] = Infinity;
403
- if (body.type === 'dynamic' && !body.isSleeping) {
404
- body._accumulateSleepTime(dt, this.sleepLinearVelocity, this.sleepAngularVelocity);
405
- }
406
- }
407
- parent.length = count;
408
- minSleep.length = count;
409
- // Union dynamic↔dynamic solid contacts into islands.
410
- for (const contact of this._backend.contactGraph.solidContacts) {
411
- const bodyA = contact.a.body;
412
- const bodyB = contact.b.body;
413
- if (bodyA.type === 'dynamic' && bodyB.type === 'dynamic') {
414
- this._union(bodyA._islandIndex, bodyB._islandIndex);
415
- }
416
- }
417
- // Joints couple their two bodies into the same island (sleep/wake together).
418
- for (const joint of this._joints) {
419
- const bodyA = joint.bodyA;
420
- const bodyB = joint.bodyB;
421
- if (joint.enabled && bodyA.type === 'dynamic' && bodyB.type === 'dynamic') {
422
- this._union(bodyA._islandIndex, bodyB._islandIndex);
423
- }
424
- }
425
- // Per-island minimum sleep time over its dynamic members.
426
- for (let i = 0; i < count; i++) {
427
- const body = bodies[i];
428
- if (body.type === 'dynamic') {
429
- const root = this._find(i);
430
- if (body._sleepTime < minSleep[root]) {
431
- minSleep[root] = body._sleepTime;
432
- }
433
- }
434
- }
435
- // Sleep an island iff every member has rested for `timeToSleep`; otherwise
436
- // wake it (which also wakes any member dragged awake by a fresh contact).
437
- const timeToSleep = this.timeToSleep;
438
- for (let i = 0; i < count; i++) {
439
- const body = bodies[i];
440
- if (body.type === 'dynamic') {
441
- body._setSleeping(minSleep[this._find(i)] >= timeToSleep);
442
- }
443
- }
444
- }
445
- /** Union-find union by lower index (deterministic roots). */
446
- _union(a, b) {
447
- const rootA = this._find(a);
448
- const rootB = this._find(b);
449
- if (rootA < rootB) {
450
- this._islandParent[rootB] = rootA;
451
- }
452
- else if (rootB < rootA) {
453
- this._islandParent[rootA] = rootB;
454
- }
455
- }
456
- /** Union-find find with path halving. */
457
- _find(index) {
458
- const parent = this._islandParent;
459
- while (parent[index] !== index) {
460
- const grandparent = parent[parent[index]];
461
- parent[index] = grandparent;
462
- index = grandparent;
463
- }
464
- return index;
465
- }
466
- /** Build each joint's per-frame constraint data (once per fixed step). */
467
- _prepareJoints(h) {
468
- for (const joint of this._joints) {
469
- joint._prepare(h);
470
- }
471
- }
472
- /** Re-apply each joint's accumulated impulse (each sub-step). */
473
- _warmStartJoints() {
474
- for (const joint of this._joints) {
475
- joint._warmStart();
476
- }
477
- }
478
- /** One joint velocity pass (each sub-step, after the contacts). */
479
- _solveJoints(useBias) {
480
- for (const joint of this._joints) {
481
- joint._solve(useBias);
482
- }
483
- }
484
- /** Whether any dynamic body is flagged for continuous collision (bullet mode). */
485
- _hasBullets() {
486
- for (const body of this._bodies) {
487
- if (body.isBullet && body.type === 'dynamic') {
488
- return true;
489
- }
490
- }
491
- return false;
492
- }
493
- /** Snapshot each bullet's centre of mass at the start of the fixed step (the swept-test origin). */
494
- _recordBulletPositions() {
495
- for (const body of this._bodies) {
496
- if (body.isBullet && body.type === 'dynamic') {
497
- body._ccdPrevX = body.worldCenterOfMassX;
498
- body._ccdPrevY = body.worldCenterOfMassY;
499
- }
500
- }
501
- }
502
- /**
503
- * Sweep each bullet's centre of mass along this fixed step's motion against every
504
- * other body's colliders; if it would cross one, clamp the body just short of the
505
- * surface and resolve the impact about the surface normal (a slide for a non-bouncy
506
- * body, an elastic reflection as restitution → 1) so it cannot tunnel. Sweeps the
507
- * centre point — good for small/point-like projectiles; a full swept-shape TOI for
508
- * large fast bodies is backlog (raise sub-steps or thicken geometry meanwhile).
509
- */
510
- _advanceBullets() {
511
- for (const body of this._bodies) {
512
- if (!body.isBullet || body.type !== 'dynamic' || body.isSleeping) {
513
- continue;
514
- }
515
- const newX = body.worldCenterOfMassX;
516
- const newY = body.worldCenterOfMassY;
517
- let dirX = newX - body._ccdPrevX;
518
- let dirY = newY - body._ccdPrevY;
519
- const distance = Math.hypot(dirX, dirY);
520
- if (distance < 1e-6) {
521
- continue;
522
- }
523
- dirX /= distance;
524
- dirY /= distance;
525
- this._ccdOrigin.x = body._ccdPrevX;
526
- this._ccdOrigin.y = body._ccdPrevY;
527
- this._ccdDir.x = dirX;
528
- this._ccdDir.y = dirY;
529
- const hits = this._query.rayCastAll(this._ccdOrigin, this._ccdDir, undefined, this._ccdHits, distance);
530
- let blocked = null;
531
- for (const hit of hits) {
532
- // Sweep against every other body (static, kinematic, dynamic); sensors never
533
- // block. Hits are distance-sorted, so the first match is the nearest surface.
534
- if (hit.body !== body && !hit.collider.isSensor) {
535
- blocked = hit;
536
- break;
537
- }
538
- }
539
- if (blocked === null) {
540
- continue;
541
- }
542
- // Clamp the CoM just short of the surface (a pure translation — the rotation
543
- // is already applied), then resolve the impact about the surface normal.
544
- const clampDistance = Math.max(0, blocked.distance - ccdSkin);
545
- const deltaX = body._ccdPrevX + dirX * clampDistance - newX;
546
- const deltaY = body._ccdPrevY + dirY * clampDistance - newY;
547
- this._ccdOrigin.x = body.x + deltaX;
548
- this._ccdOrigin.y = body.y + deltaY;
549
- body.setTransform(this._ccdOrigin, body.angle);
550
- // Reflect about the true surface normal: a slide for a non-bouncy body
551
- // (restitution 0), an elastic bounce as restitution → 1. The body's own
552
- // restitution combines (max) with the surface's, matching the contact solver.
553
- const nx = blocked.normal.x;
554
- const ny = blocked.normal.y;
555
- const vn = body.linearVelocityX * nx + body.linearVelocityY * ny;
556
- if (vn < 0) {
557
- const restitution = vn < -1 ? Math.max(this._bulletRestitution(body), blocked.collider.restitution) : 0;
558
- const impulse = -(1 + restitution) * vn;
559
- body.linearVelocityX += impulse * nx;
560
- body.linearVelocityY += impulse * ny;
561
- }
562
- }
563
- }
564
- /** The highest restitution among a body's colliders (its CCD bounce factor). */
565
- _bulletRestitution(body) {
566
- let restitution = 0;
567
- for (const collider of body.colliders) {
568
- if (collider.restitution > restitution) {
569
- restitution = collider.restitution;
570
- }
571
- }
572
- return restitution;
573
- }
574
- /** Run `command` now, or queue it when inside an event dispatch (deferred to end of step). */
575
- _defer(command) {
576
- if (this._dispatching) {
577
- this._commands.push(command);
578
- }
579
- else {
580
- command();
581
- }
582
- }
583
- _drainCommands() {
584
- if (this._commands.length === 0) {
585
- return;
586
- }
587
- const commands = this._commands.splice(0, this._commands.length);
588
- for (const command of commands) {
589
- command();
590
- }
591
- }
592
- _removeBody(body) {
593
- const index = this._bodies.indexOf(body);
594
- if (index === -1) {
595
- // Never added (created and destroyed within the same dispatch) — still
596
- // tear down its colliders and mark it dead.
597
- this._teardownBody(body);
598
- return;
599
- }
600
- this._bodies.splice(index, 1);
601
- this._teardownBody(body);
602
- }
603
- _teardownBody(body) {
604
- for (const collider of body.colliders) {
605
- this._detachCollider(collider);
606
- }
607
- this._bindings.unbind(body);
608
- body._markDestroyed();
609
- }
610
- _removeCollider(collider) {
611
- this._detachCollider(collider);
612
- collider.body._removeCollider(collider);
613
- }
614
- _detachCollider(collider) {
615
- const index = this._colliders.indexOf(collider);
616
- if (index !== -1) {
617
- this._colliders.splice(index, 1);
618
- }
619
- this._backend.removeCollider(collider);
620
- collider._markDestroyed();
621
- }
622
- _assertAlive() {
623
- if (this._destroyed) {
624
- throw new Error('PhysicsWorld: the world has been destroyed.');
625
- }
626
- }
627
- }
15
+ * Overlap left after clamping a bullet at its time of impact (the clamp
16
+ * overshoots the contact by this much along the motion), so the next step's
17
+ * discrete detection forms a real contact and the solver owns resting,
18
+ * friction and events from then on.
19
+ */
20
+ const ccdEmbed = .05;
21
+ /**
22
+ * Keep a host-supplied blend factor inside `[0, 1]`. A caller's own accumulator
23
+ * can hand over a value outside it (a stale read, a custom scheduler), and an
24
+ * unclamped one extrapolates the node past the simulated state instead of
25
+ * blending between two known ones.
26
+ */
27
+ const clampAlpha = (alpha) => {
28
+ if (!(alpha > 0)) return 0;
29
+ return alpha < 1 ? alpha : 1;
30
+ };
31
+ /**
32
+ * Reject removing the last collider a dynamic body's mass rests on while it still
33
+ * holds others. Such a body keeps colliding but has `invMass === 0`, which the
34
+ * solver cannot tell apart from a static body - the same state `PhysicsBody`
35
+ * refuses to be constructed in.
36
+ *
37
+ * Removing the body's *only* collider is fine: a body with no geometry at all is
38
+ * a body still being assembled, not a silently broken one.
39
+ */
40
+ const assertBodyKeepsItsMass = (collider) => {
41
+ const body = collider.body;
42
+ if (body.type !== "dynamic" || collider.shape.massProperties === null || collider.density <= 0) return;
43
+ let remaining = 0;
44
+ for (const other of body.colliders) {
45
+ if (other === collider) continue;
46
+ if (other.shape.massProperties !== null && other.density > 0) return;
47
+ remaining++;
48
+ }
49
+ if (remaining === 0) return;
50
+ throw new Error("PhysicsWorld.destroyCollider: this is the only collider carrying mass for a dynamic body — removing it would leave the body colliding but massless. Destroy the body instead, or give it another solid collider first.");
51
+ };
52
+ /**
53
+ * `true` when a static/kinematic body - an island boundary, never an island
54
+ * member - is being driven this step, either through its velocity or by a
55
+ * {@link PhysicsBody.setTransform} teleport. Speed is deliberately not compared
56
+ * against the sleep thresholds: a platform creeping along at 1 px/s still has to
57
+ * push its passengers, and it is exactly that sub-threshold case where nothing
58
+ * else would keep them awake.
59
+ */
60
+ const isMovingBoundary = (body) => body._teleported || body.linearVelocityX !== 0 || body.linearVelocityY !== 0 || body.angularVelocity !== 0;
61
+ /**
62
+ * `true` while a contact still carries more overlap than the solver leaves
63
+ * behind at rest. Read from the manifold detection just produced, which is the
64
+ * same separation the solver's push-out bias is about to act on - the sleep gate
65
+ * and the constraint it gates therefore agree by construction.
66
+ */
67
+ const isUnresolved = (manifold) => {
68
+ for (let i = 0; i < manifold.pointCount; i++) if ((i === 0 ? manifold.points[0] : manifold.points[1]).penetration > .75) return true;
69
+ return false;
70
+ };
71
+ /**
72
+ * {@link PhysicsWorld.attach}'s default `position`: `node`'s current WORLD
73
+ * translation, duck-typed the same way `AudioListener` reads a follow target -
74
+ * real {@link SceneNode}s expose `getWorldTransform()`, test doubles that omit
75
+ * it fall back to `(0, 0)` (the previous, surprising default).
76
+ */
77
+ const worldPositionOf = (node) => {
78
+ const asNode = node;
79
+ if (typeof asNode.getWorldTransform === "function") {
80
+ const world = asNode.getWorldTransform();
81
+ return {
82
+ x: world.x,
83
+ y: world.y
84
+ };
85
+ }
86
+ return {
87
+ x: 0,
88
+ y: 0
89
+ };
90
+ };
91
+ /**
92
+ * {@link PhysicsWorld.attach}'s default `angle` (radians): `node`'s current
93
+ * WORLD rotation, decomposed from `getWorldTransform()`'s linear part
94
+ * (`atan2(-c, a)` - `SceneNode.updateTransform` builds its rotation block as
95
+ * `[[cosθ, sinθ], [-sinθ, cosθ]]`, i.e. `b = sinθ`/`c = -sinθ`, matching the
96
+ * radians degrees round-trip `PhysicsBinding.sync` already relies on).
97
+ * Falls back to `0` for a duck-typed node without `getWorldTransform`.
98
+ */
99
+ const worldAngleOf = (node) => {
100
+ const asNode = node;
101
+ if (typeof asNode.getWorldTransform === "function") {
102
+ const world = asNode.getWorldTransform();
103
+ return Math.atan2(-world.c, world.a);
104
+ }
105
+ return 0;
106
+ };
107
+ /**
108
+ * The collision/query world: owns bodies, colliders, the detection backend,
109
+ * bindings, the query engine and the fixed-step accumulator. Stepped by the
110
+ * caller (commonly from a `Scene.update`), each fixed sub-step it integrates
111
+ * body velocities, runs broad- and narrow-phase detection, solves contacts and
112
+ * integrates positions, then fires immutable contact/sensor events and writes
113
+ * bound node transforms. It holds **no module-level state**, so any number of
114
+ * worlds run in isolation.
115
+ *
116
+ * The dynamics are a native, warm-started **TGS-Soft** solver (Box2D-v3 "soft
117
+ * step"): each fixed step runs detection once, then several sub-steps, each
118
+ * integrating gravity over the sub-step and solving contacts with a soft
119
+ * position bias plus a bias-free relax pass; a 2-point block normal solve
120
+ * propagates stack loads, and restitution is a separate final pass. Decoupling
121
+ * stiffness from the iteration count keeps tall towers stable. The detection
122
+ * backend sits behind an internal seam, so the solver is swappable without
123
+ * touching this public surface.
124
+ *
125
+ * **Operating envelope.** The soft solver trades a little accuracy for
126
+ * robustness, so it has a few documented limits - each stays finite and stable,
127
+ * and each is pinned by a gate in `dynamics.test.ts` or
128
+ * `contact-push-out.test.ts`:
129
+ * - **Resting contacts** settle within a small, fixed tolerance. A face contact
130
+ * settles at exactly that tolerance; a single-point contact settles slightly
131
+ * deeper, and that gap grows with acceleration. Very high accelerations
132
+ * eventually exceed what the push-out can resolve within a step, at which
133
+ * point the contact holds a slightly deeper resting depth instead of the
134
+ * tolerance - keep gravity in the range ordinary 2D games use.
135
+ * - **Mass ratio** - contacts between bodies of very different mass degrade
136
+ * gradually rather than at a fixed ratio. What sets the practical limit is how
137
+ * thick the supporting geometry is relative to the lighter body, not the ratio
138
+ * alone: a light body squeezed against a boundary thinner than itself is the
139
+ * case that fails first, and it fails by sinking through.
140
+ * - **Scale-relative behaviour** - the solver's tolerances are absolute lengths,
141
+ * so a very small shape sees them as a large fraction of itself, and a very
142
+ * large one as a negligible one. Keep the shapes of one world within a couple
143
+ * of orders of magnitude of each other.
144
+ * - **CCD is opt-in and translation-only** - detection runs once per fixed
145
+ * step, so an ordinary body that crosses more than roughly half a barrier's
146
+ * total thickness within one step may end up on the wrong side of it, either
147
+ * passing through or being resolved out of the far face. Flag fast
148
+ * projectiles with {@link PhysicsBody.isBullet}: each of the body's colliders
149
+ * is then shape-cast along the step's motion (an exact translation-only sweep
150
+ * of the full shape, not just the centre) and clamped at the first impact.
151
+ * Rotation over the step is not swept - a long body spinning fast enough to
152
+ * sweep past an obstacle within one step can still miss it.
153
+ * - **{@link PhysicsWorldOptions.subStepCount}** - the default `4` is
154
+ * load-bearing for tall-stack stability; lowering it below `2` visibly
155
+ * degrades stacking, so do not reduce it for performance.
156
+ * - **Broad phase is a dynamic AABB tree** (`AabbTreeBroadPhase`), stateful
157
+ * across fixed steps: a collider whose tight AABB stays inside its stored
158
+ * fat AABB costs nothing to re-sync, and only colliders that actually move
159
+ * outside their margin trigger a tree update and a local re-query for new
160
+ * neighbours. Detection still walks every live collider once per step (a
161
+ * cheap containment check for each), so there is a small linear floor, but
162
+ * the dominant cost - reinsertion and neighbour discovery - is driven by
163
+ * how much actually moved, not by the total live collider count; sleeping
164
+ * bodies skip that dominant cost entirely. Scales to tens of thousands of
165
+ * simultaneously-live colliders, including dense clusters that would
166
+ * degrade a sort-and-sweep broad phase; very large or highly dynamic
167
+ * worlds may still benefit from splitting into several smaller
168
+ * `PhysicsWorld` instances (e.g. per room/chunk).
169
+ */
170
+ var PhysicsWorld = class {
171
+ /** Fires when two solid colliders begin touching. Argument is an immutable snapshot. */
172
+ onCollisionStart = new Signal();
173
+ /** Fires when two solid colliders stop touching (or one is destroyed). */
174
+ onCollisionEnd = new Signal();
175
+ /** Fires when a collider enters a sensor. */
176
+ onSensorEnter = new Signal();
177
+ /** Fires when a collider leaves a sensor. */
178
+ onSensorExit = new Signal();
179
+ /** World gravity (px/s², +Y down). Integrated each sub-step. */
180
+ gravity;
181
+ /** The fixed-step accumulator. */
182
+ timeStepper;
183
+ /** TGS-Soft sub-steps per fixed step. */
184
+ subStepCount;
185
+ /** Soft-contact stiffness in Hz. */
186
+ contactHertz;
187
+ /** Soft-contact damping ratio. */
188
+ dampingRatio;
189
+ /** Whether resting bodies are put to sleep. */
190
+ enableSleeping;
191
+ /** Linear sleep threshold (px/s). */
192
+ sleepLinearVelocity;
193
+ /** Angular sleep threshold (rad/s). */
194
+ sleepAngularVelocity;
195
+ /** Seconds below the thresholds before a body sleeps. */
196
+ timeToSleep;
197
+ /**
198
+ * Runs once per solid contact per fixed step, after contact generation and
199
+ * before island building and the solver, so a contact it disables applies no
200
+ * impulse and does not couple its two bodies into one sleeping island.
201
+ *
202
+ * At most one modifier per world - it mutates simulation state, so a
203
+ * multi-listener signal would make the outcome depend on registration order.
204
+ * Set to `null` to remove it; the per-contact values then stay at the
205
+ * defaults derived from the two colliders.
206
+ */
207
+ contactModifier;
208
+ /**
209
+ * Whether bound nodes are placed between the last two fixed states rather than
210
+ * snapped to the latest one. Toggleable at runtime; switching it off snaps
211
+ * every bound node back to the current fixed state on the next sync.
212
+ */
213
+ interpolation;
214
+ /**
215
+ * Supplies the `[0, 1)` blend factor for interpolated bindings. Defaults to
216
+ * this world's own accumulator; point it at the host's own fraction
217
+ * (`() => app.frameAlpha`) when the world runs as a `System`, because
218
+ * {@link fixedUpdate} never advances the local accumulator.
219
+ */
220
+ frameAlphaSource;
221
+ _backend = new NativePhysicsBackend();
222
+ _bodies = [];
223
+ _colliders = [];
224
+ /**
225
+ * What detection actually runs over: the authored colliders, with a chain
226
+ * replaced by the edge proxies it fans out into. The broad phase, the narrow
227
+ * phase and the queries all read this list; `_colliders` stays the authored
228
+ * set every public surface reports.
229
+ */
230
+ _detectionColliders = [];
231
+ /** Pooled scratch for re-syncing a single body's broad-phase leaves. */
232
+ _leafScratch = [];
233
+ _joints = [];
234
+ _bindings = new BindingRegistry();
235
+ _query;
236
+ _commands = [];
237
+ /** Pooled union-find parent array for the per-step island pass (reused; sized to the body count). */
238
+ _islandParent = [];
239
+ /** Pooled per-island minimum sleep time, indexed by union-find root. */
240
+ _islandMinSleep = [];
241
+ /** Pooled scratch for the CCD pass (clamp target, per-pair sweep hit, best hit, swept AABB) - keeps the sweep allocation-free. */
242
+ _ccdClampPosition = {
243
+ x: 0,
244
+ y: 0
245
+ };
246
+ _ccdSweepHit = {
247
+ t: 0,
248
+ normalX: 0,
249
+ normalY: 0
250
+ };
251
+ _ccdBestHit = {
252
+ t: 0,
253
+ normalX: 0,
254
+ normalY: 0
255
+ };
256
+ _ccdSweptAabb = createAabb();
257
+ /** Pooled result buffer for the CCD pass's broad-phase query (refilled per bullet collider). */
258
+ _ccdCandidates = [];
259
+ /**
260
+ * Narrow-phase sweep tests run by the CCD pass since world creation. Pairs
261
+ * rejected by the swept-AABB prune never count, so slow bullets far from any
262
+ * geometry keep this at zero (the cheap path).
263
+ *
264
+ * @internal - test/diagnostic hook.
265
+ */
266
+ _ccdSweepTests = 0;
267
+ /**
268
+ * Colliders the CCD pass pulled out of the broad phase since world creation -
269
+ * the size of the candidate set the swept-AABB prune and the narrow-phase
270
+ * sweep then work on. Counted separately from {@link _ccdSweepTests} because
271
+ * the two measure different halves: this is what the pass looks at, that is
272
+ * what it actually sweeps. Stays proportional to the geometry near the
273
+ * bullets, not to the world's collider count.
274
+ *
275
+ * @internal - test/diagnostic hook.
276
+ */
277
+ _ccdBroadPhaseCandidates = 0;
278
+ _nextBodyId = 1;
279
+ _nextColliderId = 1;
280
+ _dispatching = false;
281
+ _destroyed = false;
282
+ constructor(options = {}) {
283
+ this.gravity = new Vector(options.gravity?.x ?? 0, options.gravity?.y ?? 0);
284
+ this.timeStepper = new TimeStepper({
285
+ ...options.fixedDelta !== void 0 && { fixedDelta: options.fixedDelta },
286
+ ...options.maxSubSteps !== void 0 && { maxSubSteps: options.maxSubSteps }
287
+ });
288
+ const subStepCount = options.subStepCount ?? 4;
289
+ if (!Number.isInteger(subStepCount) || subStepCount < 1) throw new RangeError(`PhysicsWorld: subStepCount must be an integer ≥ 1, received ${subStepCount}.`);
290
+ this.subStepCount = subStepCount;
291
+ this.contactHertz = options.contactHertz ?? 30;
292
+ this.dampingRatio = options.dampingRatio ?? 10;
293
+ this.enableSleeping = options.enableSleeping ?? true;
294
+ this.sleepLinearVelocity = options.sleepLinearVelocity ?? 5;
295
+ this.sleepAngularVelocity = options.sleepAngularVelocity ?? .06;
296
+ this.timeToSleep = options.timeToSleep ?? .5;
297
+ this.contactModifier = options.contactModifier ?? null;
298
+ this.interpolation = options.interpolation ?? false;
299
+ this.frameAlphaSource = options.frameAlphaSource ?? (() => this.timeStepper.alpha);
300
+ this._query = new QueryEngine(this._detectionColliders, this._backend.spatialIndex);
301
+ }
302
+ /** Live bodies (read-only view). */
303
+ get bodies() {
304
+ return this._bodies;
305
+ }
306
+ /** Live colliders (read-only view). */
307
+ get colliders() {
308
+ return this._colliders;
309
+ }
310
+ /**
311
+ * @internal - the geometry the broad phase and the narrow phase actually hold:
312
+ * every authored collider, with a chain replaced by its per-edge proxies.
313
+ * Consumed by the debug draw layer, which visualises what the solver sees;
314
+ * public identity stays {@link colliders}.
315
+ */
316
+ get detectionGeometry() {
317
+ return this._detectionColliders;
318
+ }
319
+ /**
320
+ * Add a body to the world: allocates the body and its collider ids, registers
321
+ * the colliders, computes the mass model and tracks the body for stepping.
322
+ * Construct the body freely first (`new PhysicsBody({ ... })`), then add it.
323
+ * Safe to call inside an event callback - the body push is deferred to the end
324
+ * of the step, exactly like collider registration. Returns the body.
325
+ *
326
+ * @throws if the body has already been added to a world.
327
+ */
328
+ add(body) {
329
+ this._assertAlive();
330
+ if (body.attached) throw new Error("PhysicsWorld.add: this body has already been added to a world.");
331
+ body._attachToWorld(this, this._nextBodyId++);
332
+ this._defer(() => {
333
+ if (!body.destroyed) this._bodies.push(body);
334
+ });
335
+ return body;
336
+ }
337
+ /**
338
+ * Convenience: create a body carrying a single collider, add it to the world
339
+ * and bind it to `node` in one call. The node tracks `body.position` after each
340
+ * step. Returns the body. Equivalent to `new PhysicsBody(...)` + `add` + `bind`.
341
+ *
342
+ * When `options.position`/`options.angle` are omitted, the body starts at
343
+ * `node`'s current WORLD position/rotation (via `getWorldTransform()`,
344
+ * composed through any transform-group boundary) rather than `(0, 0)` -
345
+ * otherwise a body attached to an already-placed node would visibly "teleport"
346
+ * to the origin on the next step. Pass `position`/`angle` explicitly to override.
347
+ */
348
+ attach(node, options) {
349
+ const body = new PhysicsBody({
350
+ ...options.type !== void 0 && { type: options.type },
351
+ position: options.position ?? worldPositionOf(node),
352
+ angle: options.angle ?? worldAngleOf(node),
353
+ ...options.gravityScale !== void 0 && { gravityScale: options.gravityScale },
354
+ ...options.fixedRotation !== void 0 && { fixedRotation: options.fixedRotation },
355
+ colliders: [new Collider({
356
+ shape: options.shape,
357
+ ...options.offset !== void 0 && { offset: options.offset },
358
+ ...options.rotation !== void 0 && { rotation: options.rotation },
359
+ ...options.density !== void 0 && { density: options.density },
360
+ ...options.friction !== void 0 && { friction: options.friction },
361
+ ...options.restitution !== void 0 && { restitution: options.restitution },
362
+ ...options.isSensor !== void 0 && { isSensor: options.isSensor },
363
+ ...options.filter !== void 0 && { filter: options.filter }
364
+ })]
365
+ });
366
+ this.add(body);
367
+ this.bind(body, node);
368
+ return body;
369
+ }
370
+ /**
371
+ * Destroy a body and its colliders. Every dynamic body touching it is woken,
372
+ * so anything that was resting on the destroyed body falls once its support is
373
+ * gone. Deferred when called inside a callback.
374
+ */
375
+ destroyBody(body) {
376
+ this._defer(() => this._removeBody(body));
377
+ }
378
+ /**
379
+ * Destroy a single collider, recomputing its body's mass. Wakes every dynamic
380
+ * body touching it, the same way {@link destroyBody} does. Deferred when
381
+ * called inside a callback.
382
+ */
383
+ destroyCollider(collider) {
384
+ assertBodyKeepsItsMass(collider);
385
+ this._defer(() => this._removeCollider(collider));
386
+ }
387
+ /** Live joints (read-only view). */
388
+ get joints() {
389
+ return this._joints;
390
+ }
391
+ /**
392
+ * Add a constraint joint. Construct it first (`new DistanceJoint({ ... })`),
393
+ * then add it. Wakes both bodies; safe inside a callback (registration is
394
+ * deferred). Returns the joint.
395
+ */
396
+ addJoint(joint) {
397
+ this._assertAlive();
398
+ joint.bodyA.wake();
399
+ joint.bodyB.wake();
400
+ this._defer(() => {
401
+ if (!this._joints.includes(joint)) this._joints.push(joint);
402
+ });
403
+ return joint;
404
+ }
405
+ /** Remove a joint, waking both bodies so they respond to the lost constraint. Deferred when called inside a callback. */
406
+ removeJoint(joint) {
407
+ joint.bodyA.wake();
408
+ joint.bodyB.wake();
409
+ this._defer(() => {
410
+ const index = this._joints.indexOf(joint);
411
+ if (index !== -1) this._joints.splice(index, 1);
412
+ });
413
+ }
414
+ /**
415
+ * Advance the world by `frameDeltaSeconds`. Accumulates into fixed steps; each
416
+ * fixed step runs detection once, then a TGS-Soft sub-step loop (integrate
417
+ * gravity, solve contacts with a soft bias, integrate positions, relax) and a
418
+ * restitution pass, then writes the accumulated motion into each body. Finally
419
+ * dispatches events and writes bound node transforms.
420
+ *
421
+ * **Prefer registering the world as a system instead**
422
+ * (`app.systems.add(world, { order: SystemOrder.Physics })` or the scene
423
+ * equivalent) so {@link fixedUpdate} drives stepping directly from the
424
+ * engine's own fixed-timestep scheduler - one call per fixed step, no
425
+ * accumulator duplication. `step` remains available here for manual/advanced
426
+ * driving (e.g. a fixed-but-non-standard rate, or stepping outside the
427
+ * normal frame loop entirely): pass any delta and this accumulator converts
428
+ * it into the right number of fixed sub-steps (0, 1, or several, clamped by
429
+ * {@link PhysicsWorldOptions.maxSubSteps}).
430
+ *
431
+ * Either way the simulation is frame-rate independent and deterministic -
432
+ * the same sequence of deltas replays identically. `timeStepper.alpha`
433
+ * (`[0, 1)`) is the leftover sub-step fraction after this call, for callers
434
+ * that want to interpolate a bound node's rendered position between the last
435
+ * two fixed states instead of snapping to the latest one (bindings do not do
436
+ * this automatically - {@link bind} always writes the latest fixed-step
437
+ * transform verbatim).
438
+ */
439
+ step(frameDeltaSeconds) {
440
+ this._assertAlive();
441
+ const steps = this.timeStepper.advance(frameDeltaSeconds);
442
+ if (steps > 0) {
443
+ const subStepCount = this.subStepCount;
444
+ const h = this.timeStepper.fixedDelta / subStepCount;
445
+ const gravityX = this.gravity.x;
446
+ const gravityY = this.gravity.y;
447
+ const contactHertz = this.contactHertz;
448
+ const dampingRatio = this.dampingRatio;
449
+ const hasJoints = this._joints.length > 0;
450
+ const hasBullets = this._hasBullets();
451
+ for (let step = 0; step < steps; step++) this._stepOnce(h, subStepCount, gravityX, gravityY, contactHertz, dampingRatio, hasJoints, hasBullets);
452
+ this._dispatchEvents();
453
+ }
454
+ this._syncBindings();
455
+ this._drainCommands();
456
+ }
457
+ /**
458
+ * Advance by exactly one fixed step, driven directly by the engine's own
459
+ * fixed-timestep scheduler when this world is registered as a `System`
460
+ * (`app.systems.add(world, { order: SystemOrder.Physics })` or the scene
461
+ * equivalent) - bypasses {@link timeStepper}'s variable-delta accumulator
462
+ * entirely, since the caller has already decided exactly when a fixed step
463
+ * occurs. Prefer this over manual {@link step} once the world is
464
+ * system-registered; `step` remains available for advanced manual driving.
465
+ * Always advances by exactly {@link timeStepper}'s configured `fixedDelta`,
466
+ * regardless of the caller's actual fixed-step interval - if the engine's
467
+ * `Application.fixedTimeStep` doesn't match this world's `fixedDelta`, the
468
+ * simulation stays deterministic but runs at the wrong wall-clock speed
469
+ * relative to real time.
470
+ */
471
+ fixedUpdate(_step) {
472
+ this._assertAlive();
473
+ const subStepCount = this.subStepCount;
474
+ const h = this.timeStepper.fixedDelta / subStepCount;
475
+ this._stepOnce(h, subStepCount, this.gravity.x, this.gravity.y, this.contactHertz, this.dampingRatio, this._joints.length > 0, this._hasBullets());
476
+ this._dispatchEvents();
477
+ if (!this.interpolation) this._bindings.sync();
478
+ this._drainCommands();
479
+ }
480
+ /**
481
+ * Variable-rate `System` phase: places bound nodes for the frame that is about
482
+ * to be drawn. Only does work while {@link interpolation} is on - otherwise
483
+ * {@link fixedUpdate} has already snapped them to the latest fixed state.
484
+ */
485
+ update(_delta) {
486
+ this._assertAlive();
487
+ if (this.interpolation) this._bindings.syncInterpolated(clampAlpha(this.frameAlphaSource()));
488
+ }
489
+ /** Present bound nodes, snapping or interpolating according to {@link interpolation}. */
490
+ _syncBindings() {
491
+ if (this.interpolation) this._bindings.syncInterpolated(clampAlpha(this.frameAlphaSource()));
492
+ else this._bindings.sync();
493
+ }
494
+ _stepOnce(h, subStepCount, gravityX, gravityY, contactHertz, dampingRatio, hasJoints, hasBullets) {
495
+ this._backend.detect(this._detectionColliders);
496
+ if (this.contactModifier !== null) this._backend.applyContactModifier(this.contactModifier);
497
+ if (this.enableSleeping) this._updateSleeping(this.timeStepper.fixedDelta);
498
+ this._backend.prepareSolve(h, contactHertz, dampingRatio);
499
+ if (hasJoints) this._prepareJoints(h);
500
+ if (hasBullets) this._recordBulletPositions();
501
+ for (let subStep = 0; subStep < subStepCount; subStep++) {
502
+ for (const body of this._bodies) body._integrateVelocity(h, gravityX, gravityY);
503
+ this._backend.warmStart();
504
+ if (hasJoints) this._warmStartJoints();
505
+ this._backend.solveVelocities(true);
506
+ if (hasJoints) this._solveJoints(true);
507
+ for (const body of this._bodies) body._integratePosition(h);
508
+ this._backend.solveVelocities(false);
509
+ if (hasJoints) this._solveJoints(false);
510
+ }
511
+ this._backend.applyRestitution();
512
+ for (const body of this._bodies) body._finalizePosition();
513
+ if (hasBullets) this._advanceBullets();
514
+ }
515
+ /**
516
+ * Link a body to a scene node; the node tracks the body after each step.
517
+ * Destroying the node is enough to end the link - the next step drops it
518
+ * rather than writing into the node's released transform, so an explicit
519
+ * {@link unbind} is only needed to stop tracking a node that stays alive.
520
+ *
521
+ * @throws if `node` is already destroyed.
522
+ */
523
+ bind(body, node) {
524
+ return this._bindings.bind(body, node);
525
+ }
526
+ /** Remove a body↔node link. */
527
+ unbind(body) {
528
+ this._bindings.unbind(body);
529
+ }
530
+ /** Colliders containing `point`. Fresh array. */
531
+ queryPoint(point, filter) {
532
+ return this._query.queryPoint(point, filter);
533
+ }
534
+ /** Colliders whose AABB overlaps `bounds`. Writes into `out` (cleared) if given. */
535
+ queryAabb(bounds, filter, out) {
536
+ return this._query.queryAabb(bounds, filter, out);
537
+ }
538
+ /** Invoke `callback` for each collider whose AABB overlaps `bounds`. Allocation-free. */
539
+ forEachAabbHit(bounds, filter, callback) {
540
+ this._query.forEachAabbHit(bounds, filter, callback);
541
+ }
542
+ /** Nearest collider hit by the ray, or `null`. */
543
+ rayCast(origin, direction, filter, maxDistance) {
544
+ return this._query.rayCast(origin, direction, filter, maxDistance);
545
+ }
546
+ /** All collider hits along the ray, sorted by distance. Writes into `out` (cleared) if given. */
547
+ rayCastAll(origin, direction, filter, out, maxDistance) {
548
+ return this._query.rayCastAll(origin, direction, filter, out, maxDistance);
549
+ }
550
+ /** Colliders overlapping `shape` placed at `position`/`angle`. Fresh array. */
551
+ overlapShape(shape, position, filter, angle) {
552
+ return this._query.overlapShape(shape, position, filter, angle);
553
+ }
554
+ /** Release every body, collider, binding and backend resource. */
555
+ destroy() {
556
+ if (this._destroyed) return;
557
+ this._destroyed = true;
558
+ for (const body of this._bodies) {
559
+ for (const collider of body.colliders) collider._markDestroyed();
560
+ body._markDestroyed();
561
+ }
562
+ this._bodies.length = 0;
563
+ this._colliders.length = 0;
564
+ this._detectionColliders.length = 0;
565
+ this._joints.length = 0;
566
+ this._commands.length = 0;
567
+ this._bindings.clear();
568
+ this._backend.destroy();
569
+ this.onCollisionStart.destroy();
570
+ this.onCollisionEnd.destroy();
571
+ this.onSensorEnter.destroy();
572
+ this.onSensorExit.destroy();
573
+ }
574
+ _allocateColliderId() {
575
+ return this._nextColliderId++;
576
+ }
577
+ _registerCollider(collider) {
578
+ this._defer(() => {
579
+ if (collider.destroyed) return;
580
+ this._colliders.push(collider);
581
+ const edges = collider.chainEdges;
582
+ if (edges === null) {
583
+ this._detectionColliders.push(collider);
584
+ return;
585
+ }
586
+ for (const edge of edges) this._detectionColliders.push(edge);
587
+ });
588
+ }
589
+ /** The detection backend (internal; consumed by the debug draw layer). */
590
+ get backend() {
591
+ return this._backend;
592
+ }
593
+ _dispatchEvents() {
594
+ const graph = this._backend.contactGraph;
595
+ this._dispatching = true;
596
+ for (const event of graph.collisionEnd) this.onCollisionEnd.dispatch(event);
597
+ for (const event of graph.sensorExit) this.onSensorExit.dispatch(event);
598
+ for (const event of graph.collisionStart) this.onCollisionStart.dispatch(event);
599
+ for (const event of graph.sensorEnter) this.onSensorEnter.dispatch(event);
600
+ this._dispatching = false;
601
+ }
602
+ /**
603
+ * Accumulate per-body sleep timers and put/keep islands of resting bodies
604
+ * asleep so a stack sleeps and wakes as one unit. An island is a connected
605
+ * component of dynamic bodies joined by touching solid contacts (static and
606
+ * kinematic bodies are boundaries, not nodes); it sleeps once every member has
607
+ * stayed below the sleep thresholds for `timeToSleep`, and wakes the instant
608
+ * any member does (e.g. an awake body merges into it via a new contact).
609
+ * A boundary that is itself moving resets the sleep timer of every dynamic
610
+ * body it touches, so a platform keeps its passengers awake however slowly it
611
+ * travels; so does a contact still carrying more penetration than the solver
612
+ * leaves at rest, because the push-out that resolves it is slower than the
613
+ * sleep velocity threshold and a body would otherwise be frozen embedded.
614
+ * Deterministic: union-find roots break ties by lower index and the contact
615
+ * set is id-sorted.
616
+ */
617
+ _updateSleeping(dt) {
618
+ const bodies = this._bodies;
619
+ const count = bodies.length;
620
+ const parent = this._islandParent;
621
+ const minSleep = this._islandMinSleep;
622
+ for (let i = 0; i < count; i++) {
623
+ const body = bodies[i];
624
+ body._islandIndex = i;
625
+ parent[i] = i;
626
+ minSleep[i] = Infinity;
627
+ if (body.type === "dynamic" && !body.isSleeping) body._accumulateSleepTime(dt, this.sleepLinearVelocity, this.sleepAngularVelocity);
628
+ }
629
+ parent.length = count;
630
+ minSleep.length = count;
631
+ this._unionContactIslands();
632
+ for (const joint of this._joints) {
633
+ const bodyA = joint.bodyA;
634
+ const bodyB = joint.bodyB;
635
+ if (joint.enabled && bodyA.type === "dynamic" && bodyB.type === "dynamic") this._union(bodyA._islandIndex, bodyB._islandIndex);
636
+ }
637
+ for (let i = 0; i < count; i++) {
638
+ const body = bodies[i];
639
+ if (body.type === "dynamic") {
640
+ const root = this._find(i);
641
+ if (body._sleepTime < minSleep[root]) minSleep[root] = body._sleepTime;
642
+ }
643
+ }
644
+ const timeToSleep = this.timeToSleep;
645
+ for (let i = 0; i < count; i++) {
646
+ const body = bodies[i];
647
+ if (body.type === "dynamic") body._setSleeping(minSleep[this._find(i)] >= timeToSleep);
648
+ }
649
+ }
650
+ /**
651
+ * Union dynamic↔dynamic solid contacts into islands. A dynamic body touching a
652
+ * MOVING static/kinematic body has its sleep timer reset instead: those types
653
+ * are island boundaries, not members, so nothing else would keep the passenger
654
+ * of a slow-moving platform awake - and the solver skips a contact whose
655
+ * dynamic side is asleep, letting the platform drive straight through it.
656
+ *
657
+ * The same traversal resets the sleep timer of both sides of a contact whose
658
+ * penetration the solver has not worked off yet, so the island it belongs to
659
+ * stays awake until the overlap is within the tolerance the solver itself
660
+ * converges to.
661
+ */
662
+ _unionContactIslands() {
663
+ for (const contact of this._backend.contactGraph.solidContacts) {
664
+ if (!contact.enabled) continue;
665
+ const bodyA = contact.a.body;
666
+ const bodyB = contact.b.body;
667
+ const dynamicA = bodyA.type === "dynamic";
668
+ const dynamicB = bodyB.type === "dynamic";
669
+ if (dynamicA && dynamicB) this._union(bodyA._islandIndex, bodyB._islandIndex);
670
+ else if (dynamicA && isMovingBoundary(bodyB)) bodyA._sleepTime = 0;
671
+ else if (dynamicB && isMovingBoundary(bodyA)) bodyB._sleepTime = 0;
672
+ if ((dynamicA || dynamicB) && isUnresolved(contact.manifold)) {
673
+ if (dynamicA) bodyA._sleepTime = 0;
674
+ if (dynamicB) bodyB._sleepTime = 0;
675
+ }
676
+ }
677
+ }
678
+ /** Union-find union by lower index (deterministic roots). */
679
+ _union(a, b) {
680
+ const rootA = this._find(a);
681
+ const rootB = this._find(b);
682
+ if (rootA < rootB) this._islandParent[rootB] = rootA;
683
+ else if (rootB < rootA) this._islandParent[rootA] = rootB;
684
+ }
685
+ /** Union-find find with path halving. */
686
+ _find(index) {
687
+ const parent = this._islandParent;
688
+ while (parent[index] !== index) {
689
+ const grandparent = parent[parent[index]];
690
+ parent[index] = grandparent;
691
+ index = grandparent;
692
+ }
693
+ return index;
694
+ }
695
+ /** Build each joint's per-frame constraint data (once per fixed step). */
696
+ _prepareJoints(h) {
697
+ for (const joint of this._joints) joint._prepare(h);
698
+ }
699
+ /** Re-apply each joint's accumulated impulse (each sub-step). */
700
+ _warmStartJoints() {
701
+ for (const joint of this._joints) joint._warmStart();
702
+ }
703
+ /** One joint velocity pass (each sub-step, after the contacts). */
704
+ _solveJoints(useBias) {
705
+ for (const joint of this._joints) joint._solve(useBias);
706
+ }
707
+ /** Whether any dynamic body is flagged for continuous collision (bullet mode). */
708
+ _hasBullets() {
709
+ for (const body of this._bodies) if (body.isBullet && body.type === "dynamic") return true;
710
+ return false;
711
+ }
712
+ /** Snapshot each bullet's centre of mass at the start of the fixed step (the swept-test origin). */
713
+ _recordBulletPositions() {
714
+ for (const body of this._bodies) if (body.isBullet && body.type === "dynamic") {
715
+ body._ccdPrevX = body.worldCenterOfMassX;
716
+ body._ccdPrevY = body.worldCenterOfMassY;
717
+ }
718
+ }
719
+ /**
720
+ * Shape-cast each bullet's colliders along this fixed step's motion against
721
+ * every other body's colliders; if any would cross one, clamp the body at the
722
+ * earliest impact and resolve it about the surface normal (a slide for a
723
+ * non-bouncy body, an elastic reflection as restitution → 1) so it cannot
724
+ * tunnel. The sweep is an exact translation-only cast of the full shapes
725
+ * (Minkowski ray for circles, swept SAT for polygon pairs) - the step's
726
+ * rotation is applied before the sweep, not swept through. Pairs already
727
+ * overlapping at the start of the step are skipped: the discrete solver owns
728
+ * them (that hand-off is what lets a landed bullet rest on real contacts).
729
+ * Filters and sensors behave exactly as in the discrete narrow phase.
730
+ */
731
+ _advanceBullets() {
732
+ this._backend.spatialIndex?.sync(this._colliders);
733
+ for (const body of this._bodies) {
734
+ if (!body.isBullet || body.type !== "dynamic" || body.isSleeping) continue;
735
+ const newX = body.worldCenterOfMassX;
736
+ const newY = body.worldCenterOfMassY;
737
+ const dx = newX - body._ccdPrevX;
738
+ const dy = newY - body._ccdPrevY;
739
+ const distance = Math.hypot(dx, dy);
740
+ if (distance < 1e-6) continue;
741
+ const blocked = this._sweepBulletColliders(body, dx, dy);
742
+ if (blocked === null) continue;
743
+ const best = this._ccdBestHit;
744
+ const clampDistance = Math.min(distance, best.t * distance + ccdEmbed);
745
+ if (clampDistance < distance) {
746
+ const pullBack = (clampDistance - distance) / distance;
747
+ this._ccdClampPosition.x = body.x + dx * pullBack;
748
+ this._ccdClampPosition.y = body.y + dy * pullBack;
749
+ body.setTransform(this._ccdClampPosition, body.angle);
750
+ this._backend.spatialIndex?.sync(this._detectionLeavesOf(body));
751
+ }
752
+ const vn = body.linearVelocityX * best.normalX + body.linearVelocityY * best.normalY;
753
+ if (vn < 0) {
754
+ const impulse = -(1 + (vn < -1 ? Math.max(this._bulletRestitution(body), blocked.restitution) : 0)) * vn;
755
+ body.linearVelocityX += impulse * best.normalX;
756
+ body.linearVelocityY += impulse * best.normalY;
757
+ }
758
+ }
759
+ }
760
+ /**
761
+ * Find the earliest impact for a bullet whose colliders translated by
762
+ * `(dx, dy)` this step. Each of the bullet's colliders queries the broad
763
+ * phase with its swept AABB, so the pass costs what the geometry around the
764
+ * bullet's path costs - not one iteration per collider in the world. Returns
765
+ * the blocking collider (with the impact written into {@link _ccdBestHit}),
766
+ * or `null` when nothing blocks the motion.
767
+ */
768
+ _sweepBulletColliders(body, dx, dy) {
769
+ const hit = this._ccdSweepHit;
770
+ const best = this._ccdBestHit;
771
+ const swept = this._ccdSweptAabb;
772
+ const spatialIndex = this._backend.spatialIndex;
773
+ let blocked = null;
774
+ best.t = Infinity;
775
+ for (const collider of body.colliders) {
776
+ if (collider.isSensor) continue;
777
+ const aabb = collider.aabb;
778
+ swept.minX = dx > 0 ? aabb.minX - dx : aabb.minX;
779
+ swept.maxX = dx < 0 ? aabb.maxX - dx : aabb.maxX;
780
+ swept.minY = dy > 0 ? aabb.minY - dy : aabb.minY;
781
+ swept.maxY = dy < 0 ? aabb.maxY - dy : aabb.maxY;
782
+ const candidates = spatialIndex === void 0 ? this._detectionColliders : spatialIndex.queryAabb(swept, this._ccdCandidates);
783
+ this._ccdBroadPhaseCandidates += candidates.length;
784
+ for (const other of candidates) {
785
+ const target = authoredCollider(other);
786
+ if (target.isSensor || other.body === body || !shouldCollide(collider.filter, target.filter)) continue;
787
+ if (!aabbOverlap(swept, other.aabb)) continue;
788
+ this._ccdSweepTests++;
789
+ if (sweepProxies(collider, dx, dy, other, hit) && hit.t < best.t) {
790
+ best.t = hit.t;
791
+ best.normalX = hit.normalX;
792
+ best.normalY = hit.normalY;
793
+ blocked = target;
794
+ }
795
+ }
796
+ }
797
+ return blocked;
798
+ }
799
+ /** The highest restitution among a body's colliders (its CCD bounce factor). */
800
+ _bulletRestitution(body) {
801
+ let restitution = 0;
802
+ for (const collider of body.colliders) if (collider.restitution > restitution) restitution = collider.restitution;
803
+ return restitution;
804
+ }
805
+ /** Run `command` now, or queue it when inside an event dispatch (deferred to end of step). */
806
+ _defer(command) {
807
+ if (this._dispatching) this._commands.push(command);
808
+ else command();
809
+ }
810
+ _drainCommands() {
811
+ if (this._commands.length === 0) return;
812
+ const commands = this._commands.splice(0);
813
+ for (const command of commands) command();
814
+ }
815
+ _removeBody(body) {
816
+ const index = this._bodies.indexOf(body);
817
+ if (index === -1) {
818
+ this._teardownBody(body);
819
+ return;
820
+ }
821
+ this._bodies.splice(index, 1);
822
+ this._teardownBody(body);
823
+ }
824
+ _teardownBody(body) {
825
+ for (const collider of body.colliders) this._detachCollider(collider);
826
+ this._bindings.unbind(body);
827
+ body._markDestroyed();
828
+ }
829
+ _removeCollider(collider) {
830
+ this._detachCollider(collider);
831
+ collider.body._removeCollider(collider);
832
+ }
833
+ _detachCollider(collider) {
834
+ const index = this._colliders.indexOf(collider);
835
+ if (index !== -1) this._colliders.splice(index, 1);
836
+ this._wakeTouchingBodies(collider);
837
+ const edges = collider.chainEdges;
838
+ if (edges === null) this._removeDetectionCollider(collider);
839
+ else for (const edge of edges) this._removeDetectionCollider(edge);
840
+ collider._markDestroyed();
841
+ }
842
+ /** Drop one broad-phase leaf: an authored collider, or one chain edge proxy. */
843
+ _removeDetectionCollider(collider) {
844
+ const index = this._detectionColliders.indexOf(collider);
845
+ if (index !== -1) this._detectionColliders.splice(index, 1);
846
+ this._backend.removeCollider(collider);
847
+ }
848
+ /**
849
+ * The broad-phase leaves of one body - its colliders, with a chain replaced by
850
+ * its edge proxies. Returns pooled scratch, valid until the next call.
851
+ */
852
+ _detectionLeavesOf(body) {
853
+ const leaves = this._leafScratch;
854
+ leaves.length = 0;
855
+ for (const collider of body.colliders) {
856
+ const edges = collider.chainEdges;
857
+ if (edges === null) {
858
+ leaves.push(collider);
859
+ continue;
860
+ }
861
+ for (const edge of edges) leaves.push(edge);
862
+ }
863
+ return leaves;
864
+ }
865
+ /**
866
+ * Wake every dynamic body still touching `collider` at the moment it leaves
867
+ * the world. A sleeping body's sleep timer is frozen, so the island pass alone
868
+ * can never re-open its sleep decision: losing the support it rested on just
869
+ * removes a contact, and the island (now smaller, or a lone body) still reports
870
+ * a long-expired timer and is put straight back to sleep - a stack whose floor,
871
+ * platform or bottom box is destroyed would hang in mid-air forever. Kinematic
872
+ * and static supports are the sharp edge, since they are island boundaries
873
+ * rather than nodes and so are invisible to wake propagation, but the same hole
874
+ * exists for a dynamic support. Waking the far side of each contact is enough:
875
+ * the woken body's reset timer propagates through the rest of its sleeping
876
+ * island on the next step, exactly like any other wake event.
877
+ */
878
+ _wakeTouchingBodies(collider) {
879
+ for (const contact of this._backend.contactGraph.solidContacts) {
880
+ let other;
881
+ if (contact.ownerA === collider) other = contact.ownerB;
882
+ else if (contact.ownerB === collider) other = contact.ownerA;
883
+ else continue;
884
+ if (other.body.type === "dynamic") other.body.wake();
885
+ }
886
+ }
887
+ _assertAlive() {
888
+ if (this._destroyed) throw new Error("PhysicsWorld: the world has been destroyed.");
889
+ }
890
+ };
628
891
 
892
+ //#endregion
629
893
  export { PhysicsWorld };
630
- //# sourceMappingURL=PhysicsWorld.js.map
894
+ //# sourceMappingURL=PhysicsWorld.js.map