@flighthq/physics3d-abi 0.4.0-next.1827.9db114b

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 (39) hide show
  1. package/dist/contract.d.ts +6 -0
  2. package/dist/contract.d.ts.map +1 -0
  3. package/dist/contract.js +6 -0
  4. package/dist/contract.js.map +1 -0
  5. package/dist/index.d.ts +2 -0
  6. package/dist/index.d.ts.map +1 -0
  7. package/dist/index.js +2 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/physics3DAbi.d.ts +11 -0
  10. package/dist/physics3DAbi.d.ts.map +1 -0
  11. package/dist/physics3DAbi.js +32 -0
  12. package/dist/physics3DAbi.js.map +1 -0
  13. package/dist/physics3DAbiBuffer.d.ts +10 -0
  14. package/dist/physics3DAbiBuffer.d.ts.map +1 -0
  15. package/dist/physics3DAbiBuffer.js +81 -0
  16. package/dist/physics3DAbiBuffer.js.map +1 -0
  17. package/dist/physics3DAbiCommand.d.ts +17 -0
  18. package/dist/physics3DAbiCommand.d.ts.map +1 -0
  19. package/dist/physics3DAbiCommand.js +554 -0
  20. package/dist/physics3DAbiCommand.js.map +1 -0
  21. package/dist/physics3DAbiLayout.d.ts +216 -0
  22. package/dist/physics3DAbiLayout.d.ts.map +1 -0
  23. package/dist/physics3DAbiLayout.js +221 -0
  24. package/dist/physics3DAbiLayout.js.map +1 -0
  25. package/dist/physics3DAbiQuery.d.ts +7 -0
  26. package/dist/physics3DAbiQuery.d.ts.map +1 -0
  27. package/dist/physics3DAbiQuery.js +16 -0
  28. package/dist/physics3DAbiQuery.js.map +1 -0
  29. package/dist/referencePhysics3DAbi.d.ts +3 -0
  30. package/dist/referencePhysics3DAbi.d.ts.map +1 -0
  31. package/dist/referencePhysics3DAbi.js +1165 -0
  32. package/dist/referencePhysics3DAbi.js.map +1 -0
  33. package/package.json +50 -0
  34. package/src/physics3DAbi.test.ts +584 -0
  35. package/src/physics3DAbiBuffer.test.ts +117 -0
  36. package/src/physics3DAbiCommand.test.ts +368 -0
  37. package/src/physics3DAbiLayout.test.ts +64 -0
  38. package/src/physics3DAbiQuery.test.ts +122 -0
  39. package/src/referencePhysics3DAbi.test.ts +19 -0
@@ -0,0 +1,584 @@
1
+ import {
2
+ addPhysics3DBody,
3
+ addPhysics3DCollider,
4
+ createPhysics3DBallAndSocketJoint,
5
+ createPhysics3DCollider,
6
+ createPhysics3DConeTwistJoint,
7
+ createPhysics3DDistanceJoint,
8
+ createPhysics3DFixedJoint,
9
+ createPhysics3DGeneric6DofJoint,
10
+ createPhysics3DHingeJoint,
11
+ createPhysics3DSolverConfig,
12
+ createPhysics3DSliderJoint,
13
+ createPhysics3DWorld,
14
+ createRigidBody3D,
15
+ stepPhysics3D,
16
+ } from '@flighthq/physics3d/contract';
17
+ import type {
18
+ CollisionColliderShape3D,
19
+ Physics3DAbi,
20
+ Physics3DAbiBodyBuffer,
21
+ Physics3DAbiCommandBuffer,
22
+ Physics3DJoint,
23
+ RigidBody3D,
24
+ } from '@flighthq/types/contract';
25
+ import { describe, expect, it } from 'vitest';
26
+
27
+ import {
28
+ createPhysics3DAbi,
29
+ createPhysics3DAbiWorld,
30
+ destroyPhysics3DAbiWorld,
31
+ executePhysics3DAbiCommands,
32
+ getPhysics3DAbiWorldStatus,
33
+ readPhysics3DAbiBodies,
34
+ readPhysics3DAbiContacts,
35
+ readPhysics3DAbiJoints,
36
+ stepPhysics3DAbiWorld,
37
+ } from './physics3DAbi';
38
+ import {
39
+ clearPhysics3DAbiCommandBuffer,
40
+ createPhysics3DAbiBodyBuffer,
41
+ createPhysics3DAbiCommandBuffer,
42
+ createPhysics3DAbiContactBuffer,
43
+ createPhysics3DAbiExecutionResult,
44
+ createPhysics3DAbiJointBuffer,
45
+ createPhysics3DAbiQueryBuffer,
46
+ } from './physics3DAbiBuffer';
47
+ import {
48
+ writePhysics3DAbiApplyForceAtPointCommand,
49
+ writePhysics3DAbiApplyForceCommand,
50
+ writePhysics3DAbiApplyLinearImpulseAtPointCommand,
51
+ writePhysics3DAbiApplyLinearImpulseCommand,
52
+ writePhysics3DAbiApplyTorqueCommand,
53
+ writePhysics3DAbiDestroyBodyCommand,
54
+ writePhysics3DAbiDestroyColliderCommand,
55
+ writePhysics3DAbiDestroyJointCommand,
56
+ writePhysics3DAbiSetBodyCommand,
57
+ writePhysics3DAbiSetColliderCommand,
58
+ writePhysics3DAbiSetGravityCommand,
59
+ writePhysics3DAbiSetJointCommand,
60
+ writePhysics3DAbiSetSolverConfigCommand,
61
+ writePhysics3DAbiWakeBodyCommand,
62
+ } from './physics3DAbiCommand';
63
+ import {
64
+ Physics3DAbiBodyValue,
65
+ Physics3DAbiBodyValueStride,
66
+ Physics3DAbiCapability,
67
+ Physics3DAbiCommandHeaderByteLength,
68
+ Physics3DAbiVersion,
69
+ } from './physics3DAbiLayout';
70
+ import { queryPhysics3DAbiPoint } from './physics3DAbiQuery';
71
+
72
+ describe('createPhysics3DAbi', () => {
73
+ it('publishes the reference version and required baseline capabilities', () => {
74
+ const abi = createPhysics3DAbi();
75
+ expect(abi.version).toBe(Physics3DAbiVersion);
76
+ expect(abi.capabilities).toBe(
77
+ Physics3DAbiCapability.ContactHooks |
78
+ Physics3DAbiCapability.PersistentWorlds |
79
+ Physics3DAbiCapability.Queries |
80
+ Physics3DAbiCapability.SelectiveReadback,
81
+ );
82
+ });
83
+
84
+ it('decodes all nine built-in collider shapes through the public command stream', () => {
85
+ const abi = createPhysics3DAbi();
86
+ const world = createPhysics3DAbiWorld(abi);
87
+ const commands = createPhysics3DAbiCommandBuffer(32768);
88
+ const body = createRigidBody3D('static');
89
+ expect(writePhysics3DAbiSetBodyCommand(commands, 1, body)).toBe(true);
90
+ const shapes = getColliderShapes();
91
+ for (let i = 0; i < shapes.length; i += 1) {
92
+ expect(writePhysics3DAbiSetColliderCommand(commands, i + 1, 1, createPhysics3DCollider(shapes[i]))).toBe(true);
93
+ }
94
+
95
+ const result = createPhysics3DAbiExecutionResult();
96
+ expect(executePhysics3DAbiCommands(abi, world, commands, result)).toBe(true);
97
+ expect(result.commandIndex).toBe(1 + shapes.length);
98
+ });
99
+
100
+ it('executes and reads through caller-owned shared-memory views', () => {
101
+ const abi = createPhysics3DAbi();
102
+ const world = createPhysics3DAbiWorld(abi);
103
+ const commands: Physics3DAbiCommandBuffer = {
104
+ data: new Uint8Array(new SharedArrayBuffer(512)),
105
+ byteLength: 0,
106
+ commandCount: 0,
107
+ };
108
+ clearPhysics3DAbiCommandBuffer(commands);
109
+ const body = createRigidBody3D();
110
+ body.x = 17;
111
+ expect(writePhysics3DAbiSetBodyCommand(commands, 3, body)).toBe(true);
112
+ executeOrThrow(abi, world, commands);
113
+
114
+ const out: Physics3DAbiBodyBuffer = {
115
+ ids: new Uint32Array(new SharedArrayBuffer(Uint32Array.BYTES_PER_ELEMENT)),
116
+ flags: new Uint32Array(new SharedArrayBuffer(Uint32Array.BYTES_PER_ELEMENT)),
117
+ values: new Float64Array(new SharedArrayBuffer(Physics3DAbiBodyValueStride * Float64Array.BYTES_PER_ELEMENT)),
118
+ count: 0,
119
+ requiredCount: 0,
120
+ };
121
+ expect(readPhysics3DAbiBodies(abi, world, null, out)).toBe(true);
122
+ expect(out.ids[0]).toBe(3);
123
+ expect(out.values[Physics3DAbiBodyValue.X]).toBe(17);
124
+ });
125
+ });
126
+
127
+ describe('createPhysics3DAbiWorld', () => {
128
+ it('allocates non-zero handles that are never reused by one ABI instance', () => {
129
+ const abi = createPhysics3DAbi();
130
+ const first = createPhysics3DAbiWorld(abi);
131
+ expect(first).toBeGreaterThan(0);
132
+ expect(destroyPhysics3DAbiWorld(abi, first)).toBe(true);
133
+ const second = createPhysics3DAbiWorld(abi);
134
+ expect(second).toBeGreaterThan(first);
135
+ });
136
+ });
137
+
138
+ describe('destroyPhysics3DAbiWorld', () => {
139
+ it('makes every later operation on that handle observably stale', () => {
140
+ const abi = createPhysics3DAbi();
141
+ const world = createPhysics3DAbiWorld(abi);
142
+ expect(destroyPhysics3DAbiWorld(abi, world)).toBe(true);
143
+ expect(destroyPhysics3DAbiWorld(abi, world)).toBe(false);
144
+ expect(stepPhysics3DAbiWorld(abi, world, 1 / 60)).toBe('StaleWorld');
145
+ expect(readPhysics3DAbiBodies(abi, world, null, createPhysics3DAbiBodyBuffer(1))).toBe(false);
146
+ });
147
+ });
148
+
149
+ describe('executePhysics3DAbiCommands', () => {
150
+ it('commits the valid prefix and identifies the first rejected command', () => {
151
+ const abi = createPhysics3DAbi();
152
+ const world = createPhysics3DAbiWorld(abi);
153
+ const commands = createPhysics3DAbiCommandBuffer();
154
+ const body = createRigidBody3D();
155
+ body.x = 6;
156
+ expect(writePhysics3DAbiSetBodyCommand(commands, 5, body)).toBe(true);
157
+ expect(writePhysics3DAbiDestroyBodyCommand(commands, 999)).toBe(true);
158
+
159
+ const result = createPhysics3DAbiExecutionResult();
160
+ expect(executePhysics3DAbiCommands(abi, world, commands, result)).toBe(false);
161
+ expect(result).toMatchObject({ status: 'MissingBody', commandIndex: 1 });
162
+
163
+ const bodies = createPhysics3DAbiBodyBuffer(1);
164
+ expect(readPhysics3DAbiBodies(abi, world, null, bodies)).toBe(true);
165
+ expect(bodies.ids[0]).toBe(5);
166
+ expect(bodies.values[Physics3DAbiBodyValue.X]).toBe(6);
167
+ });
168
+
169
+ it('rejects a corrupted header before applying anything', () => {
170
+ const abi = createPhysics3DAbi();
171
+ const world = createPhysics3DAbiWorld(abi);
172
+ const commands = createPhysics3DAbiCommandBuffer();
173
+ writePhysics3DAbiSetGravityCommand(commands, 1, 2, 3);
174
+ commands.data[0] ^= 0xff;
175
+ const result = createPhysics3DAbiExecutionResult();
176
+
177
+ expect(executePhysics3DAbiCommands(abi, world, commands, result)).toBe(false);
178
+ expect(result).toEqual({
179
+ status: 'InvalidBuffer',
180
+ commandIndex: 0,
181
+ byteOffset: Physics3DAbiCommandHeaderByteLength,
182
+ commandKind: 0,
183
+ });
184
+ });
185
+
186
+ it('rejects unknown flag bits rather than silently adopting future semantics', () => {
187
+ const abi = createPhysics3DAbi();
188
+ const world = createPhysics3DAbiWorld(abi);
189
+ const commands = createPhysics3DAbiCommandBuffer();
190
+ writePhysics3DAbiSetBodyCommand(commands, 1, createRigidBody3D());
191
+ new DataView(commands.data.buffer).setUint32(32, 1 << 31, true);
192
+ const result = createPhysics3DAbiExecutionResult();
193
+
194
+ expect(executePhysics3DAbiCommands(abi, world, commands, result)).toBe(false);
195
+ expect(result.status).toBe('InvalidCommand');
196
+ });
197
+
198
+ it('applies configuration, force, impulse, torque, and wake commands to persistent state', () => {
199
+ const abi = createPhysics3DAbi();
200
+ const world = createPhysics3DAbiWorld(abi);
201
+ const body = createRigidBody3D();
202
+ body.mass = 2;
203
+ body.inertiaXX = 4;
204
+ body.inertiaYY = 4;
205
+ body.inertiaZZ = 4;
206
+ body.sleeping = true;
207
+ const config = createPhysics3DSolverConfig();
208
+ config.substeps = 2;
209
+
210
+ executeOrThrow(
211
+ abi,
212
+ world,
213
+ writeCommands((commands) => {
214
+ writePhysics3DAbiSetGravityCommand(commands, 0, 0, 0);
215
+ writePhysics3DAbiSetSolverConfigCommand(commands, config);
216
+ writePhysics3DAbiSetBodyCommand(commands, 7, body);
217
+ writePhysics3DAbiApplyForceCommand(commands, 7, 2, 0, 0);
218
+ writePhysics3DAbiApplyForceAtPointCommand(commands, 7, 2, 0, 0, 0, 1, 0);
219
+ writePhysics3DAbiApplyLinearImpulseCommand(commands, 7, 2, 0, 0);
220
+ writePhysics3DAbiApplyLinearImpulseAtPointCommand(commands, 7, 2, 0, 0, 0, 1, 0);
221
+ writePhysics3DAbiApplyTorqueCommand(commands, 7, 0, 0, 6);
222
+ writePhysics3DAbiWakeBodyCommand(commands, 7);
223
+ }),
224
+ );
225
+
226
+ const beforeStep = createPhysics3DAbiBodyBuffer(1);
227
+ expect(readPhysics3DAbiBodies(abi, world, null, beforeStep)).toBe(true);
228
+ expect(beforeStep.flags[0] & (1 << 4)).toBe(0);
229
+ expect(beforeStep.values[Physics3DAbiBodyValue.VelocityX]).toBeCloseTo(2, 12);
230
+ expect(beforeStep.values[Physics3DAbiBodyValue.AngularVelocityZ]).toBeCloseTo(-0.5, 12);
231
+ expect(beforeStep.values[Physics3DAbiBodyValue.ForceX]).toBe(4);
232
+ expect(beforeStep.values[Physics3DAbiBodyValue.TorqueZ]).toBe(4);
233
+
234
+ expect(stepPhysics3DAbiWorld(abi, world, 0.5)).toBe('Complete');
235
+ const afterStep = createPhysics3DAbiBodyBuffer(1);
236
+ expect(readPhysics3DAbiBodies(abi, world, null, afterStep)).toBe(true);
237
+ expect(afterStep.values[Physics3DAbiBodyValue.VelocityX]).toBeCloseTo(3, 12);
238
+ expect(afterStep.values[Physics3DAbiBodyValue.VelocityY]).toBe(0);
239
+ expect(afterStep.values[Physics3DAbiBodyValue.ForceX]).toBe(0);
240
+ expect(afterStep.values[Physics3DAbiBodyValue.TorqueZ]).toBe(0);
241
+ });
242
+
243
+ it('removes collider identity and broadphase query visibility atomically', () => {
244
+ const abi = createPhysics3DAbi();
245
+ const world = createPhysics3DAbiWorld(abi);
246
+ const body = createRigidBody3D('static');
247
+ executeOrThrow(
248
+ abi,
249
+ world,
250
+ writeCommands((commands) => {
251
+ writePhysics3DAbiSetBodyCommand(commands, 1, body);
252
+ writePhysics3DAbiSetColliderCommand(commands, 2, 1, createPhysics3DCollider(unitAabb()));
253
+ }),
254
+ );
255
+ const hits = createPhysics3DAbiQueryBuffer(1);
256
+ expect(queryPhysics3DAbiPoint(abi, world, 0, 0, 0, hits)).toBe(true);
257
+ expect([hits.count, hits.requiredCount]).toEqual([1, 1]);
258
+
259
+ executeOrThrow(
260
+ abi,
261
+ world,
262
+ writeCommands((commands) => {
263
+ writePhysics3DAbiDestroyColliderCommand(commands, 2);
264
+ }),
265
+ );
266
+ expect(queryPhysics3DAbiPoint(abi, world, 0, 0, 0, hits)).toBe(true);
267
+ expect([hits.count, hits.requiredCount]).toEqual([0, 0]);
268
+ });
269
+ });
270
+
271
+ describe('getPhysics3DAbiWorldStatus', () => {
272
+ it('distinguishes a usable handle from a destroyed one', () => {
273
+ const abi = createPhysics3DAbi();
274
+ const world = createPhysics3DAbiWorld(abi);
275
+ expect(getPhysics3DAbiWorldStatus(abi, world)).toBe('Ready');
276
+ destroyPhysics3DAbiWorld(abi, world);
277
+ expect(getPhysics3DAbiWorldStatus(abi, world)).toBe('Stale');
278
+ });
279
+ });
280
+
281
+ describe('readPhysics3DAbiBodies', () => {
282
+ it('reports a sorted full count and writes only the prefix that fits', () => {
283
+ const { abi, world } = createBodyReadWorld();
284
+ const out = createPhysics3DAbiBodyBuffer(1);
285
+
286
+ expect(readPhysics3DAbiBodies(abi, world, null, out)).toBe(true);
287
+ expect(out.requiredCount).toBe(2);
288
+ expect(out.count).toBe(1);
289
+ expect(out.ids[0]).toBe(10);
290
+ });
291
+
292
+ it('preserves selective caller order and silently omits missing ids', () => {
293
+ const { abi, world } = createBodyReadWorld();
294
+ const out = createPhysics3DAbiBodyBuffer(2);
295
+
296
+ expect(readPhysics3DAbiBodies(abi, world, new Uint32Array([20, 404, 10]), out)).toBe(true);
297
+ expect(out.requiredCount).toBe(2);
298
+ expect(out.count).toBe(2);
299
+ expect([...out.ids]).toEqual([20, 10]);
300
+ });
301
+ });
302
+
303
+ describe('readPhysics3DAbiContacts', () => {
304
+ it('publishes contact identities, events, and required capacity after a step', () => {
305
+ const { abi, world } = createContactWorld();
306
+ expect(stepPhysics3DAbiWorld(abi, world, 1 / 60)).toBe('Complete');
307
+
308
+ const out = createPhysics3DAbiContactBuffer(4, 16);
309
+ expect(readPhysics3DAbiContacts(abi, world, 'All', out)).toBe(true);
310
+ expect(out.count).toBeGreaterThan(0);
311
+ expect(new Set([out.ids[0], out.ids[1]])).toEqual(new Set([1, 2]));
312
+ expect(new Set([out.ids[2], out.ids[3]])).toEqual(new Set([11, 22]));
313
+
314
+ const began = createPhysics3DAbiContactBuffer(4, 16);
315
+ expect(readPhysics3DAbiContacts(abi, world, 'Began', began)).toBe(true);
316
+ expect(began.count).toBeGreaterThan(0);
317
+
318
+ const undersized = createPhysics3DAbiContactBuffer(0, 0);
319
+ expect(readPhysics3DAbiContacts(abi, world, 'All', undersized)).toBe(true);
320
+ expect(undersized.count).toBe(0);
321
+ expect(undersized.requiredCount).toBeGreaterThan(0);
322
+ expect(undersized.requiredPointCount).toBeGreaterThan(0);
323
+ });
324
+ });
325
+
326
+ describe('readPhysics3DAbiJoints', () => {
327
+ it('decodes every built-in joint and returns reactions in caller-id order', () => {
328
+ const abi = createPhysics3DAbi();
329
+ const world = createPhysics3DAbiWorld(abi);
330
+ const commands = createPhysics3DAbiCommandBuffer(16384);
331
+ writePhysics3DAbiSetBodyCommand(commands, 10, createRigidBody3D());
332
+ writePhysics3DAbiSetBodyCommand(commands, 20, createRigidBody3D());
333
+ const joints = getJoints();
334
+ const ids = [70, 10, 60, 20, 50, 30, 40];
335
+ for (let i = 0; i < joints.length; i += 1) {
336
+ expect(writePhysics3DAbiSetJointCommand(commands, ids[i], joints[i])).toBe(true);
337
+ }
338
+ const result = createPhysics3DAbiExecutionResult();
339
+ expect(executePhysics3DAbiCommands(abi, world, commands, result)).toBe(true);
340
+
341
+ const out = createPhysics3DAbiJointBuffer(3);
342
+ expect(readPhysics3DAbiJoints(abi, world, out)).toBe(true);
343
+ expect(out.requiredCount).toBe(7);
344
+ expect(out.count).toBe(3);
345
+ expect([...out.ids]).toEqual([10, 20, 30]);
346
+ expect([...out.values]).toEqual(new Array(18).fill(0));
347
+ });
348
+
349
+ it('removes a joint explicitly and cascades the others when a body is destroyed', () => {
350
+ const abi = createPhysics3DAbi();
351
+ const world = createPhysics3DAbiWorld(abi);
352
+ executeOrThrow(
353
+ abi,
354
+ world,
355
+ writeCommands((commands) => {
356
+ writePhysics3DAbiSetBodyCommand(commands, 1, createRigidBody3D());
357
+ writePhysics3DAbiSetBodyCommand(commands, 2, createRigidBody3D());
358
+ writePhysics3DAbiSetJointCommand(commands, 5, createPhysics3DDistanceJoint({ bodyA: 1, bodyB: 2 }));
359
+ writePhysics3DAbiSetJointCommand(commands, 6, createPhysics3DBallAndSocketJoint({ bodyA: 1, bodyB: 2 }));
360
+ }),
361
+ );
362
+ executeOrThrow(
363
+ abi,
364
+ world,
365
+ writeCommands((commands) => {
366
+ writePhysics3DAbiDestroyJointCommand(commands, 5);
367
+ writePhysics3DAbiDestroyBodyCommand(commands, 1);
368
+ }),
369
+ );
370
+
371
+ const out = createPhysics3DAbiJointBuffer(2);
372
+ expect(readPhysics3DAbiJoints(abi, world, out)).toBe(true);
373
+ expect([out.count, out.requiredCount]).toEqual([0, 0]);
374
+ });
375
+ });
376
+
377
+ describe('stepPhysics3DAbiWorld', () => {
378
+ it('matches the standard Physics3D solver over a persistent multi-step world', () => {
379
+ const abi = createPhysics3DAbi();
380
+ const abiWorld = createPhysics3DAbiWorld(abi);
381
+ const source = createRigidBody3D();
382
+ source.x = -2;
383
+ source.y = 4;
384
+ source.velocityX = 1.25;
385
+ const collider = createPhysics3DCollider(unitAabb());
386
+ const commands = createPhysics3DAbiCommandBuffer();
387
+ writePhysics3DAbiSetBodyCommand(commands, 7, source);
388
+ writePhysics3DAbiSetColliderCommand(commands, 9, 7, collider);
389
+ executeOrThrow(abi, abiWorld, commands);
390
+
391
+ const directWorld = createPhysics3DWorld();
392
+ const directBody = createRigidBody3D();
393
+ directBody.x = source.x;
394
+ directBody.y = source.y;
395
+ directBody.velocityX = source.velocityX;
396
+ addPhysics3DBody(directWorld, directBody);
397
+ addPhysics3DCollider(directWorld, directBody, createPhysics3DCollider(unitAabb()));
398
+
399
+ for (let i = 0; i < 120; i += 1) {
400
+ expect(stepPhysics3DAbiWorld(abi, abiWorld, 1 / 120)).toBe('Complete');
401
+ stepPhysics3D(directWorld, 1 / 120);
402
+ }
403
+
404
+ const out = createPhysics3DAbiBodyBuffer(1);
405
+ readPhysics3DAbiBodies(abi, abiWorld, null, out);
406
+ expect(out.values[Physics3DAbiBodyValue.X]).toBeCloseTo(directBody.x, 12);
407
+ expect(out.values[Physics3DAbiBodyValue.Y]).toBeCloseTo(directBody.y, 12);
408
+ expect(out.values[Physics3DAbiBodyValue.VelocityY]).toBeCloseTo(directBody.velocityY, 12);
409
+ });
410
+
411
+ it('adapts synchronous contact hooks without exposing the owned world', () => {
412
+ const { abi, world } = createContactWorld();
413
+ const buffer = createPhysics3DAbiContactBuffer(1, 4);
414
+ const reentrantResult = createPhysics3DAbiExecutionResult();
415
+ let preSolveCount = 0;
416
+ let postSolveCount = 0;
417
+ let nestedStepStatus = '';
418
+ let statusDuringHook = '';
419
+ let readDuringHook = true;
420
+ let queryDuringHook = true;
421
+ let destroyDuringHook = true;
422
+
423
+ expect(
424
+ stepPhysics3DAbiWorld(abi, world, 1 / 60, {
425
+ buffer,
426
+ preSolve(contact): void {
427
+ preSolveCount += 1;
428
+ contact.values[3] = 0.75;
429
+ executePhysics3DAbiCommands(abi, world, createPhysics3DAbiCommandBuffer(), reentrantResult);
430
+ nestedStepStatus = stepPhysics3DAbiWorld(abi, world, 1 / 60);
431
+ statusDuringHook = getPhysics3DAbiWorldStatus(abi, world);
432
+ readDuringHook = readPhysics3DAbiBodies(abi, world, null, createPhysics3DAbiBodyBuffer(1));
433
+ queryDuringHook = queryPhysics3DAbiPoint(abi, world, 0, 0, 0, createPhysics3DAbiQueryBuffer(1));
434
+ destroyDuringHook = destroyPhysics3DAbiWorld(abi, world);
435
+ },
436
+ postSolve(): void {
437
+ postSolveCount += 1;
438
+ },
439
+ }),
440
+ ).toBe('Complete');
441
+ expect(preSolveCount).toBeGreaterThan(0);
442
+ expect(postSolveCount).toBeGreaterThan(0);
443
+ expect(reentrantResult.status).toBe('BusyWorld');
444
+ expect(nestedStepStatus).toBe('BusyWorld');
445
+ expect(statusDuringHook).toBe('Busy');
446
+ expect(readDuringHook).toBe(false);
447
+ expect(queryDuringHook).toBe(false);
448
+ expect(destroyDuringHook).toBe(false);
449
+
450
+ const contacts = createPhysics3DAbiContactBuffer(2, 8);
451
+ readPhysics3DAbiContacts(abi, world, 'All', contacts);
452
+ expect(contacts.values[3]).toBe(0.75);
453
+ });
454
+
455
+ it('declines invalid timesteps and insufficient live hook buffers explicitly', () => {
456
+ const { abi, world } = createContactWorld();
457
+ expect(stepPhysics3DAbiWorld(abi, world, 0)).toBe('Declined');
458
+ expect(
459
+ stepPhysics3DAbiWorld(abi, world, 1 / 60, {
460
+ buffer: createPhysics3DAbiContactBuffer(0, 0),
461
+ preSolve(): void {},
462
+ postSolve: null,
463
+ }),
464
+ ).toBe('InsufficientHookBuffer');
465
+ expect(
466
+ stepPhysics3DAbiWorld(abi, world, 1 / 60, {
467
+ buffer: createPhysics3DAbiContactBuffer(0, 0),
468
+ preSolve: null,
469
+ postSolve: null,
470
+ }),
471
+ ).toBe('Complete');
472
+ });
473
+ });
474
+
475
+ function createBodyReadWorld(): { abi: Physics3DAbi; world: number } {
476
+ const abi = createPhysics3DAbi();
477
+ const world = createPhysics3DAbiWorld(abi);
478
+ const commands = createPhysics3DAbiCommandBuffer();
479
+ const body20 = createRigidBody3D();
480
+ body20.x = 20;
481
+ const body10 = createRigidBody3D();
482
+ body10.x = 10;
483
+ writePhysics3DAbiSetBodyCommand(commands, 20, body20);
484
+ writePhysics3DAbiSetBodyCommand(commands, 10, body10);
485
+ executeOrThrow(abi, world, commands);
486
+ return { abi, world };
487
+ }
488
+
489
+ function createContactWorld(): { abi: Physics3DAbi; world: number } {
490
+ const abi = createPhysics3DAbi();
491
+ const world = createPhysics3DAbiWorld(abi);
492
+ const commands = createPhysics3DAbiCommandBuffer();
493
+ const ground = createRigidBody3D('static');
494
+ const box = createRigidBody3D();
495
+ box.y = 0.75;
496
+ writePhysics3DAbiSetBodyCommand(commands, 2, ground);
497
+ writePhysics3DAbiSetColliderCommand(commands, 22, 2, createPhysics3DCollider(unitAabb()));
498
+ writePhysics3DAbiSetBodyCommand(commands, 1, box);
499
+ writePhysics3DAbiSetColliderCommand(commands, 11, 1, createPhysics3DCollider(unitAabb()));
500
+ executeOrThrow(abi, world, commands);
501
+ return { abi, world };
502
+ }
503
+
504
+ function executeOrThrow(abi: Readonly<Physics3DAbi>, world: number, commands: Physics3DAbiCommandBuffer): void {
505
+ const result = createPhysics3DAbiExecutionResult();
506
+ if (!executePhysics3DAbiCommands(abi, world, commands, result)) {
507
+ throw new Error(`Unexpected ABI command failure: ${result.status} at ${result.commandIndex}`);
508
+ }
509
+ }
510
+
511
+ function getColliderShapes(): CollisionColliderShape3D[] {
512
+ return [
513
+ { kind: 'sphere', x: 0, y: 0, z: 0, radius: 1 },
514
+ unitAabb(),
515
+ {
516
+ kind: 'box',
517
+ x: 0,
518
+ y: 0,
519
+ z: 0,
520
+ halfX: 1,
521
+ halfY: 1,
522
+ halfZ: 1,
523
+ rotationX: 0,
524
+ rotationY: 0,
525
+ rotationZ: 0,
526
+ rotationW: 1,
527
+ },
528
+ { kind: 'capsule', x0: 0, y0: -1, z0: 0, x1: 0, y1: 1, z1: 0, radius: 0.5 },
529
+ { kind: 'cylinder', x0: 0, y0: -1, z0: 0, x1: 0, y1: 1, z1: 0, radius: 0.5 },
530
+ { kind: 'cone', apexX: 0, apexY: 1, apexZ: 0, baseX: 0, baseY: -1, baseZ: 0, radius: 1 },
531
+ { kind: 'convex', points: [0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1] },
532
+ {
533
+ kind: 'triangle-mesh',
534
+ version: 0,
535
+ x: 0,
536
+ y: 0,
537
+ z: 0,
538
+ rotationX: 0,
539
+ rotationY: 0,
540
+ rotationZ: 0,
541
+ rotationW: 1,
542
+ points: [0, 0, 0, 1, 0, 0, 0, 0, 1],
543
+ indices: [0, 1, 2],
544
+ },
545
+ {
546
+ kind: 'heightfield',
547
+ columns: 2,
548
+ rows: 2,
549
+ version: 0,
550
+ cellSizeX: 1,
551
+ cellSizeZ: 1,
552
+ x: 0,
553
+ y: 0,
554
+ z: 0,
555
+ rotationX: 0,
556
+ rotationY: 0,
557
+ rotationZ: 0,
558
+ rotationW: 1,
559
+ heights: [0, 0, 0, 0],
560
+ },
561
+ ];
562
+ }
563
+
564
+ function getJoints(): Physics3DJoint[] {
565
+ return [
566
+ createPhysics3DBallAndSocketJoint({ bodyA: 10, bodyB: 20 }),
567
+ createPhysics3DDistanceJoint({ bodyA: 10, bodyB: 20, length: 1 }),
568
+ createPhysics3DFixedJoint({ bodyA: 10, bodyB: 20 }),
569
+ createPhysics3DHingeJoint({ bodyA: 10, bodyB: 20 }),
570
+ createPhysics3DSliderJoint({ bodyA: 10, bodyB: 20 }),
571
+ createPhysics3DConeTwistJoint({ bodyA: 10, bodyB: 20 }),
572
+ createPhysics3DGeneric6DofJoint({ bodyA: 10, bodyB: 20 }),
573
+ ];
574
+ }
575
+
576
+ function unitAabb(): CollisionColliderShape3D {
577
+ return { kind: 'aabb', minX: -0.5, minY: -0.5, minZ: -0.5, maxX: 0.5, maxY: 0.5, maxZ: 0.5 };
578
+ }
579
+
580
+ function writeCommands(write: (commands: Physics3DAbiCommandBuffer) => void): Physics3DAbiCommandBuffer {
581
+ const commands = createPhysics3DAbiCommandBuffer();
582
+ write(commands);
583
+ return commands;
584
+ }
@@ -0,0 +1,117 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import {
4
+ clearPhysics3DAbiCommandBuffer,
5
+ createPhysics3DAbiBodyBuffer,
6
+ createPhysics3DAbiCommandBuffer,
7
+ createPhysics3DAbiContactBuffer,
8
+ createPhysics3DAbiExecutionResult,
9
+ createPhysics3DAbiJointBuffer,
10
+ createPhysics3DAbiQueryBuffer,
11
+ getPhysics3DAbiCommandBufferRemainingByteLength,
12
+ } from './physics3DAbiBuffer';
13
+ import {
14
+ Physics3DAbiBodyValueStride,
15
+ Physics3DAbiCommandHeaderByteLength,
16
+ Physics3DAbiCommandMagic,
17
+ Physics3DAbiContactIdStride,
18
+ Physics3DAbiContactPointValueStride,
19
+ Physics3DAbiContactValueStride,
20
+ Physics3DAbiJointValueStride,
21
+ Physics3DAbiQueryValueStride,
22
+ Physics3DAbiVersion,
23
+ } from './physics3DAbiLayout';
24
+
25
+ describe('clearPhysics3DAbiCommandBuffer', () => {
26
+ it('rewrites the canonical little-endian header and resets the published stream', () => {
27
+ const out = createPhysics3DAbiCommandBuffer(64);
28
+ out.data.fill(0xff);
29
+ out.byteLength = 64;
30
+ out.commandCount = 3;
31
+
32
+ clearPhysics3DAbiCommandBuffer(out);
33
+
34
+ const view = new DataView(out.data.buffer);
35
+ expect(view.getUint32(0, true)).toBe(Physics3DAbiCommandMagic);
36
+ expect(view.getUint32(4, true)).toBe(Physics3DAbiVersion);
37
+ expect(view.getUint32(8, true)).toBe(Physics3DAbiCommandHeaderByteLength);
38
+ expect(view.getUint32(12, true)).toBe(0);
39
+ expect(out.byteLength).toBe(Physics3DAbiCommandHeaderByteLength);
40
+ expect(out.commandCount).toBe(0);
41
+ });
42
+ });
43
+
44
+ describe('createPhysics3DAbiBodyBuffer', () => {
45
+ it('allocates the documented structure-of-arrays capacity', () => {
46
+ const out = createPhysics3DAbiBodyBuffer(3);
47
+ expect(out.ids).toHaveLength(3);
48
+ expect(out.flags).toHaveLength(3);
49
+ expect(out.values).toHaveLength(3 * Physics3DAbiBodyValueStride);
50
+ expect([out.count, out.requiredCount]).toEqual([0, 0]);
51
+ });
52
+ });
53
+
54
+ describe('createPhysics3DAbiCommandBuffer', () => {
55
+ it('allocates only through an explicit byte capacity', () => {
56
+ const out = createPhysics3DAbiCommandBuffer(80);
57
+ expect(out.data).toHaveLength(80);
58
+ expect(out.byteLength).toBe(Physics3DAbiCommandHeaderByteLength);
59
+ });
60
+
61
+ it('rejects a capacity that cannot hold the stream header', () => {
62
+ expect(() => createPhysics3DAbiCommandBuffer(15)).toThrow(RangeError);
63
+ });
64
+ });
65
+
66
+ describe('createPhysics3DAbiContactBuffer', () => {
67
+ it('allocates contact and point capacities independently', () => {
68
+ const out = createPhysics3DAbiContactBuffer(2, 5);
69
+ expect(out.ids).toHaveLength(2 * Physics3DAbiContactIdStride);
70
+ expect(out.values).toHaveLength(2 * Physics3DAbiContactValueStride);
71
+ expect(out.pointFeatureIds).toHaveLength(5);
72
+ expect(out.pointValues).toHaveLength(5 * Physics3DAbiContactPointValueStride);
73
+ });
74
+ });
75
+
76
+ describe('createPhysics3DAbiExecutionResult', () => {
77
+ it('starts at a complete empty stream boundary', () => {
78
+ expect(createPhysics3DAbiExecutionResult()).toEqual({
79
+ status: 'Complete',
80
+ commandIndex: 0,
81
+ byteOffset: Physics3DAbiCommandHeaderByteLength,
82
+ commandKind: 0,
83
+ });
84
+ });
85
+ });
86
+
87
+ describe('createPhysics3DAbiJointBuffer', () => {
88
+ it('allocates one reaction row per joint', () => {
89
+ const out = createPhysics3DAbiJointBuffer(4);
90
+ expect(out.ids).toHaveLength(4);
91
+ expect(out.flags).toHaveLength(4);
92
+ expect(out.values).toHaveLength(4 * Physics3DAbiJointValueStride);
93
+ });
94
+ });
95
+
96
+ describe('createPhysics3DAbiQueryBuffer', () => {
97
+ it('allocates identity and geometry rows at the same capacity', () => {
98
+ const out = createPhysics3DAbiQueryBuffer(6);
99
+ expect(out.bodyIds).toHaveLength(6);
100
+ expect(out.colliderIds).toHaveLength(6);
101
+ expect(out.values).toHaveLength(6 * Physics3DAbiQueryValueStride);
102
+ });
103
+
104
+ it('rejects negative and fractional capacities', () => {
105
+ expect(() => createPhysics3DAbiQueryBuffer(-1)).toThrow(RangeError);
106
+ expect(() => createPhysics3DAbiQueryBuffer(1.5)).toThrow(RangeError);
107
+ });
108
+ });
109
+
110
+ describe('getPhysics3DAbiCommandBufferRemainingByteLength', () => {
111
+ it('reports capacity after the published prefix without going negative', () => {
112
+ const out = createPhysics3DAbiCommandBuffer(64);
113
+ expect(getPhysics3DAbiCommandBufferRemainingByteLength(out)).toBe(48);
114
+ out.byteLength = 80;
115
+ expect(getPhysics3DAbiCommandBufferRemainingByteLength(out)).toBe(0);
116
+ });
117
+ });