@forgeax/engine-physics-rapier3d 0.1.20 → 0.1.23

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.
@@ -24,7 +24,12 @@ import {
24
24
  RigidBodyTypeValue,
25
25
  registerPhysicsComponents,
26
26
  } from '@forgeax/engine-physics';
27
- import { ChildOf, registerPropagateTransforms, Transform } from '@forgeax/engine-scene';
27
+ import {
28
+ ChildOf,
29
+ GlobalTransform,
30
+ registerPropagateTransforms,
31
+ Transform,
32
+ } from '@forgeax/engine-scene';
28
33
  import { describe, expect, it, vi } from 'vitest';
29
34
  import { createRapier3DPhysicsWorld, registerPhysicsSystems } from '../rapier-physics-world-3d';
30
35
  import { loadRapier3D } from '../wasm-loader';
@@ -32,10 +37,16 @@ import { loadRapier3D } from '../wasm-loader';
32
37
  function prepareWorld(): World {
33
38
  const world = new World();
34
39
  world.components.register(Transform).unwrap();
40
+ world.components.register(GlobalTransform).unwrap();
35
41
  registerPhysicsComponents(world);
36
42
  return world;
37
43
  }
38
44
 
45
+ // biome-ignore lint/suspicious/noExplicitAny: test fixture accepts the ECS spawn component tuple.
46
+ function spawnPairedEntity(world: World, ...components: any[]) {
47
+ return world.spawn(...components);
48
+ }
49
+
39
50
  function runPhysicsTicks(world: World, count = 1): void {
40
51
  for (let index = 0; index < count; index += 1) {
41
52
  world.update(1 / 60).unwrap();
@@ -278,28 +289,26 @@ function rapierBodyFor(pw: any, entity: number): any | undefined {
278
289
  world.insertResource('PhysicsWorld', pw);
279
290
 
280
291
  // Ground: static cuboid, top at y=0.
281
- const ground = world
282
- .spawn(
283
- { component: Transform as never, data: { pos: [0, -0.5, 0] } },
284
- { component: RigidBody as never, data: { type: RigidBodyTypeValue.static } },
285
- {
286
- component: Collider as never,
287
- data: { shape: ColliderShapeValue.cuboid, halfExtents: [10, 0.5, 10] },
288
- },
289
- )
290
- .unwrap();
292
+ const ground = spawnPairedEntity(
293
+ world,
294
+ { component: Transform as never, data: { pos: [0, -0.5, 0] } },
295
+ { component: RigidBody as never, data: { type: RigidBodyTypeValue.static } },
296
+ {
297
+ component: Collider as never,
298
+ data: { shape: ColliderShapeValue.cuboid, halfExtents: [10, 0.5, 10] },
299
+ },
300
+ ).unwrap();
291
301
 
292
302
  // Target: static cuboid centred at x=5 (near face x=4), well above ground.
293
- const target = world
294
- .spawn(
295
- { component: Transform as never, data: { pos: [5, 1, 0] } },
296
- { component: RigidBody as never, data: { type: RigidBodyTypeValue.static } },
297
- {
298
- component: Collider as never,
299
- data: { shape: ColliderShapeValue.cuboid, halfExtents: [1, 1, 1] },
300
- },
301
- )
302
- .unwrap();
303
+ const target = spawnPairedEntity(
304
+ world,
305
+ { component: Transform as never, data: { pos: [5, 1, 0] } },
306
+ { component: RigidBody as never, data: { type: RigidBodyTypeValue.static } },
307
+ {
308
+ component: Collider as never,
309
+ data: { shape: ColliderShapeValue.cuboid, halfExtents: [1, 1, 1] },
310
+ },
311
+ ).unwrap();
303
312
 
304
313
  registerPhysicsSystems(world);
305
314
  for (let i = 0; i < 5; i++) {
@@ -406,19 +415,18 @@ function rapierBodyFor(pw: any, entity: number): any | undefined {
406
415
  const pw = createRapier3DPhysicsWorld(RAPIER);
407
416
  world.insertResource('PhysicsWorld', pw);
408
417
 
409
- world
410
- .spawn(
411
- { component: Transform as never, data: { pos: [0, 1, 0] } },
412
- {
413
- component: RigidBody as never,
414
- data: { type: RigidBodyTypeValue.kinematic, ccdEnabled: true },
415
- },
416
- {
417
- component: Collider as never,
418
- data: { shape: ColliderShapeValue.sphere, radius: 0.2 },
419
- },
420
- )
421
- .unwrap();
418
+ spawnPairedEntity(
419
+ world,
420
+ { component: Transform as never, data: { pos: [0, 1, 0] } },
421
+ {
422
+ component: RigidBody as never,
423
+ data: { type: RigidBodyTypeValue.kinematic, ccdEnabled: true },
424
+ },
425
+ {
426
+ component: Collider as never,
427
+ data: { shape: ColliderShapeValue.sphere, radius: 0.2 },
428
+ },
429
+ ).unwrap();
422
430
 
423
431
  registerPhysicsSystems(world);
424
432
  world.update(1 / 60).unwrap();
@@ -465,44 +473,42 @@ function rapierBodyFor(pw: any, entity: number): any | undefined {
465
473
  const pw = createRapier3DPhysicsWorld(RAPIER);
466
474
  world.insertResource('PhysicsWorld', pw);
467
475
 
468
- const dynamicEntity = world
469
- .spawn(
470
- { component: Transform as never, data: { pos: [0, 5, 0] } },
471
- {
472
- component: RigidBody as never,
473
- data: {
474
- type: RigidBodyTypeValue.dynamic,
475
- mass: 1,
476
- linearDamping: 0,
477
- angularDamping: 0,
478
- gravityScale: 1,
479
- },
480
- },
481
- {
482
- component: Collider as never,
483
- data: { shape: 1, radius: 0.5, friction: 0.5, restitution: 0 },
476
+ const dynamicEntity = spawnPairedEntity(
477
+ world,
478
+ { component: Transform as never, data: { pos: [0, 5, 0] } },
479
+ {
480
+ component: RigidBody as never,
481
+ data: {
482
+ type: RigidBodyTypeValue.dynamic,
483
+ mass: 1,
484
+ linearDamping: 0,
485
+ angularDamping: 0,
486
+ gravityScale: 1,
484
487
  },
485
- )
486
- .unwrap();
488
+ },
489
+ {
490
+ component: Collider as never,
491
+ data: { shape: 1, radius: 0.5, friction: 0.5, restitution: 0 },
492
+ },
493
+ ).unwrap();
487
494
 
488
- const staticEntity = world
489
- .spawn(
490
- { component: Transform as never, data: { pos: [0, 0, 0] } },
491
- {
492
- component: RigidBody as never,
493
- data: { type: RigidBodyTypeValue.static },
494
- },
495
- {
496
- component: Collider as never,
497
- data: {
498
- shape: 0,
499
- halfExtents: [10, 1, 10],
500
- friction: 0.5,
501
- restitution: 0,
502
- },
495
+ const staticEntity = spawnPairedEntity(
496
+ world,
497
+ { component: Transform as never, data: { pos: [0, 0, 0] } },
498
+ {
499
+ component: RigidBody as never,
500
+ data: { type: RigidBodyTypeValue.static },
501
+ },
502
+ {
503
+ component: Collider as never,
504
+ data: {
505
+ shape: 0,
506
+ halfExtents: [10, 1, 10],
507
+ friction: 0.5,
508
+ restitution: 0,
503
509
  },
504
- )
505
- .unwrap();
510
+ },
511
+ ).unwrap();
506
512
 
507
513
  const initDynamic = world.get(dynamicEntity, Transform as never);
508
514
  const initStatic = world.get(staticEntity, Transform as never);
@@ -558,46 +564,44 @@ function rapierBodyFor(pw: any, entity: number): any | undefined {
558
564
  world.insertResource('PhysicsWorld', pw);
559
565
 
560
566
  // Ball: dynamic body + sphere collider dropped from y=5.
561
- const ball = world
562
- .spawn(
563
- { component: Transform as never, data: { pos: [0, 5, 0] } },
564
- {
565
- component: RigidBody as never,
566
- data: {
567
- type: RigidBodyTypeValue.dynamic,
568
- mass: 1,
569
- linearDamping: 0,
570
- angularDamping: 0,
571
- gravityScale: 1,
572
- },
567
+ const ball = spawnPairedEntity(
568
+ world,
569
+ { component: Transform as never, data: { pos: [0, 5, 0] } },
570
+ {
571
+ component: RigidBody as never,
572
+ data: {
573
+ type: RigidBodyTypeValue.dynamic,
574
+ mass: 1,
575
+ linearDamping: 0,
576
+ angularDamping: 0,
577
+ gravityScale: 1,
573
578
  },
574
- {
575
- component: Collider as never,
576
- data: {
577
- shape: ColliderShapeValue.sphere,
578
- radius: 0.5,
579
- friction: 0.5,
580
- restitution: 0,
581
- },
579
+ },
580
+ {
581
+ component: Collider as never,
582
+ data: {
583
+ shape: ColliderShapeValue.sphere,
584
+ radius: 0.5,
585
+ friction: 0.5,
586
+ restitution: 0,
582
587
  },
583
- )
584
- .unwrap();
588
+ },
589
+ ).unwrap();
585
590
 
586
591
  // Floor: a BARE Collider — NO RigidBody. Cuboid top at y = 0 + 0.5 = 0.5.
587
- const floor = world
588
- .spawn(
589
- { component: Transform as never, data: { pos: [0, 0, 0] } },
590
- {
591
- component: Collider as never,
592
- data: {
593
- shape: ColliderShapeValue.cuboid,
594
- halfExtents: [10, 0.5, 10],
595
- friction: 0.5,
596
- restitution: 0,
597
- },
592
+ const floor = spawnPairedEntity(
593
+ world,
594
+ { component: Transform as never, data: { pos: [0, 0, 0] } },
595
+ {
596
+ component: Collider as never,
597
+ data: {
598
+ shape: ColliderShapeValue.cuboid,
599
+ halfExtents: [10, 0.5, 10],
600
+ friction: 0.5,
601
+ restitution: 0,
598
602
  },
599
- )
600
- .unwrap();
603
+ },
604
+ ).unwrap();
601
605
 
602
606
  // Sanity: the floor archetype genuinely has no RigidBody column.
603
607
  expect(world.get(floor, RigidBody as never).ok).toBe(false);
@@ -634,22 +638,21 @@ function rapierBodyFor(pw: any, entity: number): any | undefined {
634
638
  const world = prepareWorld();
635
639
  const pw = createRapier3DPhysicsWorld(RAPIER);
636
640
  world.insertResource('PhysicsWorld', pw);
637
- const obstacle = world
638
- .spawn(
639
- {
640
- component: Transform as never,
641
- data: {
642
- quat: [0, 0, Math.SQRT1_2, Math.SQRT1_2],
643
- scale: [2, 1, 1],
644
- },
645
- },
646
- { component: RigidBody as never, data: { type: RigidBodyTypeValue.static } },
647
- {
648
- component: Collider as never,
649
- data: { shape: ColliderShapeValue.cuboid, halfExtents: [1, 0.25, 0.25] },
641
+ const obstacle = spawnPairedEntity(
642
+ world,
643
+ {
644
+ component: Transform as never,
645
+ data: {
646
+ quat: [0, 0, Math.SQRT1_2, Math.SQRT1_2],
647
+ scale: [2, 1, 1],
650
648
  },
651
- )
652
- .unwrap();
649
+ },
650
+ { component: RigidBody as never, data: { type: RigidBodyTypeValue.static } },
651
+ {
652
+ component: Collider as never,
653
+ data: { shape: ColliderShapeValue.cuboid, halfExtents: [1, 0.25, 0.25] },
654
+ },
655
+ ).unwrap();
653
656
  registerPropagateTransforms(world);
654
657
  registerPhysicsSystems(world);
655
658
  world.update(1 / 60).unwrap();
@@ -683,16 +686,15 @@ function rapierBodyFor(pw: any, entity: number): any | undefined {
683
686
  const world = prepareWorld();
684
687
  const pw = createRapier3DPhysicsWorld(RAPIER);
685
688
  world.insertResource('PhysicsWorld', pw);
686
- const obstacle = world
687
- .spawn(
688
- { component: Transform as never, data: { scale: [1, 1, 1] } },
689
- { component: RigidBody as never, data: { type: RigidBodyTypeValue.static } },
690
- {
691
- component: Collider as never,
692
- data: { shape: ColliderShapeValue.cuboid, halfExtents: [1, 0.25, 0.25] },
693
- },
694
- )
695
- .unwrap();
689
+ const obstacle = spawnPairedEntity(
690
+ world,
691
+ { component: Transform as never, data: { scale: [1, 1, 1] } },
692
+ { component: RigidBody as never, data: { type: RigidBodyTypeValue.static } },
693
+ {
694
+ component: Collider as never,
695
+ data: { shape: ColliderShapeValue.cuboid, halfExtents: [1, 0.25, 0.25] },
696
+ },
697
+ ).unwrap();
696
698
  registerPropagateTransforms(world);
697
699
  registerPhysicsSystems(world);
698
700
  world.update(1 / 60).unwrap();
@@ -731,29 +733,27 @@ function rapierBodyFor(pw: any, entity: number): any | undefined {
731
733
  const world = prepareWorld();
732
734
  const pw = createRapier3DPhysicsWorld(RAPIER as never);
733
735
  world.insertResource('PhysicsWorld', pw);
734
- const ball = world
735
- .spawn(
736
- { component: Transform as never, data: { pos: [0, 8, 0] } },
737
- {
738
- component: RigidBody as never,
739
- data: { type: RigidBodyTypeValue.dynamic, mass: 1, gravityScale: 1 },
740
- },
741
- {
742
- component: Collider as never,
743
- data: { shape: 1, radius: 0.5, friction: 0.5, restitution: 0 },
744
- },
745
- )
746
- .unwrap();
747
- world
748
- .spawn(
749
- { component: Transform as never, data: { pos: [0, 0, 0] } },
750
- { component: RigidBody as never, data: { type: RigidBodyTypeValue.static } },
751
- {
752
- component: Collider as never,
753
- data: { shape: 0, halfExtents, friction: 0.5, restitution: 0 },
754
- },
755
- )
756
- .unwrap();
736
+ const ball = spawnPairedEntity(
737
+ world,
738
+ { component: Transform as never, data: { pos: [0, 8, 0] } },
739
+ {
740
+ component: RigidBody as never,
741
+ data: { type: RigidBodyTypeValue.dynamic, mass: 1, gravityScale: 1 },
742
+ },
743
+ {
744
+ component: Collider as never,
745
+ data: { shape: 1, radius: 0.5, friction: 0.5, restitution: 0 },
746
+ },
747
+ ).unwrap();
748
+ spawnPairedEntity(
749
+ world,
750
+ { component: Transform as never, data: { pos: [0, 0, 0] } },
751
+ { component: RigidBody as never, data: { type: RigidBodyTypeValue.static } },
752
+ {
753
+ component: Collider as never,
754
+ data: { shape: 0, halfExtents, friction: 0.5, restitution: 0 },
755
+ },
756
+ ).unwrap();
757
757
  registerPhysicsSystems(world);
758
758
  for (let i = 0; i < 300; i++) {
759
759
  world.update(1 / 60).unwrap();
@@ -800,57 +800,53 @@ function rapierBodyFor(pw: any, entity: number): any | undefined {
800
800
  // Parent: a kinematic body (NO CharacterController, so the kinematic
801
801
  // mirror -- not moveAndSlide -- drives it) placed far from the origin.
802
802
  const PARENT_X = 8;
803
- const parent = world
804
- .spawn(
805
- { component: Transform as never, data: { pos: [PARENT_X, 0, 0] } },
806
- { component: RigidBody as never, data: { type: RigidBodyTypeValue.kinematic } },
807
- {
808
- component: Collider as never,
809
- data: { shape: ColliderShapeValue.sphere, radius: 0.3 },
810
- },
811
- )
812
- .unwrap();
803
+ const parent = spawnPairedEntity(
804
+ world,
805
+ { component: Transform as never, data: { pos: [PARENT_X, 0, 0] } },
806
+ { component: RigidBody as never, data: { type: RigidBodyTypeValue.kinematic } },
807
+ {
808
+ component: Collider as never,
809
+ data: { shape: ColliderShapeValue.sphere, radius: 0.3 },
810
+ },
811
+ ).unwrap();
813
812
 
814
813
  // Child sensor: ChildOf the parent with LOCAL pos (0,0,0), so its world
815
814
  // pos equals the parent's. This is the shape that regressed.
816
- const sensor = world
817
- .spawn(
818
- { component: Transform as never, data: { pos: [0, 0, 0] } },
819
- { component: RigidBody as never, data: { type: RigidBodyTypeValue.kinematic } },
820
- {
821
- component: Collider as never,
822
- data: { shape: ColliderShapeValue.sphere, radius: 1, isSensor: true },
823
- },
824
- { component: ChildOf as never, data: { parent } },
825
- { component: CollidingEntities as never, data: { entities: [] } },
826
- )
827
- .unwrap();
815
+ const sensor = spawnPairedEntity(
816
+ world,
817
+ { component: Transform as never, data: { pos: [0, 0, 0] } },
818
+ { component: RigidBody as never, data: { type: RigidBodyTypeValue.kinematic } },
819
+ {
820
+ component: Collider as never,
821
+ data: { shape: ColliderShapeValue.sphere, radius: 1, isSensor: true },
822
+ },
823
+ { component: ChildOf as never, data: { parent } },
824
+ { component: CollidingEntities as never, data: { entities: [] } },
825
+ ).unwrap();
828
826
 
829
827
  // Probe at the ORIGIN: if the sensor collider were (wrongly) pinned at
830
828
  // the origin, it would overlap this probe.
831
- const originProbe = world
832
- .spawn(
833
- { component: Transform as never, data: { pos: [0, 0, 0] } },
834
- { component: RigidBody as never, data: { type: RigidBodyTypeValue.static } },
835
- {
836
- component: Collider as never,
837
- data: { shape: ColliderShapeValue.sphere, radius: 0.3 },
838
- },
839
- )
840
- .unwrap();
829
+ const originProbe = spawnPairedEntity(
830
+ world,
831
+ { component: Transform as never, data: { pos: [0, 0, 0] } },
832
+ { component: RigidBody as never, data: { type: RigidBodyTypeValue.static } },
833
+ {
834
+ component: Collider as never,
835
+ data: { shape: ColliderShapeValue.sphere, radius: 0.3 },
836
+ },
837
+ ).unwrap();
841
838
 
842
839
  // Probe at the PARENT world pos: the sensor must overlap THIS one once it
843
840
  // correctly follows the parent.
844
- const farProbe = world
845
- .spawn(
846
- { component: Transform as never, data: { pos: [PARENT_X, 0, 0] } },
847
- { component: RigidBody as never, data: { type: RigidBodyTypeValue.static } },
848
- {
849
- component: Collider as never,
850
- data: { shape: ColliderShapeValue.sphere, radius: 0.3 },
851
- },
852
- )
853
- .unwrap();
841
+ const farProbe = spawnPairedEntity(
842
+ world,
843
+ { component: Transform as never, data: { pos: [PARENT_X, 0, 0] } },
844
+ { component: RigidBody as never, data: { type: RigidBodyTypeValue.static } },
845
+ {
846
+ component: Collider as never,
847
+ data: { shape: ColliderShapeValue.sphere, radius: 0.3 },
848
+ },
849
+ ).unwrap();
854
850
 
855
851
  registerPropagateTransforms(world);
856
852
  registerPhysicsSystems(world);
@@ -974,8 +970,9 @@ describe('incremental physics ECS reconciliation', () => {
974
970
  };
975
971
  const result =
976
972
  options.bodyType === 'implicit'
977
- ? world.spawn(transform, collider)
978
- : world.spawn(
973
+ ? spawnPairedEntity(world, transform, collider)
974
+ : spawnPairedEntity(
975
+ world,
979
976
  transform,
980
977
  {
981
978
  component: RigidBody as never,
@@ -1104,20 +1101,20 @@ describe('incremental physics ECS reconciliation', () => {
1104
1101
  const world = prepareWorld();
1105
1102
  const pw = createRapier3DPhysicsWorld(RAPIER);
1106
1103
  world.insertResource('PhysicsWorld', pw);
1107
- const parent = world
1108
- .spawn({ component: Transform as never, data: { pos: [1, 0, 0] } })
1109
- .unwrap();
1110
- const child = world
1111
- .spawn(
1112
- { component: Transform as never, data: { pos: [2, 0, 0] } },
1113
- { component: ChildOf as never, data: { parent } },
1114
- { component: RigidBody as never, data: { type: RigidBodyTypeValue.static } },
1115
- {
1116
- component: Collider as never,
1117
- data: { shape: ColliderShapeValue.cuboid, halfExtents: [1, 1, 1] },
1118
- },
1119
- )
1120
- .unwrap();
1104
+ const parent = spawnPairedEntity(world, {
1105
+ component: Transform as never,
1106
+ data: { pos: [1, 0, 0] },
1107
+ }).unwrap();
1108
+ const child = spawnPairedEntity(
1109
+ world,
1110
+ { component: Transform as never, data: { pos: [2, 0, 0] } },
1111
+ { component: ChildOf as never, data: { parent } },
1112
+ { component: RigidBody as never, data: { type: RigidBodyTypeValue.static } },
1113
+ {
1114
+ component: Collider as never,
1115
+ data: { shape: ColliderShapeValue.cuboid, halfExtents: [1, 1, 1] },
1116
+ },
1117
+ ).unwrap();
1121
1118
  const unrelated = spawnBox(world, { pos: [20, 0, 0] });
1122
1119
  registerPropagateTransforms(world);
1123
1120
  registerPhysicsSystems(world);
@@ -1148,26 +1145,27 @@ describe('incremental physics ECS reconciliation', () => {
1148
1145
  const pw = createRapier3DPhysicsWorld(RAPIER);
1149
1146
  world.insertResource('PhysicsWorld', pw);
1150
1147
  const root = spawnBox(world, { bodyType });
1151
- const parent = world
1152
- .spawn({ component: Transform as never, data: { pos: [1, 0, 0] } })
1153
- .unwrap();
1154
- const child = world
1155
- .spawn(
1156
- { component: Transform as never, data: { pos: [-1, 0, 0] } },
1157
- { component: ChildOf as never, data: { parent } },
1158
- { component: RigidBody as never, data: { type: bodyType } },
1159
- {
1160
- component: Collider as never,
1161
- data: { shape: ColliderShapeValue.cuboid, halfExtents: [1, 1, 1] },
1162
- },
1163
- )
1164
- .unwrap();
1148
+ const parent = spawnPairedEntity(world, {
1149
+ component: Transform as never,
1150
+ data: { pos: [1, 0, 0] },
1151
+ }).unwrap();
1152
+ const child = spawnPairedEntity(
1153
+ world,
1154
+ { component: Transform as never, data: { pos: [-1, 0, 0] } },
1155
+ { component: ChildOf as never, data: { parent } },
1156
+ { component: RigidBody as never, data: { type: bodyType } },
1157
+ {
1158
+ component: Collider as never,
1159
+ data: { shape: ColliderShapeValue.cuboid, halfExtents: [1, 1, 1] },
1160
+ },
1161
+ ).unwrap();
1165
1162
  registerPropagateTransforms(world);
1166
1163
  registerPhysicsSystems(world);
1167
1164
  runPhysicsTicks(world);
1168
1165
 
1169
- const rootWorld = world.get(root as never, Transform as never).unwrap().world as Float32Array;
1170
- const childWorld = world.get(child, Transform as never).unwrap().world as Float32Array;
1166
+ const rootWorld = world.get(root as never, GlobalTransform as never).unwrap()
1167
+ .world as Float32Array;
1168
+ const childWorld = world.get(child, GlobalTransform as never).unwrap().world as Float32Array;
1171
1169
  expect(Array.from(rootWorld)).toEqual([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
1172
1170
  expect(Array.from(childWorld)).toEqual([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
1173
1171
  expect(rapierBodyFor(pw, root)?.translation().x).toBeCloseTo(0, 5);
@@ -1418,7 +1416,7 @@ describe('incremental physics ECS reconciliation', () => {
1418
1416
  expect(rapierBodyFor(pw, replacement)?.translation().x).toBeCloseTo(11, 5);
1419
1417
  });
1420
1418
 
1421
- it('falls back to a full reconcile after projection journal overflow', {
1419
+ it('coalesces more than 65k writes into one final component-version sync', {
1422
1420
  timeout: 20_000,
1423
1421
  }, async () => {
1424
1422
  const RAPIER = await loadOrSkip();
@@ -1441,9 +1439,9 @@ describe('incremental physics ECS reconciliation', () => {
1441
1439
  }
1442
1440
  runPhysicsTicks(world);
1443
1441
 
1444
- expect(removeSpy).toHaveBeenCalledTimes(1);
1445
- expect(ensureSpy).toHaveBeenCalledTimes(1);
1446
- expect(syncSpy).not.toHaveBeenCalled();
1442
+ expect(removeSpy).not.toHaveBeenCalled();
1443
+ expect(ensureSpy).not.toHaveBeenCalled();
1444
+ expect(syncSpy).toHaveBeenCalledTimes(1);
1447
1445
  expect(pw.getBodyCount()).toBe(1);
1448
1446
  expect(rapierBodyFor(pw, entity)?.translation().x).toBeCloseTo(finalX, 5);
1449
1447
  });
@@ -1508,17 +1506,16 @@ describe('incremental physics ECS reconciliation', () => {
1508
1506
  cc?: Record<string, number>,
1509
1507
  bodyType: number = RigidBodyTypeValue.kinematic,
1510
1508
  ): number {
1511
- const entity = world
1512
- .spawn(
1513
- { component: Transform as never, data: { pos: [pos[0], pos[1], pos[2]] } },
1514
- { component: RigidBody as never, data: { type: bodyType } },
1515
- {
1516
- component: Collider as never,
1517
- data: { shape: 2, radius: 0.3, halfHeight: 0.5, friction: 0.5, restitution: 0 },
1518
- },
1519
- { component: CharacterController as never, data: cc ?? {} },
1520
- )
1521
- .unwrap();
1509
+ const entity = spawnPairedEntity(
1510
+ world,
1511
+ { component: Transform as never, data: { pos: [pos[0], pos[1], pos[2]] } },
1512
+ { component: RigidBody as never, data: { type: bodyType } },
1513
+ {
1514
+ component: Collider as never,
1515
+ data: { shape: 2, radius: 0.3, halfHeight: 0.5, friction: 0.5, restitution: 0 },
1516
+ },
1517
+ { component: CharacterController as never, data: cc ?? {} },
1518
+ ).unwrap();
1522
1519
  return entity as unknown as number;
1523
1520
  }
1524
1521
 
@@ -1529,16 +1526,15 @@ describe('incremental physics ECS reconciliation', () => {
1529
1526
  friction: 0.5,
1530
1527
  restitution: 0,
1531
1528
  };
1532
- const entity = world
1533
- .spawn(
1534
- {
1535
- component: Transform as never,
1536
- data: { pos: [box.pos[0], box.pos[1], box.pos[2]] },
1537
- },
1538
- { component: RigidBody as never, data: { type: RigidBodyTypeValue.static } },
1539
- { component: Collider as never, data },
1540
- )
1541
- .unwrap();
1529
+ const entity = spawnPairedEntity(
1530
+ world,
1531
+ {
1532
+ component: Transform as never,
1533
+ data: { pos: [box.pos[0], box.pos[1], box.pos[2]] },
1534
+ },
1535
+ { component: RigidBody as never, data: { type: RigidBodyTypeValue.static } },
1536
+ { component: Collider as never, data },
1537
+ ).unwrap();
1542
1538
  return entity as unknown as number;
1543
1539
  }
1544
1540
 
@@ -1679,16 +1675,15 @@ describe('incremental physics ECS reconciliation', () => {
1679
1675
  // collectathon guardian attack-sensor whose physics body stayed at world
1680
1676
  // origin). A sensor reports overlaps but must NEVER act as a solid wall
1681
1677
  // for the KCC -- before the fix this froze the character in place.
1682
- world
1683
- .spawn(
1684
- { component: Transform as never, data: { pos: [0, 0, 0] } },
1685
- { component: RigidBody as never, data: { type: RigidBodyTypeValue.kinematic } },
1686
- {
1687
- component: Collider as never,
1688
- data: { shape: ColliderShapeValue.sphere, radius: 1.5, isSensor: 1 },
1689
- },
1690
- )
1691
- .unwrap();
1678
+ spawnPairedEntity(
1679
+ world,
1680
+ { component: Transform as never, data: { pos: [0, 0, 0] } },
1681
+ { component: RigidBody as never, data: { type: RigidBodyTypeValue.kinematic } },
1682
+ {
1683
+ component: Collider as never,
1684
+ data: { shape: ColliderShapeValue.sphere, radius: 1.5, isSensor: 1 },
1685
+ },
1686
+ ).unwrap();
1692
1687
 
1693
1688
  world.update(1 / 60).unwrap();
1694
1689
  world.update(1 / 60).unwrap();
@@ -2081,7 +2076,7 @@ describe('incremental physics ECS reconciliation', () => {
2081
2076
  const world = prepareWorld();
2082
2077
  const pw = createRapier3DPhysicsWorld(RAPIER);
2083
2078
  world.insertResource('PhysicsWorld', pw);
2084
- // The kinematic mirror drives the collider from Transform.world (so a
2079
+ // The kinematic mirror drives the collider from GlobalTransform.world (so a
2085
2080
  // ChildOf collider follows its parent), which propagateTransforms
2086
2081
  // populates; register it (createApp always does, and physicsSyncBackend
2087
2082
  // declares `after: propagateTransforms`). For this root platform
@@ -2090,16 +2085,15 @@ describe('incremental physics ECS reconciliation', () => {
2090
2085
  registerPhysicsSystems(world);
2091
2086
 
2092
2087
  // Platform: kinematic body + collider, NO CharacterController.
2093
- const platform = world
2094
- .spawn(
2095
- { component: Transform as never, data: { pos: [0, 0, 0] } },
2096
- { component: RigidBody as never, data: { type: RigidBodyTypeValue.kinematic } },
2097
- {
2098
- component: Collider as never,
2099
- data: { shape: 0, halfExtents: [1, 0.5, 1] },
2100
- },
2101
- )
2102
- .unwrap() as unknown as number;
2088
+ const platform = spawnPairedEntity(
2089
+ world,
2090
+ { component: Transform as never, data: { pos: [0, 0, 0] } },
2091
+ { component: RigidBody as never, data: { type: RigidBodyTypeValue.kinematic } },
2092
+ {
2093
+ component: Collider as never,
2094
+ data: { shape: 0, halfExtents: [1, 0.5, 1] },
2095
+ },
2096
+ ).unwrap() as unknown as number;
2103
2097
 
2104
2098
  // Move the platform via Transform; syncBackend should mirror it.
2105
2099
  world.set(platform as never, Transform as never, { pos: [5, 2, 0] });
@@ -2277,30 +2271,28 @@ describe('incremental physics ECS reconciliation', () => {
2277
2271
  registerPhysicsSystems(world);
2278
2272
 
2279
2273
  // A "player" kinematic body + CollidingEntities, sitting at the origin.
2280
- const player = world
2281
- .spawn(
2282
- { component: Transform as never, data: { pos: [0, 0, 0] } },
2283
- { component: RigidBody as never, data: { type: RigidBodyTypeValue.kinematic } },
2284
- {
2285
- component: Collider as never,
2286
- data: { shape: ColliderShapeValue.capsule, radius: 0.3, halfHeight: 0.5 },
2287
- },
2288
- { component: CollidingEntities as never, data: { entities: [] } },
2289
- )
2290
- .unwrap();
2274
+ const player = spawnPairedEntity(
2275
+ world,
2276
+ { component: Transform as never, data: { pos: [0, 0, 0] } },
2277
+ { component: RigidBody as never, data: { type: RigidBodyTypeValue.kinematic } },
2278
+ {
2279
+ component: Collider as never,
2280
+ data: { shape: ColliderShapeValue.capsule, radius: 0.3, halfHeight: 0.5 },
2281
+ },
2282
+ { component: CollidingEntities as never, data: { entities: [] } },
2283
+ ).unwrap();
2291
2284
 
2292
2285
  // A "Core" kinematic SENSOR overlapping the player.
2293
- const core = world
2294
- .spawn(
2295
- { component: Transform as never, data: { pos: [0, 0, 0] } },
2296
- { component: RigidBody as never, data: { type: RigidBodyTypeValue.kinematic } },
2297
- {
2298
- component: Collider as never,
2299
- data: { shape: ColliderShapeValue.sphere, radius: 0.35, isSensor: true },
2300
- },
2301
- { component: CollidingEntities as never, data: { entities: [] } },
2302
- )
2303
- .unwrap();
2286
+ const core = spawnPairedEntity(
2287
+ world,
2288
+ { component: Transform as never, data: { pos: [0, 0, 0] } },
2289
+ { component: RigidBody as never, data: { type: RigidBodyTypeValue.kinematic } },
2290
+ {
2291
+ component: Collider as never,
2292
+ data: { shape: ColliderShapeValue.sphere, radius: 0.35, isSensor: true },
2293
+ },
2294
+ { component: CollidingEntities as never, data: { entities: [] } },
2295
+ ).unwrap();
2304
2296
 
2305
2297
  world.update(1 / 60).unwrap();
2306
2298
  // A few ticks: tick 1 builds bodies (ensureBody), the next steps + drains.
@@ -2323,28 +2315,26 @@ describe('incremental physics ECS reconciliation', () => {
2323
2315
  world.insertResource('PhysicsWorld', pw);
2324
2316
  registerPhysicsSystems(world);
2325
2317
 
2326
- const player = world
2327
- .spawn(
2328
- { component: Transform as never, data: { pos: [0, 0, 0] } },
2329
- { component: RigidBody as never, data: { type: RigidBodyTypeValue.kinematic } },
2330
- {
2331
- component: Collider as never,
2332
- data: { shape: ColliderShapeValue.capsule, radius: 0.3, halfHeight: 0.5 },
2333
- },
2334
- { component: CollidingEntities as never, data: { entities: [] } },
2335
- )
2336
- .unwrap();
2337
- const core = world
2338
- .spawn(
2339
- { component: Transform as never, data: { pos: [0, 0, 0] } },
2340
- { component: RigidBody as never, data: { type: RigidBodyTypeValue.kinematic } },
2341
- {
2342
- component: Collider as never,
2343
- data: { shape: ColliderShapeValue.sphere, radius: 0.35, isSensor: true },
2344
- },
2345
- { component: CollidingEntities as never, data: { entities: [] } },
2346
- )
2347
- .unwrap();
2318
+ const player = spawnPairedEntity(
2319
+ world,
2320
+ { component: Transform as never, data: { pos: [0, 0, 0] } },
2321
+ { component: RigidBody as never, data: { type: RigidBodyTypeValue.kinematic } },
2322
+ {
2323
+ component: Collider as never,
2324
+ data: { shape: ColliderShapeValue.capsule, radius: 0.3, halfHeight: 0.5 },
2325
+ },
2326
+ { component: CollidingEntities as never, data: { entities: [] } },
2327
+ ).unwrap();
2328
+ const core = spawnPairedEntity(
2329
+ world,
2330
+ { component: Transform as never, data: { pos: [0, 0, 0] } },
2331
+ { component: RigidBody as never, data: { type: RigidBodyTypeValue.kinematic } },
2332
+ {
2333
+ component: Collider as never,
2334
+ data: { shape: ColliderShapeValue.sphere, radius: 0.35, isSensor: true },
2335
+ },
2336
+ { component: CollidingEntities as never, data: { entities: [] } },
2337
+ ).unwrap();
2348
2338
 
2349
2339
  world.update(1 / 60).unwrap();
2350
2340
  for (let i = 0; i < 4; i++) world.update(1 / 60).unwrap();