@flighthq/physics2d 0.3.0-next.1032.e2b99fc → 0.3.0-next.1220.6982c21

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.
@@ -0,0 +1,648 @@
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
+ describe('physics2DDistanceJointSolver', () => {
249
+ it('holds a hanging body at the joint length instead of letting it fall', () => {
250
+ const world = createPhysics2DWorld();
251
+ registerPhysics2DJointSolver(world, Physics2DDistanceJointKind, physics2DDistanceJointSolver);
252
+ const anchor = box(world, 'static', 0, 5);
253
+ const bob = box(world, 'dynamic', 0, 3);
254
+ const joint: Physics2DDistanceJoint = {
255
+ ...baseJoint(Physics2DDistanceJointKind, anchor.index, bob.index),
256
+ length: 2,
257
+ stiffness: 0,
258
+ damping: 0,
259
+ };
260
+ addPhysics2DJoint(world, joint);
261
+ run(world, 240);
262
+
263
+ const separation = Math.sqrt((bob.x - anchor.x) ** 2 + (bob.y - anchor.y) ** 2);
264
+ expect(separation).toBeGreaterThan(1.9);
265
+ expect(separation).toBeLessThan(2.1);
266
+ });
267
+
268
+ it('swings a displaced bob back under the anchor rather than holding it out sideways', () => {
269
+ const world = createPhysics2DWorld();
270
+ registerPhysics2DJointSolver(world, Physics2DDistanceJointKind, physics2DDistanceJointSolver);
271
+ const anchor = box(world, 'static', 0, 5);
272
+ const bob = box(world, 'dynamic', 2, 5);
273
+ bob.linearDamping = 1.5;
274
+ const joint: Physics2DDistanceJoint = {
275
+ ...baseJoint(Physics2DDistanceJointKind, anchor.index, bob.index),
276
+ length: 2,
277
+ stiffness: 0,
278
+ damping: 0,
279
+ };
280
+ addPhysics2DJoint(world, joint);
281
+ run(world, 600);
282
+ expect(bob.y).toBeLessThan(4);
283
+ });
284
+ });
285
+
286
+ function revoluteJoint(
287
+ bodyA: number,
288
+ bodyB: number,
289
+ over: Partial<Physics2DRevoluteJoint> = {},
290
+ ): Physics2DRevoluteJoint {
291
+ return {
292
+ ...baseJoint(Physics2DRevoluteJointKind, bodyA, bodyB),
293
+ enableLimit: false,
294
+ enableMotor: false,
295
+ lowerAngle: 0,
296
+ maxMotorTorque: 0,
297
+ motorImpulse: 0,
298
+ motorSpeed: 0,
299
+ referenceAngle: 0,
300
+ upperAngle: 0,
301
+ ...over,
302
+ } as Physics2DRevoluteJoint;
303
+ }
304
+
305
+ describe('physics2DMouseJointSolver', () => {
306
+ it('drags a body toward its target', () => {
307
+ const world = createPhysics2DWorld(0, 0);
308
+ registerPhysics2DJointSolver(world, Physics2DMouseJointKind, physics2DMouseJointSolver);
309
+ const dragged = box(world, 'dynamic', 0, 0);
310
+ const joint: Physics2DMouseJoint = {
311
+ ...baseJoint(Physics2DMouseJointKind, dragged.index, dragged.index),
312
+ targetX: 5,
313
+ targetY: 0,
314
+ maxForce: 1000,
315
+ stiffness: 5,
316
+ damping: 0.7,
317
+ };
318
+ addPhysics2DJoint(world, joint);
319
+ run(world, 120);
320
+ expect(dragged.x).toBeGreaterThan(3);
321
+ });
322
+
323
+ it('respects its force bound rather than injecting unbounded energy', () => {
324
+ // The reason a mouse joint is soft by construction: a rigid drag lets a user pull a body arbitrarily
325
+ // fast simply by moving the cursor, and the rest of the world absorbs it.
326
+ //
327
+ // This assertion used to read `velocity < 60` with a maxForce of 0.5, which is not a force bound at
328
+ // all — it passed while the solver was clamping the ACCUMULATED IMPULSE against maxForce, a unit
329
+ // error worth 1/dt. The bound a force actually implies is dv <= F*dt/m per step, and that is what
330
+ // is asserted now: at maxForce 1, dt 0.01, mass 1 the step may add 0.01 and no more, where the old
331
+ // code added 1.0 and the old assertion waved it through.
332
+ const world = createPhysics2DWorld(0, 0);
333
+ registerPhysics2DJointSolver(world, Physics2DMouseJointKind, physics2DMouseJointSolver);
334
+ const dragged = box(world, 'dynamic', 0, 0);
335
+ const maxForce = 1;
336
+ const dt = 0.01;
337
+ const joint: Physics2DMouseJoint = {
338
+ ...baseJoint(Physics2DMouseJointKind, dragged.index, dragged.index),
339
+ targetX: 1000,
340
+ targetY: 0,
341
+ maxForce,
342
+ stiffness: 5,
343
+ damping: 0.7,
344
+ };
345
+ addPhysics2DJoint(world, joint);
346
+
347
+ const perStep = (maxForce * dt) / (1 / dragged.inverseMass);
348
+ let previous = dragged.velocityX;
349
+ for (let i = 0; i < 20; i++) {
350
+ stepPhysics2D(world, dt);
351
+ expect(Math.abs(dragged.velocityX - previous)).toBeLessThanOrEqual(perStep * 1.0000001);
352
+ previous = dragged.velocityX;
353
+ }
354
+ expect(Number.isFinite(dragged.x)).toBe(true);
355
+ });
356
+
357
+ it('scales the impulse bound with the timestep, so the force means the same at any dt', () => {
358
+ // A force bound that ignored dt would let a smaller step inject the same impulse more often.
359
+ const speeds: number[] = [];
360
+ for (const dt of [0.02, 0.01]) {
361
+ const world = createPhysics2DWorld(0, 0);
362
+ registerPhysics2DJointSolver(world, Physics2DMouseJointKind, physics2DMouseJointSolver);
363
+ const dragged = box(world, 'dynamic', 0, 0);
364
+ const joint: Physics2DMouseJoint = {
365
+ ...baseJoint(Physics2DMouseJointKind, dragged.index, dragged.index),
366
+ targetX: 1000,
367
+ targetY: 0,
368
+ maxForce: 1,
369
+ stiffness: 5,
370
+ damping: 0.7,
371
+ };
372
+ addPhysics2DJoint(world, joint);
373
+ stepPhysics2D(world, dt);
374
+ speeds.push(Math.abs(dragged.velocityX));
375
+ }
376
+ // Halving dt halves the impulse a single step may deliver.
377
+ expect(speeds[1]).toBeCloseTo(speeds[0] / 2, 10);
378
+ });
379
+
380
+ it('drags the dynamic body even when it was added before its anchor', () => {
381
+ // Canonical ordering swaps a joint's ends when the caller supplies them low-index-second. A mouse
382
+ // joint has only one real body — the other end is a world-space target — so the exchange moved the
383
+ // dragged body into bodyA while this solver acts on bodyB, and the drag silently applied to the
384
+ // anchor. The dragged body did not move at all.
385
+ const world = createPhysics2DWorld(0, 0);
386
+ registerPhysics2DJointSolver(world, Physics2DMouseJointKind, physics2DMouseJointSolver);
387
+ const dragged = box(world, 'dynamic', 0, 0);
388
+ const anchor = box(world, 'static', 0, 0);
389
+ expect(dragged.index).toBeLessThan(anchor.index);
390
+
391
+ const joint: Physics2DMouseJoint = {
392
+ ...baseJoint(Physics2DMouseJointKind, anchor.index, dragged.index),
393
+ targetX: 5,
394
+ targetY: 0,
395
+ maxForce: 1000,
396
+ stiffness: 5,
397
+ damping: 0.7,
398
+ };
399
+ addPhysics2DJoint(world, joint);
400
+ expect(joint.bodyB).toBe(dragged.index);
401
+
402
+ run(world, 60);
403
+ expect(dragged.x).toBeGreaterThan(3);
404
+ expect(anchor.x).toBe(0);
405
+ });
406
+
407
+ it('drags the dynamic body when it was added after its anchor', () => {
408
+ // The orientation that already worked, kept so the veto cannot be "fixed" by breaking this one.
409
+ const world = createPhysics2DWorld(0, 0);
410
+ registerPhysics2DJointSolver(world, Physics2DMouseJointKind, physics2DMouseJointSolver);
411
+ const anchor = box(world, 'static', 0, 0);
412
+ const dragged = box(world, 'dynamic', 0, 0);
413
+ const joint: Physics2DMouseJoint = {
414
+ ...baseJoint(Physics2DMouseJointKind, anchor.index, dragged.index),
415
+ targetX: 5,
416
+ targetY: 0,
417
+ maxForce: 1000,
418
+ stiffness: 5,
419
+ damping: 0.7,
420
+ };
421
+ addPhysics2DJoint(world, joint);
422
+ expect(joint.bodyB).toBe(dragged.index);
423
+ run(world, 60);
424
+ expect(dragged.x).toBeGreaterThan(3);
425
+ });
426
+ });
427
+
428
+ describe('physics2DRevoluteJointSolver', () => {
429
+ it('keeps the pinned anchors together while leaving rotation free', () => {
430
+ const world = createPhysics2DWorld();
431
+ registerPhysics2DJointSolver(world, Physics2DRevoluteJointKind, physics2DRevoluteJointSolver);
432
+ const anchor = box(world, 'static', 0, 5);
433
+ const arm = box(world, 'dynamic', 1, 5);
434
+ const joint = {
435
+ ...baseJoint(Physics2DRevoluteJointKind, anchor.index, arm.index),
436
+ localAnchorAX: 0,
437
+ localAnchorAY: 0,
438
+ localAnchorBX: -1,
439
+ localAnchorBY: 0,
440
+ };
441
+ addPhysics2DJoint(world, joint);
442
+ run(world, 180);
443
+
444
+ // The arm's anchor point must stay on the static body's anchor.
445
+ const cos = Math.cos(arm.angle);
446
+ const sin = Math.sin(arm.angle);
447
+ const anchorX = arm.x + -1 * cos - 0 * sin;
448
+ const anchorY = arm.y + -1 * sin + 0 * cos;
449
+ expect(Math.abs(anchorX - anchor.x)).toBeLessThan(0.15);
450
+ expect(Math.abs(anchorY - anchor.y)).toBeLessThan(0.15);
451
+ // Rotation is free, so gravity must have swung it.
452
+ expect(Math.abs(arm.angle)).toBeGreaterThan(0.05);
453
+ });
454
+ });
455
+
456
+ describe('physics2DRevoluteJointSolver motor and limits', () => {
457
+ // The type advertises a motor and an angular limit; the solver read none of those fields, so an
458
+ // enabled motor left the arm at exactly zero angular velocity. These are the behaviours the header
459
+ // promises, asserted against the numbers the promise implies rather than against "it moved".
460
+ function hinged(world: Physics2DWorld) {
461
+ const anchor = box(world, 'static', 0, 0);
462
+ const arm = box(world, 'dynamic', 0, 0);
463
+ return { anchor, arm };
464
+ }
465
+
466
+ it('drives the relative angular velocity to the motor speed', () => {
467
+ const world = createPhysics2DWorld(0, 0);
468
+ registerPhysics2DJointSolver(world, Physics2DRevoluteJointKind, physics2DRevoluteJointSolver);
469
+ const { anchor, arm } = hinged(world);
470
+ addPhysics2DJoint(
471
+ world,
472
+ revoluteJoint(anchor.index, arm.index, { enableMotor: true, maxMotorTorque: 100, motorSpeed: 2 }),
473
+ );
474
+ run(world, 30);
475
+ expect(arm.angularVelocity).toBeCloseTo(2, 6);
476
+ // Half a second at 2 rad/s.
477
+ expect(arm.angle).toBeCloseTo(1, 3);
478
+ });
479
+
480
+ it('drives the relative angle the other way for a negative motor speed', () => {
481
+ const world = createPhysics2DWorld(0, 0);
482
+ registerPhysics2DJointSolver(world, Physics2DRevoluteJointKind, physics2DRevoluteJointSolver);
483
+ const { anchor, arm } = hinged(world);
484
+ addPhysics2DJoint(
485
+ world,
486
+ revoluteJoint(anchor.index, arm.index, { enableMotor: true, maxMotorTorque: 100, motorSpeed: -2 }),
487
+ );
488
+ run(world, 30);
489
+ expect(arm.angularVelocity).toBeCloseTo(-2, 6);
490
+ });
491
+
492
+ it('does not drive the arm at all when the motor is disabled', () => {
493
+ const world = createPhysics2DWorld(0, 0);
494
+ registerPhysics2DJointSolver(world, Physics2DRevoluteJointKind, physics2DRevoluteJointSolver);
495
+ const { anchor, arm } = hinged(world);
496
+ addPhysics2DJoint(
497
+ world,
498
+ revoluteJoint(anchor.index, arm.index, { enableMotor: false, maxMotorTorque: 100, motorSpeed: 2 }),
499
+ );
500
+ run(world, 30);
501
+ expect(arm.angularVelocity).toBeCloseTo(0, 9);
502
+ });
503
+
504
+ it('bounds the motor by its torque, so a small budget cannot reach the target speed', () => {
505
+ // The torque bound is a torque: the accumulated impulse may not exceed maxMotorTorque * dt.
506
+ const world = createPhysics2DWorld(0, 0);
507
+ registerPhysics2DJointSolver(world, Physics2DRevoluteJointKind, physics2DRevoluteJointSolver);
508
+ const { anchor, arm } = hinged(world);
509
+ const maxMotorTorque = 0.01;
510
+ addPhysics2DJoint(
511
+ world,
512
+ revoluteJoint(anchor.index, arm.index, { enableMotor: true, maxMotorTorque, motorSpeed: 100 }),
513
+ );
514
+ const dt = 1 / 60;
515
+ let previous = arm.angularVelocity;
516
+ for (let i = 0; i < 10; i++) {
517
+ stepPhysics2D(world, dt);
518
+ const perStep = maxMotorTorque * dt * arm.inverseInertia;
519
+ expect(Math.abs(arm.angularVelocity - previous)).toBeLessThanOrEqual(perStep * 1.0000001);
520
+ previous = arm.angularVelocity;
521
+ }
522
+ expect(arm.angularVelocity).toBeLessThan(100);
523
+ });
524
+
525
+ it('stops the arm at the upper limit instead of letting the motor carry it past', () => {
526
+ const world = createPhysics2DWorld(0, 0);
527
+ registerPhysics2DJointSolver(world, Physics2DRevoluteJointKind, physics2DRevoluteJointSolver);
528
+ const { anchor, arm } = hinged(world);
529
+ addPhysics2DJoint(
530
+ world,
531
+ revoluteJoint(anchor.index, arm.index, {
532
+ enableLimit: true,
533
+ enableMotor: true,
534
+ lowerAngle: -0.2,
535
+ maxMotorTorque: 1000,
536
+ motorSpeed: 5,
537
+ upperAngle: 0.5,
538
+ }),
539
+ );
540
+ run(world, 120);
541
+ expect(arm.angle).toBeCloseTo(0.5, 6);
542
+ expect(arm.angularVelocity).toBeCloseTo(0, 6);
543
+ });
544
+
545
+ it('stops the arm at the lower limit when driven the other way', () => {
546
+ const world = createPhysics2DWorld(0, 0);
547
+ registerPhysics2DJointSolver(world, Physics2DRevoluteJointKind, physics2DRevoluteJointSolver);
548
+ const { anchor, arm } = hinged(world);
549
+ addPhysics2DJoint(
550
+ world,
551
+ revoluteJoint(anchor.index, arm.index, {
552
+ enableLimit: true,
553
+ enableMotor: true,
554
+ lowerAngle: -0.3,
555
+ maxMotorTorque: 1000,
556
+ motorSpeed: -5,
557
+ upperAngle: 0.5,
558
+ }),
559
+ );
560
+ run(world, 120);
561
+ expect(arm.angle).toBeCloseTo(-0.3, 6);
562
+ });
563
+
564
+ it('leaves the arm free to turn while it is inside the limit interval', () => {
565
+ // The regression that cost the most to find: a limit whose bias is the violation depth rather
566
+ // than the remaining room brakes ALL rotation, so an enabled limit the arm was nowhere near held
567
+ // it at a standstill while the motor spent its whole torque budget every step.
568
+ const world = createPhysics2DWorld(0, 0);
569
+ registerPhysics2DJointSolver(world, Physics2DRevoluteJointKind, physics2DRevoluteJointSolver);
570
+ const { anchor, arm } = hinged(world);
571
+ addPhysics2DJoint(
572
+ world,
573
+ revoluteJoint(anchor.index, arm.index, {
574
+ enableLimit: true,
575
+ enableMotor: true,
576
+ lowerAngle: -100,
577
+ maxMotorTorque: 1000,
578
+ motorSpeed: 5,
579
+ upperAngle: 100,
580
+ }),
581
+ );
582
+ run(world, 30);
583
+ expect(arm.angularVelocity).toBeCloseTo(5, 6);
584
+ });
585
+
586
+ it('exchanges and negates the limit interval when the ends are swapped', () => {
587
+ // lower <= (angleB - angleA - reference) <= upper. Reversing the ends negates that measurement, so
588
+ // the same physical interval requires the bounds to negate AND trade places.
589
+ const world = createPhysics2DWorld(0, 0);
590
+ registerPhysics2DJointSolver(world, Physics2DRevoluteJointKind, physics2DRevoluteJointSolver);
591
+ const second = box(world, 'dynamic', 0, 0);
592
+ const first = box(world, 'dynamic', 1, 0);
593
+ const joint = revoluteJoint(first.index, second.index, {
594
+ lowerAngle: -0.2,
595
+ motorSpeed: 3,
596
+ referenceAngle: 0.1,
597
+ upperAngle: 0.5,
598
+ });
599
+ addPhysics2DJoint(world, joint);
600
+ expect(joint.bodyA).toBe(second.index);
601
+ expect(joint.lowerAngle).toBeCloseTo(-0.5, 12);
602
+ expect(joint.upperAngle).toBeCloseTo(0.2, 12);
603
+ expect(joint.referenceAngle).toBeCloseTo(-0.1, 12);
604
+ expect(joint.motorSpeed).toBeCloseTo(-3, 12);
605
+ });
606
+ });
607
+
608
+ describe('physics2DRopeJointSolver', () => {
609
+ it('goes slack inside its length and catches the body at full extension', () => {
610
+ const world = createPhysics2DWorld();
611
+ registerPhysics2DJointSolver(world, Physics2DRopeJointKind, physics2DRopeJointSolver);
612
+ const anchor = box(world, 'static', 0, 10);
613
+ const bob = box(world, 'dynamic', 0, 9.5);
614
+ const joint: Physics2DRopeJoint = {
615
+ ...baseJoint(Physics2DRopeJointKind, anchor.index, bob.index),
616
+ maxLength: 3,
617
+ };
618
+ addPhysics2DJoint(world, joint);
619
+
620
+ run(world, 30);
621
+ // Still inside the rope's length: it must not have pulled the body back up.
622
+ expect(anchor.y - bob.y).toBeLessThan(3.05);
623
+ run(world, 300);
624
+ expect(anchor.y - bob.y).toBeLessThan(3.2);
625
+ expect(anchor.y - bob.y).toBeGreaterThan(2.8);
626
+ });
627
+ });
628
+
629
+ describe('physics2DWeldJointSolver', () => {
630
+ it('locks the relative angle as well as the anchor point', () => {
631
+ const world = createPhysics2DWorld();
632
+ registerPhysics2DJointSolver(world, Physics2DWeldJointKind, physics2DWeldJointSolver);
633
+ const post = box(world, 'static', 0, 5);
634
+ const welded = box(world, 'dynamic', 1, 5);
635
+ const joint: Physics2DWeldJoint = {
636
+ ...baseJoint(Physics2DWeldJointKind, post.index, welded.index),
637
+ localAnchorBX: -1,
638
+ localAnchorBY: 0,
639
+ referenceAngle: 0,
640
+ };
641
+ addPhysics2DJoint(world, joint);
642
+ run(world, 180);
643
+
644
+ // A revolute would have swung; a weld holds the angle.
645
+ expect(Math.abs(welded.angle)).toBeLessThan(0.2);
646
+ expect(Math.abs(welded.y - 5)).toBeLessThan(0.3);
647
+ });
648
+ });
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
3
3
 
4
4
  import {
5
5
  applyPhysics2DImpulse,
6
+ solvePhysics2DContactsOnce,
6
7
  relativeNormalVelocity,
7
8
  solvePhysics2DContacts,
8
9
  warmStartPhysics2DContacts,
@@ -140,6 +141,24 @@ describe('solvePhysics2DContacts', () => {
140
141
  });
141
142
  });
142
143
 
144
+ describe('solvePhysics2DContactsOnce', () => {
145
+ it('applies one pass, so the step can interleave contacts with joints inside a single iteration', () => {
146
+ // Joints and contacts constrain the same bodies. Giving either a whole pass to itself lets it undo
147
+ // what the other just corrected, which is why the step alternates them rather than running one solver
148
+ // to convergence and then the other.
149
+ const { world, crate } = restingWorld();
150
+ crate.velocityY = -5;
151
+ stepPhysics2D(world, 1 / 60);
152
+ const before = crate.velocityY;
153
+ solvePhysics2DContactsOnce(world);
154
+ expect(crate.velocityY).not.toBe(before);
155
+ });
156
+
157
+ it('does nothing for a world with no contacts', () => {
158
+ expect(() => solvePhysics2DContactsOnce(createPhysics2DWorld())).not.toThrow();
159
+ });
160
+ });
161
+
143
162
  describe('warmStartPhysics2DContacts', () => {
144
163
  it('reapplies the cached impulse, leaving a converged resting contact deeper than a cold start', () => {
145
164
  // The measurable effect of warm starting: a settled stack keeps its impulses and stays put, where a