@flighthq/physics2d 0.3.0-edge.1458.0c01b63

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 (49) hide show
  1. package/dist/colliderTransform.d.ts +10 -0
  2. package/dist/colliderTransform.d.ts.map +1 -0
  3. package/dist/colliderTransform.js +137 -0
  4. package/dist/colliderTransform.js.map +1 -0
  5. package/dist/contract.d.ts +9 -0
  6. package/dist/contract.d.ts.map +1 -0
  7. package/dist/contract.js +9 -0
  8. package/dist/contract.js.map +1 -0
  9. package/dist/index.d.ts +9 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +9 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/islands.d.ts +38 -0
  14. package/dist/islands.d.ts.map +1 -0
  15. package/dist/islands.js +159 -0
  16. package/dist/islands.js.map +1 -0
  17. package/dist/jointRegistry.d.ts +6 -0
  18. package/dist/jointRegistry.d.ts.map +1 -0
  19. package/dist/jointRegistry.js +78 -0
  20. package/dist/jointRegistry.js.map +1 -0
  21. package/dist/joints.d.ts +39 -0
  22. package/dist/joints.d.ts.map +1 -0
  23. package/dist/joints.js +561 -0
  24. package/dist/joints.js.map +1 -0
  25. package/dist/massProperties.d.ts +4 -0
  26. package/dist/massProperties.d.ts.map +1 -0
  27. package/dist/massProperties.js +146 -0
  28. package/dist/massProperties.js.map +1 -0
  29. package/dist/solver.d.ts +7 -0
  30. package/dist/solver.d.ts.map +1 -0
  31. package/dist/solver.js +129 -0
  32. package/dist/solver.js.map +1 -0
  33. package/dist/step.d.ts +3 -0
  34. package/dist/step.d.ts.map +1 -0
  35. package/dist/step.js +414 -0
  36. package/dist/step.js.map +1 -0
  37. package/dist/world.d.ts +10 -0
  38. package/dist/world.d.ts.map +1 -0
  39. package/dist/world.js +133 -0
  40. package/dist/world.js.map +1 -0
  41. package/package.json +49 -0
  42. package/src/colliderTransform.test.ts +115 -0
  43. package/src/islands.test.ts +262 -0
  44. package/src/jointRegistry.test.ts +205 -0
  45. package/src/joints.test.ts +951 -0
  46. package/src/massProperties.test.ts +169 -0
  47. package/src/solver.test.ts +186 -0
  48. package/src/step.test.ts +745 -0
  49. package/src/world.test.ts +142 -0
@@ -0,0 +1,951 @@
1
+ import type {
2
+ Physics2DDistanceJoint,
3
+ Physics2DRevoluteJoint,
4
+ Physics2DMouseJoint,
5
+ Physics2DRopeJoint,
6
+ Physics2DWeldJoint,
7
+ Physics2DWorld,
8
+ } from '@flighthq/types/contract';
9
+ import { describe, expect, it } from 'vitest';
10
+
11
+ import { addPhysics2DJoint, registerPhysics2DJointSolver } from './jointRegistry';
12
+ import {
13
+ Physics2DDistanceJointKind,
14
+ Physics2DMouseJointKind,
15
+ Physics2DRevoluteJointKind,
16
+ Physics2DRopeJointKind,
17
+ Physics2DWeldJointKind,
18
+ physics2DDistanceJointSolver,
19
+ physics2DMouseJointSolver,
20
+ physics2DRevoluteJointSolver,
21
+ physics2DRopeJointSolver,
22
+ physics2DWeldJointSolver,
23
+ } from './joints';
24
+ import { stepPhysics2D } from './step';
25
+ import { addPhysics2DBody, createPhysics2DCollider, createPhysics2DWorld, createRigidBody2D } from './world';
26
+
27
+ const STONE = { density: 1, friction: 0.3, restitution: 0 };
28
+
29
+ function box(world: ReturnType<typeof createPhysics2DWorld>, type: 'dynamic' | 'static', x: number, y: number) {
30
+ const body = createRigidBody2D(type, x, y);
31
+ body.colliders.push(createPhysics2DCollider({ kind: 'aabb', minX: -0.5, minY: -0.5, maxX: 0.5, maxY: 0.5 }, STONE));
32
+ return addPhysics2DBody(world, body);
33
+ }
34
+
35
+ function baseJoint(kind: string, bodyA: number, bodyB: number) {
36
+ return {
37
+ kind,
38
+ bodyA,
39
+ bodyB,
40
+ localAnchorAX: 0,
41
+ localAnchorAY: 0,
42
+ localAnchorBX: 0,
43
+ localAnchorBY: 0,
44
+ collideConnected: false,
45
+ impulse0: 0,
46
+ impulse1: 0,
47
+ impulse2: 0,
48
+ rAX: 0,
49
+ rAY: 0,
50
+ rBX: 0,
51
+ rBY: 0,
52
+ };
53
+ }
54
+
55
+ function run(world: ReturnType<typeof createPhysics2DWorld>, steps: number): void {
56
+ for (let i = 0; i < steps; i++) stepPhysics2D(world, 1 / 60);
57
+ }
58
+
59
+ describe('canonical end ordering and direction-bearing joint state', () => {
60
+ // The generic swap in addPhysics2DJoint moves only what every joint has: two body indices and two
61
+ // anchors. Anything a kind measures FROM bodyA TO bodyB reverses when the ends trade places, and the
62
+ // registry cannot know which fields those are — so each kind now answers for its own via swapEnds.
63
+ it('negates a weld reference angle when the ends are exchanged', () => {
64
+ const world = createPhysics2DWorld(0, 0);
65
+ registerPhysics2DJointSolver(world, Physics2DWeldJointKind, physics2DWeldJointSolver);
66
+ const second = box(world, 'dynamic', 0, 0);
67
+ const first = box(world, 'dynamic', 1, 0);
68
+ // Supplied high-index-first, so the ends must be exchanged.
69
+ const joint: Physics2DWeldJoint = {
70
+ ...baseJoint(Physics2DWeldJointKind, first.index, second.index),
71
+ referenceAngle: 0.5,
72
+ };
73
+ addPhysics2DJoint(world, joint);
74
+ expect(joint.bodyA).toBe(second.index);
75
+ expect(joint.referenceAngle).toBeCloseTo(-0.5, 12);
76
+ });
77
+
78
+ it('leaves a weld reference angle alone when no exchange happens', () => {
79
+ // swapEnds both vetoes and transforms, so it must only be consulted when a swap is actually
80
+ // pending — calling it unconditionally would negate the angle of every joint supplied in order.
81
+ const world = createPhysics2DWorld(0, 0);
82
+ registerPhysics2DJointSolver(world, Physics2DWeldJointKind, physics2DWeldJointSolver);
83
+ const first = box(world, 'dynamic', 0, 0);
84
+ const second = box(world, 'dynamic', 1, 0);
85
+ const joint: Physics2DWeldJoint = {
86
+ ...baseJoint(Physics2DWeldJointKind, first.index, second.index),
87
+ referenceAngle: 0.5,
88
+ };
89
+ addPhysics2DJoint(world, joint);
90
+ expect(joint.bodyA).toBe(first.index);
91
+ expect(joint.referenceAngle).toBeCloseTo(0.5, 12);
92
+ });
93
+
94
+ it('holds the same physical pose whichever order the weld ends were supplied in', () => {
95
+ // The property the negation exists for: the constraint is a fact about the pair, so which end the
96
+ // caller happened to name first must not change where the bodies end up.
97
+ function poseFor(supplyReversed: boolean): number {
98
+ const world = createPhysics2DWorld(0, 0);
99
+ registerPhysics2DJointSolver(world, Physics2DWeldJointKind, physics2DWeldJointSolver);
100
+ const low = box(world, 'static', 0, 0);
101
+ const high = box(world, 'dynamic', 1, 0);
102
+ const joint: Physics2DWeldJoint = {
103
+ ...baseJoint(
104
+ Physics2DWeldJointKind,
105
+ supplyReversed ? high.index : low.index,
106
+ supplyReversed ? low.index : high.index,
107
+ ),
108
+ referenceAngle: supplyReversed ? -0.4 : 0.4,
109
+ };
110
+ addPhysics2DJoint(world, joint);
111
+ run(world, 60);
112
+ return high.angle;
113
+ }
114
+ expect(poseFor(true)).toBeCloseTo(poseFor(false), 6);
115
+ });
116
+
117
+ it('still swaps a direction-free kind, which needs no transform', () => {
118
+ const world = createPhysics2DWorld(0, 0);
119
+ registerPhysics2DJointSolver(world, Physics2DDistanceJointKind, physics2DDistanceJointSolver);
120
+ const second = box(world, 'dynamic', 0, 0);
121
+ const first = box(world, 'dynamic', 1, 0);
122
+ const joint: Physics2DDistanceJoint = {
123
+ ...baseJoint(Physics2DDistanceJointKind, first.index, second.index),
124
+ length: 1,
125
+ stiffness: 0,
126
+ damping: 0,
127
+ };
128
+ addPhysics2DJoint(world, joint);
129
+ expect(joint.bodyA).toBe(second.index);
130
+ });
131
+ });
132
+
133
+ describe('joint warm starting', () => {
134
+ // Physics2DJoint documents its impulse block as "reused as the next step's warm start exactly as a
135
+ // contact's are". Only contacts were warm-started; the joint block accumulated forever and was never
136
+ // reapplied, and switching warmStarting off did not clear it either.
137
+ function hangingWorld(warmStarting: boolean) {
138
+ const world = createPhysics2DWorld(0, -10);
139
+ world.config.warmStarting = warmStarting;
140
+ registerPhysics2DJointSolver(world, Physics2DDistanceJointKind, physics2DDistanceJointSolver);
141
+ const anchor = box(world, 'static', 0, 0);
142
+ const hanging = box(world, 'dynamic', 0, -2);
143
+ const joint: Physics2DDistanceJoint = {
144
+ ...baseJoint(Physics2DDistanceJointKind, anchor.index, hanging.index),
145
+ damping: 0,
146
+ length: 2,
147
+ stiffness: 0,
148
+ };
149
+ addPhysics2DJoint(world, joint);
150
+ return { hanging, joint, world };
151
+ }
152
+
153
+ it('clears the accumulated impulse each step when warm starting is off', () => {
154
+ // Cold start every step: the same step from the same state converges to the same impulse, so two
155
+ // consecutive settled steps agree. Before, the accumulator was carried whether or not the flag
156
+ // said to, so it drifted step over step.
157
+ const { joint, world } = hangingWorld(false);
158
+ run(world, 60);
159
+ const first = joint.impulse0;
160
+ stepPhysics2D(world, 1 / 60);
161
+ expect(joint.impulse0).toBeCloseTo(first, 6);
162
+ });
163
+
164
+ it("invokes each kind's warm start once per step when warm starting is on", () => {
165
+ // Asserted on the wiring rather than on a numerical difference. Warm starting changes the PATH to
166
+ // convergence, not the fixed point, and a single stiff distance joint converges in one iteration
167
+ // either way — so every physical observable I tried was identical by construction, and a test
168
+ // built on one would have been measuring nothing while claiming to measure warm starting. What
169
+ // finding 7 is actually about is that the hook never ran at all.
170
+ const world = createPhysics2DWorld(0, -10);
171
+ world.config.warmStarting = true;
172
+ const calls: string[] = [];
173
+ registerPhysics2DJointSolver(world, Physics2DDistanceJointKind, {
174
+ ...physics2DDistanceJointSolver,
175
+ clearAccumulatedImpulses: () => calls.push('clear'),
176
+ warmStart: () => calls.push('warm'),
177
+ });
178
+ const anchor = box(world, 'static', 0, 0);
179
+ const hanging = box(world, 'dynamic', 0, -2);
180
+ addPhysics2DJoint(world, {
181
+ ...baseJoint(Physics2DDistanceJointKind, anchor.index, hanging.index),
182
+ damping: 0,
183
+ length: 2,
184
+ stiffness: 0,
185
+ } as Physics2DDistanceJoint);
186
+
187
+ stepPhysics2D(world, 1 / 60);
188
+ expect(calls).toEqual(['warm']);
189
+
190
+ world.config.warmStarting = false;
191
+ stepPhysics2D(world, 1 / 60);
192
+ expect(calls).toEqual(['warm', 'clear']);
193
+ });
194
+
195
+ it('leaves a kind that declares neither hook untouched', () => {
196
+ // Both hooks are optional: a kind that starts cold every step is correct, just slower to converge.
197
+ const world = createPhysics2DWorld(0, -10);
198
+ registerPhysics2DJointSolver(world, Physics2DDistanceJointKind, {
199
+ prepare: physics2DDistanceJointSolver.prepare,
200
+ solve: physics2DDistanceJointSolver.solve,
201
+ });
202
+ const anchor = box(world, 'static', 0, 0);
203
+ const hanging = box(world, 'dynamic', 0, -2);
204
+ addPhysics2DJoint(world, {
205
+ ...baseJoint(Physics2DDistanceJointKind, anchor.index, hanging.index),
206
+ damping: 0,
207
+ length: 2,
208
+ stiffness: 0,
209
+ } as Physics2DDistanceJoint);
210
+ expect(() => run(world, 30)).not.toThrow();
211
+ expect(hanging.y).toBeLessThan(-1.5);
212
+ });
213
+
214
+ it('holds the hanging body at the joint length either way', () => {
215
+ // Warm starting is a convergence aid, not a behaviour change: both settle to the same pose.
216
+ const cold = hangingWorld(false);
217
+ const warm = hangingWorld(true);
218
+ run(cold.world, 240);
219
+ run(warm.world, 240);
220
+ expect(warm.hanging.y).toBeCloseTo(cold.hanging.y, 2);
221
+ expect(warm.hanging.y).toBeLessThan(-1.5);
222
+ expect(warm.hanging.y).toBeGreaterThan(-2.5);
223
+ });
224
+
225
+ it('clears a weld joint every impulse it accumulates, not only the linear pair', () => {
226
+ const world = createPhysics2DWorld(0, -10);
227
+ world.config.warmStarting = false;
228
+ registerPhysics2DJointSolver(world, Physics2DWeldJointKind, physics2DWeldJointSolver);
229
+ const anchor = box(world, 'static', 0, 0);
230
+ // Offset so gravity puts real load on both the linear and the angular part; welded at the origin
231
+ // the impulses settle at ~1e-6 and asserting on them would be asserting on noise.
232
+ const welded = box(world, 'dynamic', 3, 0);
233
+ const joint: Physics2DWeldJoint = {
234
+ ...baseJoint(Physics2DWeldJointKind, anchor.index, welded.index),
235
+ referenceAngle: 0,
236
+ };
237
+ addPhysics2DJoint(world, joint);
238
+ run(world, 120);
239
+ const settled = { angular: joint.impulse2, x: joint.impulse0, y: joint.impulse1 };
240
+ expect(Math.abs(settled.y)).toBeGreaterThan(0.001);
241
+ stepPhysics2D(world, 1 / 60);
242
+ expect(joint.impulse0).toBeCloseTo(settled.x, 4);
243
+ expect(joint.impulse1).toBeCloseTo(settled.y, 4);
244
+ expect(joint.impulse2).toBeCloseTo(settled.angular, 4);
245
+ });
246
+ });
247
+
248
+ // A joint whose impulse accumulators were never written. The type declares them required, so this is not
249
+ // a shape a TypeScript caller can build by hand — it is what a joint deserialized from a saved world
250
+ // actually looks like at runtime, where the compile-time guarantee bought nothing.
251
+ function jointWithoutAccumulators(kind: string, bodyA: number, bodyB: number, over: object = {}): never {
252
+ return {
253
+ kind,
254
+ bodyA,
255
+ bodyB,
256
+ localAnchorAX: 0,
257
+ localAnchorAY: 0,
258
+ localAnchorBX: 0,
259
+ localAnchorBY: 0,
260
+ collideConnected: false,
261
+ rAX: 0,
262
+ rAY: 0,
263
+ rBX: 0,
264
+ rBY: 0,
265
+ ...over,
266
+ } as never;
267
+ }
268
+
269
+ describe('joints built without their impulse accumulators', () => {
270
+ // An absent accumulator is read as `undefined`, and the first arithmetic on it yields NaN. Because an
271
+ // accumulator is fed back into the next iteration, the NaN does not stay in the joint: it leaves
272
+ // through the applied impulse into BOTH bodies' velocities, and from there into every contact they
273
+ // touch. One joint missing one number poisons the world.
274
+ it('drives a revolute motor normally when the ends are already canonical', () => {
275
+ const world = createPhysics2DWorld(0, 0);
276
+ const anchor = box(world, 'static', 0, 0);
277
+ const arm = box(world, 'dynamic', 1, 0);
278
+ registerPhysics2DJointSolver(world, Physics2DRevoluteJointKind, physics2DRevoluteJointSolver);
279
+ const joint = jointWithoutAccumulators(Physics2DRevoluteJointKind, anchor.index, arm.index, {
280
+ enableLimit: false,
281
+ enableMotor: true,
282
+ lowerAngle: 0,
283
+ maxMotorTorque: 100,
284
+ motorSpeed: 2,
285
+ referenceAngle: 0,
286
+ upperAngle: 0,
287
+ });
288
+ addPhysics2DJoint(world, joint);
289
+ run(world, 30);
290
+
291
+ // Not merely finite: the motor has to actually turn the arm toward its target speed, which is what
292
+ // proves the accumulator was established as a usable zero rather than just kept out of the maths.
293
+ expect(arm.angularVelocity).toBeGreaterThan(0.5);
294
+ expect(Number.isFinite((joint as unknown as Physics2DRevoluteJoint).motorImpulse)).toBe(true);
295
+ });
296
+
297
+ it('drives a revolute motor normally when the ends must be exchanged first', () => {
298
+ // The second, distinct mechanism. swapEnds runs when the joint is ADDED, before any prepare, and it
299
+ // negates the motor accumulator. Negating an absent value writes NaN into the field — and NaN is not
300
+ // nullish, so every later `??` accepts the poison instead of replacing it. This order is the only one
301
+ // that reaches that path, which is why both orders are pinned.
302
+ const world = createPhysics2DWorld(0, 0);
303
+ const arm = box(world, 'dynamic', 1, 0);
304
+ const anchor = box(world, 'static', 0, 0);
305
+ registerPhysics2DJointSolver(world, Physics2DRevoluteJointKind, physics2DRevoluteJointSolver);
306
+ // Supplied high-index-first, so the ends must be exchanged.
307
+ const joint = jointWithoutAccumulators(Physics2DRevoluteJointKind, anchor.index, arm.index, {
308
+ enableLimit: false,
309
+ enableMotor: true,
310
+ lowerAngle: 0,
311
+ maxMotorTorque: 100,
312
+ motorSpeed: 2,
313
+ referenceAngle: 0,
314
+ upperAngle: 0,
315
+ });
316
+ addPhysics2DJoint(world, joint);
317
+ // Magnitude, because negating an established zero keeps the sign bit and yields -0. That is
318
+ // identical to +0 in every arithmetic that follows and differs only under Object.is, which toBe uses.
319
+ expect(Math.abs((joint as unknown as Physics2DRevoluteJoint).motorImpulse)).toBe(0);
320
+
321
+ run(world, 30);
322
+
323
+ expect(Math.abs(arm.angularVelocity)).toBeGreaterThan(0.5);
324
+ });
325
+
326
+ // The same defect on every other solved kind: the revolute motor was the instance that was reported,
327
+ // the generic impulse0/1/2 block is the class.
328
+ const GENERIC_KINDS = [
329
+ [Physics2DDistanceJointKind, physics2DDistanceJointSolver, { length: 2, stiffness: 0, damping: 0 }],
330
+ [Physics2DRopeJointKind, physics2DRopeJointSolver, { maxLength: 1 }],
331
+ [Physics2DWeldJointKind, physics2DWeldJointSolver, { referenceAngle: 0, stiffness: 0, damping: 0 }],
332
+ ] as const;
333
+
334
+ for (const [kind, solver, over] of GENERIC_KINDS) {
335
+ it(`keeps both bodies finite across a ${kind} joint`, () => {
336
+ const world = createPhysics2DWorld();
337
+ const first = box(world, 'dynamic', 0, 0);
338
+ const second = box(world, 'dynamic', 2, 0);
339
+ registerPhysics2DJointSolver(world, kind, solver);
340
+ addPhysics2DJoint(world, jointWithoutAccumulators(kind, first.index, second.index, over));
341
+ // Two steps, because the second is the one that warm-starts from the first step's accumulator —
342
+ // a single step would pass even if the accumulator were never written back.
343
+ run(world, 2);
344
+
345
+ for (const body of [first, second]) {
346
+ expect(Number.isFinite(body.velocityX)).toBe(true);
347
+ expect(Number.isFinite(body.velocityY)).toBe(true);
348
+ expect(Number.isFinite(body.angularVelocity)).toBe(true);
349
+ }
350
+ });
351
+ }
352
+ });
353
+
354
+ describe('physics2DDistanceJointSolver', () => {
355
+ it('holds a hanging body at the joint length instead of letting it fall', () => {
356
+ const world = createPhysics2DWorld();
357
+ registerPhysics2DJointSolver(world, Physics2DDistanceJointKind, physics2DDistanceJointSolver);
358
+ const anchor = box(world, 'static', 0, 5);
359
+ const bob = box(world, 'dynamic', 0, 3);
360
+ const joint: Physics2DDistanceJoint = {
361
+ ...baseJoint(Physics2DDistanceJointKind, anchor.index, bob.index),
362
+ length: 2,
363
+ stiffness: 0,
364
+ damping: 0,
365
+ };
366
+ addPhysics2DJoint(world, joint);
367
+ run(world, 240);
368
+
369
+ const separation = Math.sqrt((bob.x - anchor.x) ** 2 + (bob.y - anchor.y) ** 2);
370
+ expect(separation).toBeGreaterThan(1.9);
371
+ expect(separation).toBeLessThan(2.1);
372
+ });
373
+
374
+ it('swings a displaced bob back under the anchor rather than holding it out sideways', () => {
375
+ const world = createPhysics2DWorld();
376
+ registerPhysics2DJointSolver(world, Physics2DDistanceJointKind, physics2DDistanceJointSolver);
377
+ const anchor = box(world, 'static', 0, 5);
378
+ const bob = box(world, 'dynamic', 2, 5);
379
+ bob.linearDamping = 1.5;
380
+ const joint: Physics2DDistanceJoint = {
381
+ ...baseJoint(Physics2DDistanceJointKind, anchor.index, bob.index),
382
+ length: 2,
383
+ stiffness: 0,
384
+ damping: 0,
385
+ };
386
+ addPhysics2DJoint(world, joint);
387
+ run(world, 600);
388
+ expect(bob.y).toBeLessThan(4);
389
+ });
390
+ });
391
+
392
+ function revoluteJoint(
393
+ bodyA: number,
394
+ bodyB: number,
395
+ over: Partial<Physics2DRevoluteJoint> = {},
396
+ ): Physics2DRevoluteJoint {
397
+ return {
398
+ ...baseJoint(Physics2DRevoluteJointKind, bodyA, bodyB),
399
+ enableLimit: false,
400
+ enableMotor: false,
401
+ lowerAngle: 0,
402
+ maxMotorTorque: 0,
403
+ motorImpulse: 0,
404
+ motorSpeed: 0,
405
+ referenceAngle: 0,
406
+ upperAngle: 0,
407
+ ...over,
408
+ } as Physics2DRevoluteJoint;
409
+ }
410
+
411
+ describe('physics2DMouseJointSolver', () => {
412
+ it('drags a body toward its target', () => {
413
+ const world = createPhysics2DWorld(0, 0);
414
+ registerPhysics2DJointSolver(world, Physics2DMouseJointKind, physics2DMouseJointSolver);
415
+ const dragged = box(world, 'dynamic', 0, 0);
416
+ const joint: Physics2DMouseJoint = {
417
+ ...baseJoint(Physics2DMouseJointKind, dragged.index, dragged.index),
418
+ targetX: 5,
419
+ targetY: 0,
420
+ maxForce: 1000,
421
+ stiffness: 5,
422
+ damping: 0.7,
423
+ };
424
+ addPhysics2DJoint(world, joint);
425
+ run(world, 120);
426
+ expect(dragged.x).toBeGreaterThan(3);
427
+ });
428
+
429
+ // Critic's repro. The mouse joint deliberately declares no warmStart, because a target that moves
430
+ // between steps invalidates the previous impulse -- the type documents a cold start. But the step only
431
+ // cleared accumulators when the WORLD had warm starting off, so with it on (the default) the stale
432
+ // impulse stayed live and kept pushing a body whose target was already on top of it.
433
+ it('starts cold each step even while the world is warm starting', () => {
434
+ const world = createPhysics2DWorld(0, 0);
435
+ registerPhysics2DJointSolver(world, Physics2DMouseJointKind, physics2DMouseJointSolver);
436
+ const dragged = box(world, 'dynamic', 0, 0);
437
+ const joint: Physics2DMouseJoint = {
438
+ ...baseJoint(Physics2DMouseJointKind, dragged.index, dragged.index),
439
+ targetX: 5,
440
+ targetY: 0,
441
+ maxForce: 1000,
442
+ stiffness: 5,
443
+ damping: 0.7,
444
+ };
445
+ addPhysics2DJoint(world, joint);
446
+ run(world, 30);
447
+
448
+ // Park the body: stop it dead and put the target exactly where the body already is, so a correct
449
+ // cold start has nothing left to do.
450
+ dragged.velocityX = 0;
451
+ dragged.velocityY = 0;
452
+ joint.targetX = dragged.x;
453
+ joint.targetY = dragged.y;
454
+
455
+ stepPhysics2D(world, 1 / 60);
456
+
457
+ expect(Math.abs(dragged.velocityX)).toBeLessThan(1e-6);
458
+ expect(Math.abs(dragged.velocityY)).toBeLessThan(1e-6);
459
+ });
460
+
461
+ it('respects its force bound rather than injecting unbounded energy', () => {
462
+ // The reason a mouse joint is soft by construction: a rigid drag lets a user pull a body arbitrarily
463
+ // fast simply by moving the cursor, and the rest of the world absorbs it.
464
+ //
465
+ // This assertion used to read `velocity < 60` with a maxForce of 0.5, which is not a force bound at
466
+ // all — it passed while the solver was clamping the ACCUMULATED IMPULSE against maxForce, a unit
467
+ // error worth 1/dt. The bound a force actually implies is dv <= F*dt/m per step, and that is what
468
+ // is asserted now: at maxForce 1, dt 0.01, mass 1 the step may add 0.01 and no more, where the old
469
+ // code added 1.0 and the old assertion waved it through.
470
+ const world = createPhysics2DWorld(0, 0);
471
+ registerPhysics2DJointSolver(world, Physics2DMouseJointKind, physics2DMouseJointSolver);
472
+ const dragged = box(world, 'dynamic', 0, 0);
473
+ const maxForce = 1;
474
+ const dt = 0.01;
475
+ const joint: Physics2DMouseJoint = {
476
+ ...baseJoint(Physics2DMouseJointKind, dragged.index, dragged.index),
477
+ targetX: 1000,
478
+ targetY: 0,
479
+ maxForce,
480
+ stiffness: 5,
481
+ damping: 0.7,
482
+ };
483
+ addPhysics2DJoint(world, joint);
484
+
485
+ const perStep = (maxForce * dt) / (1 / dragged.inverseMass);
486
+ let previous = dragged.velocityX;
487
+ for (let i = 0; i < 20; i++) {
488
+ stepPhysics2D(world, dt);
489
+ expect(Math.abs(dragged.velocityX - previous)).toBeLessThanOrEqual(perStep * 1.0000001);
490
+ previous = dragged.velocityX;
491
+ }
492
+ expect(Number.isFinite(dragged.x)).toBe(true);
493
+ });
494
+
495
+ it('scales the impulse bound with the timestep, so the force means the same at any dt', () => {
496
+ // A force bound that ignored dt would let a smaller step inject the same impulse more often.
497
+ const speeds: number[] = [];
498
+ for (const dt of [0.02, 0.01]) {
499
+ const world = createPhysics2DWorld(0, 0);
500
+ registerPhysics2DJointSolver(world, Physics2DMouseJointKind, physics2DMouseJointSolver);
501
+ const dragged = box(world, 'dynamic', 0, 0);
502
+ const joint: Physics2DMouseJoint = {
503
+ ...baseJoint(Physics2DMouseJointKind, dragged.index, dragged.index),
504
+ targetX: 1000,
505
+ targetY: 0,
506
+ maxForce: 1,
507
+ stiffness: 5,
508
+ damping: 0.7,
509
+ };
510
+ addPhysics2DJoint(world, joint);
511
+ stepPhysics2D(world, dt);
512
+ speeds.push(Math.abs(dragged.velocityX));
513
+ }
514
+ // Halving dt halves the impulse a single step may deliver.
515
+ expect(speeds[1]).toBeCloseTo(speeds[0] / 2, 10);
516
+ });
517
+
518
+ it('drags the dynamic body even when it was added before its anchor', () => {
519
+ // Canonical ordering swaps a joint's ends when the caller supplies them low-index-second. A mouse
520
+ // joint has only one real body — the other end is a world-space target — so the exchange moved the
521
+ // dragged body into bodyA while this solver acts on bodyB, and the drag silently applied to the
522
+ // anchor. The dragged body did not move at all.
523
+ const world = createPhysics2DWorld(0, 0);
524
+ registerPhysics2DJointSolver(world, Physics2DMouseJointKind, physics2DMouseJointSolver);
525
+ const dragged = box(world, 'dynamic', 0, 0);
526
+ const anchor = box(world, 'static', 0, 0);
527
+ expect(dragged.index).toBeLessThan(anchor.index);
528
+
529
+ const joint: Physics2DMouseJoint = {
530
+ ...baseJoint(Physics2DMouseJointKind, anchor.index, dragged.index),
531
+ targetX: 5,
532
+ targetY: 0,
533
+ maxForce: 1000,
534
+ stiffness: 5,
535
+ damping: 0.7,
536
+ };
537
+ addPhysics2DJoint(world, joint);
538
+ expect(joint.bodyB).toBe(dragged.index);
539
+
540
+ run(world, 60);
541
+ expect(dragged.x).toBeGreaterThan(3);
542
+ expect(anchor.x).toBe(0);
543
+ });
544
+
545
+ it('drags the dynamic body when it was added after its anchor', () => {
546
+ // The orientation that already worked, kept so the veto cannot be "fixed" by breaking this one.
547
+ const world = createPhysics2DWorld(0, 0);
548
+ registerPhysics2DJointSolver(world, Physics2DMouseJointKind, physics2DMouseJointSolver);
549
+ const anchor = box(world, 'static', 0, 0);
550
+ const dragged = box(world, 'dynamic', 0, 0);
551
+ const joint: Physics2DMouseJoint = {
552
+ ...baseJoint(Physics2DMouseJointKind, anchor.index, dragged.index),
553
+ targetX: 5,
554
+ targetY: 0,
555
+ maxForce: 1000,
556
+ stiffness: 5,
557
+ damping: 0.7,
558
+ };
559
+ addPhysics2DJoint(world, joint);
560
+ expect(joint.bodyB).toBe(dragged.index);
561
+ run(world, 60);
562
+ expect(dragged.x).toBeGreaterThan(3);
563
+ });
564
+ });
565
+
566
+ describe('physics2DRevoluteJointSolver', () => {
567
+ it('keeps the pinned anchors together while leaving rotation free', () => {
568
+ const world = createPhysics2DWorld();
569
+ registerPhysics2DJointSolver(world, Physics2DRevoluteJointKind, physics2DRevoluteJointSolver);
570
+ const anchor = box(world, 'static', 0, 5);
571
+ const arm = box(world, 'dynamic', 1, 5);
572
+ const joint = {
573
+ ...baseJoint(Physics2DRevoluteJointKind, anchor.index, arm.index),
574
+ localAnchorAX: 0,
575
+ localAnchorAY: 0,
576
+ localAnchorBX: -1,
577
+ localAnchorBY: 0,
578
+ };
579
+ addPhysics2DJoint(world, joint);
580
+ run(world, 180);
581
+
582
+ // The arm's anchor point must stay on the static body's anchor.
583
+ const cos = Math.cos(arm.angle);
584
+ const sin = Math.sin(arm.angle);
585
+ const anchorX = arm.x + -1 * cos - 0 * sin;
586
+ const anchorY = arm.y + -1 * sin + 0 * cos;
587
+ expect(Math.abs(anchorX - anchor.x)).toBeLessThan(0.15);
588
+ expect(Math.abs(anchorY - anchor.y)).toBeLessThan(0.15);
589
+ // Rotation is free, so gravity must have swung it.
590
+ expect(Math.abs(arm.angle)).toBeGreaterThan(0.05);
591
+ });
592
+ });
593
+
594
+ describe('physics2DRevoluteJointSolver motor and limits', () => {
595
+ // The type advertises a motor and an angular limit; the solver read none of those fields, so an
596
+ // enabled motor left the arm at exactly zero angular velocity. These are the behaviours the header
597
+ // promises, asserted against the numbers the promise implies rather than against "it moved".
598
+ function hinged(world: Physics2DWorld) {
599
+ const anchor = box(world, 'static', 0, 0);
600
+ const arm = box(world, 'dynamic', 0, 0);
601
+ return { anchor, arm };
602
+ }
603
+
604
+ it('drives the relative angular velocity to the motor speed', () => {
605
+ const world = createPhysics2DWorld(0, 0);
606
+ registerPhysics2DJointSolver(world, Physics2DRevoluteJointKind, physics2DRevoluteJointSolver);
607
+ const { anchor, arm } = hinged(world);
608
+ addPhysics2DJoint(
609
+ world,
610
+ revoluteJoint(anchor.index, arm.index, { enableMotor: true, maxMotorTorque: 100, motorSpeed: 2 }),
611
+ );
612
+ run(world, 30);
613
+ expect(arm.angularVelocity).toBeCloseTo(2, 6);
614
+ // Half a second at 2 rad/s.
615
+ expect(arm.angle).toBeCloseTo(1, 3);
616
+ });
617
+
618
+ it('drives the relative angle the other way for a negative motor speed', () => {
619
+ const world = createPhysics2DWorld(0, 0);
620
+ registerPhysics2DJointSolver(world, Physics2DRevoluteJointKind, physics2DRevoluteJointSolver);
621
+ const { anchor, arm } = hinged(world);
622
+ addPhysics2DJoint(
623
+ world,
624
+ revoluteJoint(anchor.index, arm.index, { enableMotor: true, maxMotorTorque: 100, motorSpeed: -2 }),
625
+ );
626
+ run(world, 30);
627
+ expect(arm.angularVelocity).toBeCloseTo(-2, 6);
628
+ });
629
+
630
+ it('does not drive the arm at all when the motor is disabled', () => {
631
+ const world = createPhysics2DWorld(0, 0);
632
+ registerPhysics2DJointSolver(world, Physics2DRevoluteJointKind, physics2DRevoluteJointSolver);
633
+ const { anchor, arm } = hinged(world);
634
+ addPhysics2DJoint(
635
+ world,
636
+ revoluteJoint(anchor.index, arm.index, { enableMotor: false, maxMotorTorque: 100, motorSpeed: 2 }),
637
+ );
638
+ run(world, 30);
639
+ expect(arm.angularVelocity).toBeCloseTo(0, 9);
640
+ });
641
+
642
+ it('bounds the motor by its torque, so a small budget cannot reach the target speed', () => {
643
+ // The torque bound is a torque: the accumulated impulse may not exceed maxMotorTorque * dt.
644
+ const world = createPhysics2DWorld(0, 0);
645
+ registerPhysics2DJointSolver(world, Physics2DRevoluteJointKind, physics2DRevoluteJointSolver);
646
+ const { anchor, arm } = hinged(world);
647
+ const maxMotorTorque = 0.01;
648
+ addPhysics2DJoint(
649
+ world,
650
+ revoluteJoint(anchor.index, arm.index, { enableMotor: true, maxMotorTorque, motorSpeed: 100 }),
651
+ );
652
+ const dt = 1 / 60;
653
+ let previous = arm.angularVelocity;
654
+ for (let i = 0; i < 10; i++) {
655
+ stepPhysics2D(world, dt);
656
+ const perStep = maxMotorTorque * dt * arm.inverseInertia;
657
+ expect(Math.abs(arm.angularVelocity - previous)).toBeLessThanOrEqual(perStep * 1.0000001);
658
+ previous = arm.angularVelocity;
659
+ }
660
+ expect(arm.angularVelocity).toBeLessThan(100);
661
+ });
662
+
663
+ // The bound test above passes for a motor that stops acting entirely — every delta of zero is <= the
664
+ // bound — which is exactly how a saturated accumulator hid. This asserts the motor keeps ACTING, the
665
+ // property the title of that test claims. The motor impulse persists across steps, so it must be
666
+ // reapplied in warm start; without that the running total pins at maxMotorTorque * dt on step one and
667
+ // every later step adds nothing.
668
+ it('keeps accelerating on later steps rather than pinning after the first', () => {
669
+ const world = createPhysics2DWorld(0, 0);
670
+ registerPhysics2DJointSolver(world, Physics2DRevoluteJointKind, physics2DRevoluteJointSolver);
671
+ const { anchor, arm } = hinged(world);
672
+ addPhysics2DJoint(
673
+ world,
674
+ revoluteJoint(anchor.index, arm.index, { enableMotor: true, maxMotorTorque: 0.01, motorSpeed: 100 }),
675
+ );
676
+
677
+ stepPhysics2D(world, 1 / 60);
678
+ const afterFirst = arm.angularVelocity;
679
+ stepPhysics2D(world, 1 / 60);
680
+ const afterSecond = arm.angularVelocity;
681
+
682
+ expect(afterFirst).toBeGreaterThan(0);
683
+ expect(afterSecond).toBeGreaterThan(afterFirst);
684
+ });
685
+
686
+ it('keeps accelerating across many steps toward the target speed', () => {
687
+ const world = createPhysics2DWorld(0, 0);
688
+ registerPhysics2DJointSolver(world, Physics2DRevoluteJointKind, physics2DRevoluteJointSolver);
689
+ const { anchor, arm } = hinged(world);
690
+ addPhysics2DJoint(
691
+ world,
692
+ revoluteJoint(anchor.index, arm.index, { enableMotor: true, maxMotorTorque: 0.01, motorSpeed: 100 }),
693
+ );
694
+
695
+ stepPhysics2D(world, 1 / 60);
696
+ const afterFirst = arm.angularVelocity;
697
+ for (let i = 0; i < 29; i++) stepPhysics2D(world, 1 / 60);
698
+
699
+ // Thirty steps of a bounded motor must be meaningfully faster than one, not the same value.
700
+ expect(arm.angularVelocity).toBeGreaterThan(afterFirst * 5);
701
+ });
702
+
703
+ // A cached impulse is only valid while the thing that produced it is still running. `solve` skips a
704
+ // disabled motor, so warm start must skip it too — otherwise the last impulse is reapplied every step
705
+ // with nothing to cancel it, and a switched-off motor keeps turning the joint forever.
706
+ it('exerts no torque once the motor is disabled', () => {
707
+ const world = createPhysics2DWorld(0, 0);
708
+ registerPhysics2DJointSolver(world, Physics2DRevoluteJointKind, physics2DRevoluteJointSolver);
709
+ const { anchor, arm } = hinged(world);
710
+ const joint = revoluteJoint(anchor.index, arm.index, {
711
+ enableMotor: true,
712
+ maxMotorTorque: 1,
713
+ motorSpeed: 10,
714
+ });
715
+ addPhysics2DJoint(world, joint);
716
+ stepPhysics2D(world, 1 / 60);
717
+
718
+ joint.enableMotor = false;
719
+ arm.angularVelocity = 0;
720
+ stepPhysics2D(world, 1 / 60);
721
+
722
+ expect(arm.angularVelocity).toBe(0);
723
+ });
724
+
725
+ it('stays still across many steps with the motor disabled', () => {
726
+ const world = createPhysics2DWorld(0, 0);
727
+ registerPhysics2DJointSolver(world, Physics2DRevoluteJointKind, physics2DRevoluteJointSolver);
728
+ const { anchor, arm } = hinged(world);
729
+ const joint = revoluteJoint(anchor.index, arm.index, {
730
+ enableMotor: true,
731
+ maxMotorTorque: 1,
732
+ motorSpeed: 10,
733
+ });
734
+ addPhysics2DJoint(world, joint);
735
+ for (let i = 0; i < 5; i++) stepPhysics2D(world, 1 / 60);
736
+
737
+ joint.enableMotor = false;
738
+ arm.angularVelocity = 0;
739
+ for (let i = 0; i < 20; i++) stepPhysics2D(world, 1 / 60);
740
+
741
+ expect(arm.angularVelocity).toBe(0);
742
+ });
743
+
744
+ // Clearing rather than merely skipping: a motor switched back on starts from rest, not from whatever
745
+ // the accumulator held when it was last enabled.
746
+ it('starts from rest when the motor is re-enabled rather than resuming a stale accumulator', () => {
747
+ const world = createPhysics2DWorld(0, 0);
748
+ registerPhysics2DJointSolver(world, Physics2DRevoluteJointKind, physics2DRevoluteJointSolver);
749
+ const { anchor, arm } = hinged(world);
750
+ const joint = revoluteJoint(anchor.index, arm.index, {
751
+ enableMotor: true,
752
+ maxMotorTorque: 1,
753
+ motorSpeed: 10,
754
+ });
755
+ addPhysics2DJoint(world, joint);
756
+ for (let i = 0; i < 5; i++) stepPhysics2D(world, 1 / 60);
757
+
758
+ joint.enableMotor = false;
759
+ stepPhysics2D(world, 1 / 60);
760
+ expect(joint.motorImpulse).toBe(0);
761
+
762
+ joint.enableMotor = true;
763
+ arm.angularVelocity = 0;
764
+ stepPhysics2D(world, 1 / 60);
765
+
766
+ // Acts again, and from a cold accumulator: one step of a 1 N·m budget, not a resumed total.
767
+ expect(arm.angularVelocity).toBeGreaterThan(0);
768
+ expect(Math.abs(joint.motorImpulse)).toBeLessThanOrEqual((1 / 60) * 1 * 1.0000001);
769
+ });
770
+
771
+ // Critic's repro: a joint authored OUTSIDE its interval. The header says limits clamp the angle into
772
+ // the interval, and the previous bias (remaining room, clamped at zero) went silent exactly when the
773
+ // angle was already out of range -- so a violated joint sitting still was never pushed back.
774
+ it('corrects an angle that is already outside the limit interval', () => {
775
+ const world = createPhysics2DWorld(0, 0);
776
+ registerPhysics2DJointSolver(world, Physics2DRevoluteJointKind, physics2DRevoluteJointSolver);
777
+ const { anchor, arm } = hinged(world);
778
+ arm.angle = 1;
779
+ addPhysics2DJoint(
780
+ world,
781
+ revoluteJoint(anchor.index, arm.index, { enableLimit: true, lowerAngle: -0.5, upperAngle: 0.5 }),
782
+ );
783
+
784
+ for (let i = 0; i < 30; i++) stepPhysics2D(world, 1 / 60);
785
+
786
+ expect(arm.angle).toBeLessThan(1);
787
+ expect(arm.angle).toBeLessThan(0.6);
788
+ });
789
+
790
+ it('corrects a violation of the lower bound too, not just the upper', () => {
791
+ const world = createPhysics2DWorld(0, 0);
792
+ registerPhysics2DJointSolver(world, Physics2DRevoluteJointKind, physics2DRevoluteJointSolver);
793
+ const { anchor, arm } = hinged(world);
794
+ arm.angle = -1;
795
+ addPhysics2DJoint(
796
+ world,
797
+ revoluteJoint(anchor.index, arm.index, { enableLimit: true, lowerAngle: -0.5, upperAngle: 0.5 }),
798
+ );
799
+
800
+ for (let i = 0; i < 30; i++) stepPhysics2D(world, 1 / 60);
801
+
802
+ expect(arm.angle).toBeGreaterThan(-0.6);
803
+ });
804
+
805
+ // The opposite failure, which the correcting form must not reintroduce: while the angle is INSIDE the
806
+ // interval the limit has to stay silent, or it brakes a motor nowhere near the bound.
807
+ it('stays silent while the angle is inside the interval', () => {
808
+ const world = createPhysics2DWorld(0, 0);
809
+ registerPhysics2DJointSolver(world, Physics2DRevoluteJointKind, physics2DRevoluteJointSolver);
810
+ const { anchor, arm } = hinged(world);
811
+ addPhysics2DJoint(
812
+ world,
813
+ revoluteJoint(anchor.index, arm.index, {
814
+ enableLimit: true,
815
+ lowerAngle: -3,
816
+ upperAngle: 3,
817
+ enableMotor: true,
818
+ maxMotorTorque: 100,
819
+ motorSpeed: 5,
820
+ }),
821
+ );
822
+
823
+ for (let i = 0; i < 20; i++) stepPhysics2D(world, 1 / 60);
824
+
825
+ expect(arm.angularVelocity).toBeGreaterThan(1);
826
+ });
827
+
828
+ it('stops the arm at the upper limit instead of letting the motor carry it past', () => {
829
+ const world = createPhysics2DWorld(0, 0);
830
+ registerPhysics2DJointSolver(world, Physics2DRevoluteJointKind, physics2DRevoluteJointSolver);
831
+ const { anchor, arm } = hinged(world);
832
+ addPhysics2DJoint(
833
+ world,
834
+ revoluteJoint(anchor.index, arm.index, {
835
+ enableLimit: true,
836
+ enableMotor: true,
837
+ lowerAngle: -0.2,
838
+ maxMotorTorque: 1000,
839
+ motorSpeed: 5,
840
+ upperAngle: 0.5,
841
+ }),
842
+ );
843
+ run(world, 120);
844
+ expect(arm.angle).toBeCloseTo(0.5, 6);
845
+ expect(arm.angularVelocity).toBeCloseTo(0, 6);
846
+ });
847
+
848
+ it('stops the arm at the lower limit when driven the other way', () => {
849
+ const world = createPhysics2DWorld(0, 0);
850
+ registerPhysics2DJointSolver(world, Physics2DRevoluteJointKind, physics2DRevoluteJointSolver);
851
+ const { anchor, arm } = hinged(world);
852
+ addPhysics2DJoint(
853
+ world,
854
+ revoluteJoint(anchor.index, arm.index, {
855
+ enableLimit: true,
856
+ enableMotor: true,
857
+ lowerAngle: -0.3,
858
+ maxMotorTorque: 1000,
859
+ motorSpeed: -5,
860
+ upperAngle: 0.5,
861
+ }),
862
+ );
863
+ run(world, 120);
864
+ expect(arm.angle).toBeCloseTo(-0.3, 6);
865
+ });
866
+
867
+ it('leaves the arm free to turn while it is inside the limit interval', () => {
868
+ // The regression that cost the most to find: a limit whose bias is the violation depth rather
869
+ // than the remaining room brakes ALL rotation, so an enabled limit the arm was nowhere near held
870
+ // it at a standstill while the motor spent its whole torque budget every step.
871
+ const world = createPhysics2DWorld(0, 0);
872
+ registerPhysics2DJointSolver(world, Physics2DRevoluteJointKind, physics2DRevoluteJointSolver);
873
+ const { anchor, arm } = hinged(world);
874
+ addPhysics2DJoint(
875
+ world,
876
+ revoluteJoint(anchor.index, arm.index, {
877
+ enableLimit: true,
878
+ enableMotor: true,
879
+ lowerAngle: -100,
880
+ maxMotorTorque: 1000,
881
+ motorSpeed: 5,
882
+ upperAngle: 100,
883
+ }),
884
+ );
885
+ run(world, 30);
886
+ expect(arm.angularVelocity).toBeCloseTo(5, 6);
887
+ });
888
+
889
+ it('exchanges and negates the limit interval when the ends are swapped', () => {
890
+ // lower <= (angleB - angleA - reference) <= upper. Reversing the ends negates that measurement, so
891
+ // the same physical interval requires the bounds to negate AND trade places.
892
+ const world = createPhysics2DWorld(0, 0);
893
+ registerPhysics2DJointSolver(world, Physics2DRevoluteJointKind, physics2DRevoluteJointSolver);
894
+ const second = box(world, 'dynamic', 0, 0);
895
+ const first = box(world, 'dynamic', 1, 0);
896
+ const joint = revoluteJoint(first.index, second.index, {
897
+ lowerAngle: -0.2,
898
+ motorSpeed: 3,
899
+ referenceAngle: 0.1,
900
+ upperAngle: 0.5,
901
+ });
902
+ addPhysics2DJoint(world, joint);
903
+ expect(joint.bodyA).toBe(second.index);
904
+ expect(joint.lowerAngle).toBeCloseTo(-0.5, 12);
905
+ expect(joint.upperAngle).toBeCloseTo(0.2, 12);
906
+ expect(joint.referenceAngle).toBeCloseTo(-0.1, 12);
907
+ expect(joint.motorSpeed).toBeCloseTo(-3, 12);
908
+ });
909
+ });
910
+
911
+ describe('physics2DRopeJointSolver', () => {
912
+ it('goes slack inside its length and catches the body at full extension', () => {
913
+ const world = createPhysics2DWorld();
914
+ registerPhysics2DJointSolver(world, Physics2DRopeJointKind, physics2DRopeJointSolver);
915
+ const anchor = box(world, 'static', 0, 10);
916
+ const bob = box(world, 'dynamic', 0, 9.5);
917
+ const joint: Physics2DRopeJoint = {
918
+ ...baseJoint(Physics2DRopeJointKind, anchor.index, bob.index),
919
+ maxLength: 3,
920
+ };
921
+ addPhysics2DJoint(world, joint);
922
+
923
+ run(world, 30);
924
+ // Still inside the rope's length: it must not have pulled the body back up.
925
+ expect(anchor.y - bob.y).toBeLessThan(3.05);
926
+ run(world, 300);
927
+ expect(anchor.y - bob.y).toBeLessThan(3.2);
928
+ expect(anchor.y - bob.y).toBeGreaterThan(2.8);
929
+ });
930
+ });
931
+
932
+ describe('physics2DWeldJointSolver', () => {
933
+ it('locks the relative angle as well as the anchor point', () => {
934
+ const world = createPhysics2DWorld();
935
+ registerPhysics2DJointSolver(world, Physics2DWeldJointKind, physics2DWeldJointSolver);
936
+ const post = box(world, 'static', 0, 5);
937
+ const welded = box(world, 'dynamic', 1, 5);
938
+ const joint: Physics2DWeldJoint = {
939
+ ...baseJoint(Physics2DWeldJointKind, post.index, welded.index),
940
+ localAnchorBX: -1,
941
+ localAnchorBY: 0,
942
+ referenceAngle: 0,
943
+ };
944
+ addPhysics2DJoint(world, joint);
945
+ run(world, 180);
946
+
947
+ // A revolute would have swung; a weld holds the angle.
948
+ expect(Math.abs(welded.angle)).toBeLessThan(0.2);
949
+ expect(Math.abs(welded.y - 5)).toBeLessThan(0.3);
950
+ });
951
+ });