@gg-web-engine/rapier3d 0.0.65 → 0.0.67

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.
@@ -1,6 +1,7 @@
1
1
  import { CharacterController3dOptions, CollisionGroup, DebugBody3DSettings, Entity3d, ICharacterController3dComponent, Point3, Point4 } from '@gg-web-engine/core';
2
2
  import { Collider, InteractionGroups, KinematicCharacterController, RigidBody, RigidBodyDesc } from '@dimforge/rapier3d-compat';
3
3
  import { Rapier3dWorldComponent } from './rapier-3d-world.component';
4
+ import { Rapier3dRigidBodyComponent } from './rapier-3d-rigid-body.component';
4
5
  import { Rapier3dGgWorld, Rapier3dPhysicsTypeDocRepo } from '../types';
5
6
  /**
6
7
  * A capsule-shaped kinematic character controller backed by Rapier's own `KinematicCharacterController`
@@ -43,6 +44,12 @@ export declare class Rapier3dCharacterControllerComponent implements ICharacterC
43
44
  get isGrounded(): boolean;
44
45
  private _groundNormal;
45
46
  get groundNormal(): Point3 | null;
47
+ /** See `ICharacterController3dComponent.ignoredBodies`'s doc. Consulted fresh every `move()` call
48
+ * via `computeColliderMovement`'s own `filterPredicate` - unlike `AmmoCharacterControllerComponent`
49
+ * (which has to fake this by temporarily pulling ignored bodies out of the collision world),
50
+ * Rapier's character controller supports excluding specific colliders from a single query
51
+ * natively, so no such trick is needed here. */
52
+ readonly ignoredBodies: Set<Rapier3dRigidBodyComponent>;
46
53
  private static warnedMissingDtForPush;
47
54
  protected _nativeBody: RigidBody | null;
48
55
  protected _nativeCollider: Collider | null;
@@ -71,6 +78,14 @@ export declare class Rapier3dCharacterControllerComponent implements ICharacterC
71
78
  * needed here.
72
79
  */
73
80
  private syncColliderTransform;
81
+ /**
82
+ * Builds `computeColliderMovement`'s `filterPredicate` from `ignoredBodies` - `undefined` when
83
+ * empty (the common case) rather than an always-true closure, so an empty `ignoredBodies` set costs
84
+ * nothing extra per query. Compares by `RigidBody.handle` (a plain numeric id), not object
85
+ * identity - `Collider.parent()` isn't guaranteed to return the same wrapper instance across calls
86
+ * for the pinned `@dimforge/rapier3d-compat` build, only the same underlying native body.
87
+ */
88
+ private ignoredBodiesFilterPredicate;
74
89
  move(desiredTranslation: Point3, dt?: number): void;
75
90
  /**
76
91
  * Shoves any dynamic body this tick's sweep bumped into - see `addToWorld`'s doc for why this is
@@ -112,6 +112,12 @@ export class Rapier3dCharacterControllerComponent {
112
112
  this.name = '';
113
113
  this._isGrounded = false;
114
114
  this._groundNormal = null;
115
+ /** See `ICharacterController3dComponent.ignoredBodies`'s doc. Consulted fresh every `move()` call
116
+ * via `computeColliderMovement`'s own `filterPredicate` - unlike `AmmoCharacterControllerComponent`
117
+ * (which has to fake this by temporarily pulling ignored bodies out of the collision world),
118
+ * Rapier's character controller supports excluding specific colliders from a single query
119
+ * natively, so no such trick is needed here. */
120
+ this.ignoredBodies = new Set();
115
121
  this._nativeBody = null;
116
122
  this._nativeCollider = null;
117
123
  this._nativeController = null;
@@ -135,6 +141,31 @@ export class Rapier3dCharacterControllerComponent {
135
141
  syncColliderTransform() {
136
142
  this.world.nativeWorld.propagateModifiedBodyPositionsToColliders();
137
143
  }
144
+ /**
145
+ * Builds `computeColliderMovement`'s `filterPredicate` from `ignoredBodies` - `undefined` when
146
+ * empty (the common case) rather than an always-true closure, so an empty `ignoredBodies` set costs
147
+ * nothing extra per query. Compares by `RigidBody.handle` (a plain numeric id), not object
148
+ * identity - `Collider.parent()` isn't guaranteed to return the same wrapper instance across calls
149
+ * for the pinned `@dimforge/rapier3d-compat` build, only the same underlying native body.
150
+ */
151
+ ignoredBodiesFilterPredicate() {
152
+ if (this.ignoredBodies.size === 0) {
153
+ return undefined;
154
+ }
155
+ const ignoredHandles = new Set();
156
+ for (const body of this.ignoredBodies) {
157
+ if (body.nativeBody) {
158
+ ignoredHandles.add(body.nativeBody.handle);
159
+ }
160
+ }
161
+ if (ignoredHandles.size === 0) {
162
+ return undefined;
163
+ }
164
+ return (collider) => {
165
+ const parent = collider.parent();
166
+ return !parent || !ignoredHandles.has(parent.handle);
167
+ };
168
+ }
138
169
  move(desiredTranslation, dt) {
139
170
  if (!this._nativeBody || !this._nativeCollider || !this._nativeController) {
140
171
  // not yet added to the world - nothing to sweep against (matches
@@ -180,7 +211,7 @@ export class Rapier3dCharacterControllerComponent {
180
211
  }
181
212
  }
182
213
  const desired = new Vector3(desiredTranslation.x, desiredTranslation.y, desiredTranslation.z);
183
- this._nativeController.computeColliderMovement(this._nativeCollider, desired);
214
+ this._nativeController.computeColliderMovement(this._nativeCollider, desired, undefined, undefined, this.ignoredBodiesFilterPredicate());
184
215
  const computed = this._nativeController.computedMovement();
185
216
  const current = this._nativeBody.translation();
186
217
  const next = new Vector3(current.x + computed.x, current.y + computed.y, current.z + computed.z);
@@ -1,5 +1,6 @@
1
- import { Body3DOptions, CollisionGroup, DebugBody3DSettings, Entity3d, IRigidBody3dComponent, Point3, Point4, Shape3DDescriptor } from '@gg-web-engine/core';
1
+ import { Body3DOptions, CollisionEvent, CollisionGroup, DebugBody3DSettings, Entity3d, IRigidBody3dComponent, Point3, Point4, Shape3DDescriptor } from '@gg-web-engine/core';
2
2
  import { Collider, ColliderDesc, InteractionGroups, RigidBody, RigidBodyDesc } from '@dimforge/rapier3d-compat';
3
+ import { Observable, Subject } from 'rxjs';
3
4
  import { Rapier3dWorldComponent } from './rapier-3d-world.component';
4
5
  import { Rapier3dGgWorld, Rapier3dPhysicsTypeDocRepo } from '../types';
5
6
  export declare class Rapier3dRigidBodyComponent implements IRigidBody3dComponent<Rapier3dPhysicsTypeDocRepo> {
@@ -23,6 +24,33 @@ export declare class Rapier3dRigidBodyComponent implements IRigidBody3dComponent
23
24
  get nativeBody(): RigidBody | null;
24
25
  set nativeBody(value: RigidBody | null);
25
26
  name: string;
27
+ /**
28
+ * Other rigid-body components this one is currently touching via a real (non-sensor) contact -
29
+ * mirrors `Rapier3dTriggerComponent.overlaps`, but symmetric: both sides of an ordinary collision
30
+ * get notified, so both sides track it. Populated/drained by `Rapier3dWorldComponent`'s centralized
31
+ * collision-event dispatch (see `notifyCollisionStart`/`notifyCollisionEnd` below), and consulted by
32
+ * `removeFromWorld` to emit `onCollisionEnd(null)` to any partner still touching this body at the
33
+ * moment it's removed (per `CollisionEvent`'s "null when the other body was removed from the world
34
+ * while still in contact" convention) - Rapier does not reliably emit a native collision-stop event
35
+ * for a collider that's simply deleted mid-contact (same reason `Rapier3dTriggerComponent.
36
+ * checkOverlaps` has its own manual `!body.nativeBody` cleanup pass instead of trusting the event
37
+ * queue for that case).
38
+ */
39
+ readonly collidingWith: Set<Rapier3dRigidBodyComponent>;
40
+ protected readonly onCollisionStart$: Subject<CollisionEvent<Point3, Rapier3dRigidBodyComponent>>;
41
+ protected readonly onCollisionEnd$: Subject<Rapier3dRigidBodyComponent | null>;
42
+ get onCollisionStart(): Observable<CollisionEvent<Point3, Rapier3dRigidBodyComponent>>;
43
+ get onCollisionEnd(): Observable<Rapier3dRigidBodyComponent | null>;
44
+ /**
45
+ * Called by `Rapier3dWorldComponent`'s centralized collision-event dispatch (see
46
+ * `Rapier3dWorldComponent.simulate`) - not meant to be called by app code directly. Kept `public`
47
+ * (rather than some cross-class-accessible `protected`) purely because the dispatching class isn't
48
+ * a subclass of this one; there's nothing else in this package it's meant to be called from.
49
+ */
50
+ notifyCollisionStart(event: CollisionEvent<Point3, Rapier3dRigidBodyComponent>): void;
51
+ /** See `notifyCollisionStart`'s doc. `otherBody: null` signals the partner was removed from the
52
+ * world while still in contact, per `onCollisionEnd`'s doc. */
53
+ notifyCollisionEnd(otherBody: Rapier3dRigidBodyComponent | null): void;
26
54
  get factoryProps(): [
27
55
  ColliderDesc[],
28
56
  Shape3DDescriptor,
@@ -1,12 +1,13 @@
1
1
  import { BitMask, DebugBody3DSettings, Pnt3, Qtrn, } from '@gg-web-engine/core';
2
2
  import { ColliderDesc, Quaternion, RigidBodyDesc, Vector3, } from '@dimforge/rapier3d-compat';
3
+ import { Subject } from 'rxjs';
3
4
  export class Rapier3dRigidBodyComponent {
4
5
  get position() {
5
6
  return Pnt3.clone(this.nativeBody ? this.nativeBody.translation() : this._bodyDescr.translation);
6
7
  }
7
8
  set position(value) {
8
9
  if (this.nativeBody) {
9
- this.nativeBody.setTranslation(new Vector3(value.x, value.y, value.z), false);
10
+ this.nativeBody.setTranslation(new Vector3(value.x, value.y, value.z), true);
10
11
  }
11
12
  else {
12
13
  this._bodyDescr.setTranslation(value.x, value.y, value.z);
@@ -17,7 +18,7 @@ export class Rapier3dRigidBodyComponent {
17
18
  }
18
19
  set rotation(value) {
19
20
  if (this.nativeBody) {
20
- this.nativeBody.setRotation(new Quaternion(value.x, value.y, value.z, value.w), false);
21
+ this.nativeBody.setRotation(new Quaternion(value.x, value.y, value.z, value.w), true);
21
22
  }
22
23
  else {
23
24
  this._bodyDescr.setRotation(new Quaternion(value.x, value.y, value.z, value.w));
@@ -29,7 +30,8 @@ export class Rapier3dRigidBodyComponent {
29
30
  }
30
31
  set linearVelocity(value) {
31
32
  if (this.nativeBody) {
32
- this.nativeBody.setLinvel(new Vector3(value.x, value.y, value.z), false);
33
+ // see `position`'s setter above for why `true` (wake up) is required
34
+ this.nativeBody.setLinvel(new Vector3(value.x, value.y, value.z), true);
33
35
  }
34
36
  }
35
37
  get angularVelocity() {
@@ -38,7 +40,8 @@ export class Rapier3dRigidBodyComponent {
38
40
  }
39
41
  set angularVelocity(value) {
40
42
  if (this.nativeBody) {
41
- this.nativeBody.setAngvel(new Vector3(value.x, value.y, value.z), false);
43
+ // see `position`'s setter above for why `true` (wake up) is required
44
+ this.nativeBody.setAngvel(new Vector3(value.x, value.y, value.z), true);
42
45
  }
43
46
  }
44
47
  get nativeBody() {
@@ -50,6 +53,30 @@ export class Rapier3dRigidBodyComponent {
50
53
  }
51
54
  this._nativeBody = value;
52
55
  }
56
+ get onCollisionStart() {
57
+ return this.onCollisionStart$.asObservable();
58
+ }
59
+ get onCollisionEnd() {
60
+ return this.onCollisionEnd$.asObservable();
61
+ }
62
+ /**
63
+ * Called by `Rapier3dWorldComponent`'s centralized collision-event dispatch (see
64
+ * `Rapier3dWorldComponent.simulate`) - not meant to be called by app code directly. Kept `public`
65
+ * (rather than some cross-class-accessible `protected`) purely because the dispatching class isn't
66
+ * a subclass of this one; there's nothing else in this package it's meant to be called from.
67
+ */
68
+ notifyCollisionStart(event) {
69
+ this.collidingWith.add(event.otherBody);
70
+ this.onCollisionStart$.next(event);
71
+ }
72
+ /** See `notifyCollisionStart`'s doc. `otherBody: null` signals the partner was removed from the
73
+ * world while still in contact, per `onCollisionEnd`'s doc. */
74
+ notifyCollisionEnd(otherBody) {
75
+ if (otherBody) {
76
+ this.collidingWith.delete(otherBody);
77
+ }
78
+ this.onCollisionEnd$.next(otherBody);
79
+ }
53
80
  get factoryProps() {
54
81
  const colliderDescr = this._colliderDescr.map(cd => {
55
82
  const d = new ColliderDesc(cd.shape);
@@ -82,6 +109,21 @@ export class Rapier3dRigidBodyComponent {
82
109
  this._nativeBody = null;
83
110
  this._nativeBodyColliders = null;
84
111
  this.name = '';
112
+ /**
113
+ * Other rigid-body components this one is currently touching via a real (non-sensor) contact -
114
+ * mirrors `Rapier3dTriggerComponent.overlaps`, but symmetric: both sides of an ordinary collision
115
+ * get notified, so both sides track it. Populated/drained by `Rapier3dWorldComponent`'s centralized
116
+ * collision-event dispatch (see `notifyCollisionStart`/`notifyCollisionEnd` below), and consulted by
117
+ * `removeFromWorld` to emit `onCollisionEnd(null)` to any partner still touching this body at the
118
+ * moment it's removed (per `CollisionEvent`'s "null when the other body was removed from the world
119
+ * while still in contact" convention) - Rapier does not reliably emit a native collision-stop event
120
+ * for a collider that's simply deleted mid-contact (same reason `Rapier3dTriggerComponent.
121
+ * checkOverlaps` has its own manual `!body.nativeBody` cleanup pass instead of trusting the event
122
+ * queue for that case).
123
+ */
124
+ this.collidingWith = new Set();
125
+ this.onCollisionStart$ = new Subject();
126
+ this.onCollisionEnd$ = new Subject();
85
127
  this.collisionGroups = BitMask.full(32);
86
128
  this.ownCollisionGroups = (_colliderOptions === null || _colliderOptions === void 0 ? void 0 : _colliderOptions.ownCollisionGroups) || [world.mainCollisionGroup];
87
129
  this.interactWithCollisionGroups = (_colliderOptions === null || _colliderOptions === void 0 ? void 0 : _colliderOptions.interactWithCollisionGroups) || [world.mainCollisionGroup];
@@ -154,6 +196,13 @@ export class Rapier3dRigidBodyComponent {
154
196
  if (world.physicsWorld != this.world) {
155
197
  throw new Error('Rapier3D bodies cannot be shared between different worlds');
156
198
  }
199
+ // notify every body still touching this one that the contact ended because *this* body vanished
200
+ // (not because they physically separated) - see `collidingWith`'s doc.
201
+ for (const other of this.collidingWith) {
202
+ other.collidingWith.delete(this);
203
+ other.notifyCollisionEnd(null);
204
+ }
205
+ this.collidingWith.clear();
157
206
  if (this._nativeBody) {
158
207
  for (const col of this._nativeBodyColliders) {
159
208
  this.world.nativeWorld.removeCollider(col, false);
@@ -173,5 +222,7 @@ export class Rapier3dRigidBodyComponent {
173
222
  if (this.nativeBody) {
174
223
  this.removeFromWorld({ physicsWorld: this.world }, true);
175
224
  }
225
+ this.onCollisionStart$.complete();
226
+ this.onCollisionEnd$.complete();
176
227
  }
177
228
  }
@@ -18,7 +18,28 @@ export declare class Rapier3dTriggerComponent extends Rapier3dRigidBodyComponent
18
18
  constructor(world: Rapier3dWorldComponent, _colliderDescr: ColliderDesc[], shape: Shape3DDescriptor, _bodyDescr: RigidBodyDesc);
19
19
  addToWorld(world: Rapier3dGgWorld): void;
20
20
  removeFromWorld(world: Rapier3dGgWorld, dispose?: boolean): void;
21
+ /**
22
+ * Called by `Rapier3dWorldComponent`'s centralized collision-event dispatch (see
23
+ * `Rapier3dWorldComponent.simulate`) once per drained sensor-intersection event involving this
24
+ * trigger - not meant to be called by app code directly. Previously this trigger drained
25
+ * `world.eventQueue` itself from inside `checkOverlaps()`, matching `otherBody` by comparing an
26
+ * event's *collider* handle against `this.nativeBody?.handle` (a *rigid-body* handle) - those are
27
+ * two different handle namespaces in this pinned `@dimforge/rapier3d-compat` build, and the
28
+ * comparison only ever happened to work by coincidence (a body's first/only collider is allocated
29
+ * from a separate arena that, absent any prior removals, marches in lockstep with the rigid-body
30
+ * arena for the common one-collider-per-body case - see `gg-engine-physics-adapter-rapier` for the
31
+ * full incident). `EventQueue.drainCollisionEvents` also fully drains the *shared* queue on every
32
+ * call, so more than one consumer draining it independently (this trigger, another trigger, and now
33
+ * `Rapier3dRigidBodyComponent`'s own collision events) would silently steal each other's events -
34
+ * `Rapier3dWorldComponent` is now the single place that drains it, resolving collider handles to
35
+ * components correctly via `Collider.parent()`, and pushes matching events to whichever
36
+ * component(s) care.
37
+ */
38
+ notifyOverlap(otherBody: Rapier3dRigidBodyComponent, started: boolean): void;
21
39
  checkOverlaps(): void;
22
40
  clone(): Rapier3dTriggerComponent;
41
+ /** Completes `onEnter$`/`onLeft$` on top of `Rapier3dRigidBodyComponent.dispose()`'s own
42
+ * `onCollisionStart$`/`onCollisionEnd$` completion (via `super.dispose()`) - this trigger's own
43
+ * subjects have no other owner to complete them. */
23
44
  dispose(): void;
24
45
  }
@@ -36,27 +36,38 @@ export class Rapier3dTriggerComponent extends Rapier3dRigidBodyComponent {
36
36
  this.overlaps.clear();
37
37
  super.removeFromWorld(world, dispose);
38
38
  }
39
+ /**
40
+ * Called by `Rapier3dWorldComponent`'s centralized collision-event dispatch (see
41
+ * `Rapier3dWorldComponent.simulate`) once per drained sensor-intersection event involving this
42
+ * trigger - not meant to be called by app code directly. Previously this trigger drained
43
+ * `world.eventQueue` itself from inside `checkOverlaps()`, matching `otherBody` by comparing an
44
+ * event's *collider* handle against `this.nativeBody?.handle` (a *rigid-body* handle) - those are
45
+ * two different handle namespaces in this pinned `@dimforge/rapier3d-compat` build, and the
46
+ * comparison only ever happened to work by coincidence (a body's first/only collider is allocated
47
+ * from a separate arena that, absent any prior removals, marches in lockstep with the rigid-body
48
+ * arena for the common one-collider-per-body case - see `gg-engine-physics-adapter-rapier` for the
49
+ * full incident). `EventQueue.drainCollisionEvents` also fully drains the *shared* queue on every
50
+ * call, so more than one consumer draining it independently (this trigger, another trigger, and now
51
+ * `Rapier3dRigidBodyComponent`'s own collision events) would silently steal each other's events -
52
+ * `Rapier3dWorldComponent` is now the single place that drains it, resolving collider handles to
53
+ * components correctly via `Collider.parent()`, and pushes matching events to whichever
54
+ * component(s) care.
55
+ */
56
+ notifyOverlap(otherBody, started) {
57
+ if (started) {
58
+ this.overlaps.add(otherBody);
59
+ this.onEnter$.next(otherBody);
60
+ }
61
+ else {
62
+ this.overlaps.delete(otherBody);
63
+ this.onLeft$.next(otherBody);
64
+ }
65
+ }
39
66
  checkOverlaps() {
40
- this.world.eventQueue.drainCollisionEvents((h1, h2, started) => {
41
- var _a, _b;
42
- let otherBody;
43
- if (h1 === ((_a = this.nativeBody) === null || _a === void 0 ? void 0 : _a.handle)) {
44
- otherBody = this.world.handleIdEntityMap.get(h2);
45
- }
46
- else if (h2 === ((_b = this.nativeBody) === null || _b === void 0 ? void 0 : _b.handle)) {
47
- otherBody = this.world.handleIdEntityMap.get(h1);
48
- }
49
- if (!otherBody)
50
- return;
51
- if (started) {
52
- this.overlaps.add(otherBody);
53
- this.onEnter$.next(otherBody);
54
- }
55
- else {
56
- this.overlaps.delete(otherBody);
57
- this.onLeft$.next(otherBody);
58
- }
59
- });
67
+ // Rapier does not reliably emit a native intersection-stop event for a collider that's simply
68
+ // removed from the world mid-overlap (confirmed empirically) - this manual pass catches that case
69
+ // every tick regardless of whether `Rapier3dWorldComponent.simulate`'s own event dispatch already
70
+ // ran this tick.
60
71
  for (const body of this.overlaps.keys()) {
61
72
  if (!body.nativeBody) {
62
73
  this.overlaps.delete(body);
@@ -71,8 +82,13 @@ export class Rapier3dTriggerComponent extends Rapier3dRigidBodyComponent {
71
82
  component.interactWithCollisionGroups = this.interactWithCollisionGroups;
72
83
  return component;
73
84
  }
85
+ /** Completes `onEnter$`/`onLeft$` on top of `Rapier3dRigidBodyComponent.dispose()`'s own
86
+ * `onCollisionStart$`/`onCollisionEnd$` completion (via `super.dispose()`) - this trigger's own
87
+ * subjects have no other owner to complete them. */
74
88
  dispose() {
75
89
  this.overlaps.clear();
90
+ this.onEnter$.complete();
91
+ this.onLeft$.complete();
76
92
  super.dispose();
77
93
  }
78
94
  }
@@ -1,5 +1,5 @@
1
1
  import { CollisionGroup, IPhysicsWorld3dComponent, Point3, RaycastOptions, RaycastResult } from '@gg-web-engine/core';
2
- import { EventQueue, World } from '@dimforge/rapier3d-compat';
2
+ import { Collider, EventQueue, World } from '@dimforge/rapier3d-compat';
3
3
  import { Rapier3dRigidBodyComponent } from './rapier-3d-rigid-body.component';
4
4
  import { Rapier3dCharacterControllerComponent } from './rapier-3d-character-controller.component';
5
5
  import { Rapier3dFactory } from '../rapier-3d-factory';
@@ -27,6 +27,46 @@ export declare class Rapier3dWorldComponent implements IPhysicsWorld3dComponent<
27
27
  constructor();
28
28
  init(): Promise<void>;
29
29
  simulate(delta: number): void;
30
+ /**
31
+ * Drains `eventQueue` exactly once per `simulate()` call and routes each entry to whichever
32
+ * component(s) care - this is the *only* place `drainCollisionEvents` is called for the whole
33
+ * world (see `Rapier3dTriggerComponent.notifyOverlap`'s doc for why a second/independent drain
34
+ * elsewhere would silently steal events from this one). A collider pair with sensor
35
+ * semantics (either side `isSensor()`) is routed as a trigger overlap; an ordinary pair is routed
36
+ * as a real rigid-body collision.
37
+ *
38
+ * `EventQueue.drainCollisionEvents` reports *collider* handles, not rigid-body handles -
39
+ * `handleIdEntityMap` is keyed by rigid-body handle (see `addToWorld`), so each handle is resolved
40
+ * via `World.getCollider(handle)` (returns `null` for a since-removed collider, not a throw - safe
41
+ * to just skip) then `Collider.parent()` to reach the owning `RigidBody` before the map lookup.
42
+ */
43
+ protected dispatchCollisionEvents(): void;
44
+ /**
45
+ * Reads world-space contact position/normal/impulse for a just-started contact between two
46
+ * non-sensor colliders via `World.contactPair`'s `TempContactManifold`. Returns `null` only if the
47
+ * pair has no manifold at all (shouldn't normally happen for a pair `drainCollisionEvents` just
48
+ * reported as newly touching, but guarded defensively). `World.contactPair`'s callback can in
49
+ * principle be invoked once per manifold between the pair - for the compound/multi-collider shapes
50
+ * this can matter for, each sub-collider pair already gets its own separate `drainCollisionEvents`
51
+ * entry (and thus its own `computeContactGeometry` call) since collision events are per-*collider*,
52
+ * not per-body, so in practice a single call here only ever sees one manifold; only the first
53
+ * encountered is used regardless. `impulse` sums `contactImpulse(i)` across every contact point of
54
+ * that manifold (an already-solved *impulse*, not a force estimate - the pinned
55
+ * `@dimforge/rapier3d-compat` build's constraint solver runs within the same `World.step()` call
56
+ * that produced this `started` event, so the manifold's impulses are already up to date by the time
57
+ * this runs). `normal` is returned oriented "away from `collider1` towards `collider2`" - Rapier's
58
+ * `contactPair(a, b, f)` reports whether its own internally-cached manifold order matches the
59
+ * queried `(a, b)` order via the callback's `flipped` flag; when `flipped` is `true` the manifold's
60
+ * `normal()` (always world-space, always pointing from the manifold's own shape1 to shape2) actually
61
+ * points from `collider2` to `collider1` and must be negated to match this method's documented
62
+ * orientation - verified empirically against a ball resting on a floor below it (see
63
+ * `gg-engine-physics-adapter-rapier` for the exact reproduction).
64
+ */
65
+ protected computeContactGeometry(collider1: Collider, collider2: Collider): {
66
+ position: Point3;
67
+ normal: Point3;
68
+ impulse: number;
69
+ } | null;
30
70
  protected lockedCollisionGroups: number[];
31
71
  registerCollisionGroup(): CollisionGroup;
32
72
  deregisterCollisionGroup(group: CollisionGroup): void;
@@ -9,6 +9,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
9
9
  };
10
10
  import { BitMask, Pnt3, } from '@gg-web-engine/core';
11
11
  import { EventQueue, init, Vector3, World } from '@dimforge/rapier3d-compat';
12
+ import { Rapier3dTriggerComponent } from './rapier-3d-trigger.component';
12
13
  import { Rapier3dFactory } from '../rapier-3d-factory';
13
14
  import { Rapier3dLoader } from '../rapier-3d-loader';
14
15
  import { Subject } from 'rxjs';
@@ -76,6 +77,135 @@ export class Rapier3dWorldComponent {
76
77
  var _a;
77
78
  this._nativeWorld.timestep = delta / 1000;
78
79
  (_a = this._nativeWorld) === null || _a === void 0 ? void 0 : _a.step(this.eventQueue);
80
+ this.dispatchCollisionEvents();
81
+ }
82
+ /**
83
+ * Drains `eventQueue` exactly once per `simulate()` call and routes each entry to whichever
84
+ * component(s) care - this is the *only* place `drainCollisionEvents` is called for the whole
85
+ * world (see `Rapier3dTriggerComponent.notifyOverlap`'s doc for why a second/independent drain
86
+ * elsewhere would silently steal events from this one). A collider pair with sensor
87
+ * semantics (either side `isSensor()`) is routed as a trigger overlap; an ordinary pair is routed
88
+ * as a real rigid-body collision.
89
+ *
90
+ * `EventQueue.drainCollisionEvents` reports *collider* handles, not rigid-body handles -
91
+ * `handleIdEntityMap` is keyed by rigid-body handle (see `addToWorld`), so each handle is resolved
92
+ * via `World.getCollider(handle)` (returns `null` for a since-removed collider, not a throw - safe
93
+ * to just skip) then `Collider.parent()` to reach the owning `RigidBody` before the map lookup.
94
+ */
95
+ dispatchCollisionEvents() {
96
+ const nativeWorld = this._nativeWorld;
97
+ if (!nativeWorld) {
98
+ return;
99
+ }
100
+ this.eventQueue.drainCollisionEvents((h1, h2, started) => {
101
+ const collider1 = nativeWorld.getCollider(h1);
102
+ const collider2 = nativeWorld.getCollider(h2);
103
+ if (!collider1 || !collider2) {
104
+ return;
105
+ }
106
+ const body1 = collider1.parent();
107
+ const body2 = collider2.parent();
108
+ if (!body1 || !body2) {
109
+ return;
110
+ }
111
+ const comp1 = this.handleIdEntityMap.get(body1.handle);
112
+ const comp2 = this.handleIdEntityMap.get(body2.handle);
113
+ if (!comp1 || !comp2 || comp1 === comp2) {
114
+ return;
115
+ }
116
+ if (collider1.isSensor() || collider2.isSensor()) {
117
+ if (comp1 instanceof Rapier3dTriggerComponent) {
118
+ comp1.notifyOverlap(comp2, started);
119
+ }
120
+ if (comp2 instanceof Rapier3dTriggerComponent) {
121
+ comp2.notifyOverlap(comp1, started);
122
+ }
123
+ return;
124
+ }
125
+ if (started) {
126
+ const geometry = this.computeContactGeometry(collider1, collider2);
127
+ if (!geometry) {
128
+ return;
129
+ }
130
+ const v1 = body1.linvel();
131
+ const v2 = body2.linvel();
132
+ comp1.notifyCollisionStart({
133
+ otherBody: comp2,
134
+ position: geometry.position,
135
+ normal: geometry.normal,
136
+ relativeVelocity: Pnt3.sub(v2, v1),
137
+ impulse: geometry.impulse,
138
+ });
139
+ comp2.notifyCollisionStart({
140
+ otherBody: comp1,
141
+ position: geometry.position,
142
+ normal: Pnt3.scalarMult(geometry.normal, -1),
143
+ relativeVelocity: Pnt3.sub(v1, v2),
144
+ impulse: geometry.impulse,
145
+ });
146
+ }
147
+ else {
148
+ comp1.notifyCollisionEnd(comp2);
149
+ comp2.notifyCollisionEnd(comp1);
150
+ }
151
+ });
152
+ }
153
+ /**
154
+ * Reads world-space contact position/normal/impulse for a just-started contact between two
155
+ * non-sensor colliders via `World.contactPair`'s `TempContactManifold`. Returns `null` only if the
156
+ * pair has no manifold at all (shouldn't normally happen for a pair `drainCollisionEvents` just
157
+ * reported as newly touching, but guarded defensively). `World.contactPair`'s callback can in
158
+ * principle be invoked once per manifold between the pair - for the compound/multi-collider shapes
159
+ * this can matter for, each sub-collider pair already gets its own separate `drainCollisionEvents`
160
+ * entry (and thus its own `computeContactGeometry` call) since collision events are per-*collider*,
161
+ * not per-body, so in practice a single call here only ever sees one manifold; only the first
162
+ * encountered is used regardless. `impulse` sums `contactImpulse(i)` across every contact point of
163
+ * that manifold (an already-solved *impulse*, not a force estimate - the pinned
164
+ * `@dimforge/rapier3d-compat` build's constraint solver runs within the same `World.step()` call
165
+ * that produced this `started` event, so the manifold's impulses are already up to date by the time
166
+ * this runs). `normal` is returned oriented "away from `collider1` towards `collider2`" - Rapier's
167
+ * `contactPair(a, b, f)` reports whether its own internally-cached manifold order matches the
168
+ * queried `(a, b)` order via the callback's `flipped` flag; when `flipped` is `true` the manifold's
169
+ * `normal()` (always world-space, always pointing from the manifold's own shape1 to shape2) actually
170
+ * points from `collider2` to `collider1` and must be negated to match this method's documented
171
+ * orientation - verified empirically against a ball resting on a floor below it (see
172
+ * `gg-engine-physics-adapter-rapier` for the exact reproduction).
173
+ */
174
+ computeContactGeometry(collider1, collider2) {
175
+ const nativeWorld = this._nativeWorld;
176
+ if (!nativeWorld) {
177
+ return null;
178
+ }
179
+ let result = null;
180
+ nativeWorld.contactPair(collider1, collider2, (manifold, flipped) => {
181
+ if (result) {
182
+ // only the first manifold is used - see doc above.
183
+ return;
184
+ }
185
+ const rawNormal = manifold.normal();
186
+ const normal = flipped
187
+ ? { x: -rawNormal.x, y: -rawNormal.y, z: -rawNormal.z }
188
+ : { x: rawNormal.x, y: rawNormal.y, z: rawNormal.z };
189
+ const numSolverContacts = manifold.numSolverContacts();
190
+ let position = Pnt3.O;
191
+ if (numSolverContacts > 0) {
192
+ let sum = Pnt3.O;
193
+ for (let i = 0; i < numSolverContacts; i++) {
194
+ const p = manifold.solverContactPoint(i);
195
+ if (p) {
196
+ sum = Pnt3.add(sum, p);
197
+ }
198
+ }
199
+ position = Pnt3.scalarMult(sum, 1 / numSolverContacts);
200
+ }
201
+ let impulse = 0;
202
+ const numContacts = manifold.numContacts();
203
+ for (let i = 0; i < numContacts; i++) {
204
+ impulse += manifold.contactImpulse(i);
205
+ }
206
+ result = { position, normal, impulse };
207
+ });
208
+ return result;
79
209
  }
80
210
  registerCollisionGroup() {
81
211
  for (let i = 1; i < 16; i++) {
@@ -22,7 +22,14 @@ export class Rapier3dFactory {
22
22
  this.world = world;
23
23
  }
24
24
  createRigidBody(descriptor, transform) {
25
- return new Rapier3dRigidBodyComponent(this.world, this.createColliderDescr(descriptor.shape), descriptor.shape, this.createRigidBodyDescr(descriptor.body, transform), Object.assign({ friction: 0.5, restitution: 0.1, ownCollisionGroups: [this.world.mainCollisionGroup], interactWithCollisionGroups: [this.world.mainCollisionGroup] }, descriptor.body));
25
+ // `Rapier3dRigidBodyComponent.onCollisionStart`/`onCollisionEnd` are backed by Rapier's own
26
+ // collision-event queue (drained centrally by `Rapier3dWorldComponent.simulate`), which only
27
+ // reports a contact pair when *at least one* of its two colliders has `COLLISION_EVENTS` active
28
+ // (triggers already set this on their own sensor collider - see `createTrigger` below - but an
29
+ // ordinary rigid-body-vs-rigid-body contact needs it here too, since neither side had it before).
30
+ const colliderDescr = this.createColliderDescr(descriptor.shape);
31
+ colliderDescr.forEach(c => c.setActiveEvents(ActiveEvents.COLLISION_EVENTS));
32
+ return new Rapier3dRigidBodyComponent(this.world, colliderDescr, descriptor.shape, this.createRigidBodyDescr(descriptor.body, transform), Object.assign({ friction: 0.5, restitution: 0.1, ownCollisionGroups: [this.world.mainCollisionGroup], interactWithCollisionGroups: [this.world.mainCollisionGroup] }, descriptor.body));
26
33
  }
27
34
  createTrigger(descriptor, transform) {
28
35
  const colliderDescr = this.createColliderDescr(descriptor);
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@gg-web-engine/rapier3d",
3
- "version": "0.0.65",
3
+ "version": "0.0.67",
4
4
  "description": "An attempt to create open source game engine for browser",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
7
  "scripts": {
8
8
  "test": "jest",
9
- "prettier-format": "prettier --config ../core/.prettierrc 'src/**/*.ts' --write",
9
+ "prettier-format": "prettier --config ../../.prettierrc 'src/**/*.ts' --write",
10
10
  "prepublish": "rm -rf ./dist/ && tsc",
11
11
  "build": "tsc"
12
12
  },
@@ -28,7 +28,7 @@
28
28
  "homepage": "https://github.com/AndyGura/gg-web-engine#readme",
29
29
  "devDependencies": {
30
30
  "@dimforge/rapier3d-compat": "0.20.0",
31
- "@gg-web-engine/core": "0.0.65",
31
+ "@gg-web-engine/core": "0.0.67",
32
32
  "@types/jest": "^30.0.0",
33
33
  "@types/node": "^26.4.0",
34
34
  "jest": "^30.5.0",
@@ -40,7 +40,7 @@
40
40
  },
41
41
  "peerDependencies": {
42
42
  "@dimforge/rapier3d-compat": "0.20.0",
43
- "@gg-web-engine/core": "0.0.65",
43
+ "@gg-web-engine/core": "0.0.67",
44
44
  "rxjs": "7.8.2"
45
45
  },
46
46
  "jest": {
@@ -144,6 +144,46 @@ describe('Rapier3dCharacterControllerComponent', () => {
144
144
  expect(character.isGrounded).toBe(true);
145
145
  });
146
146
 
147
+ it(
148
+ 'walks straight through a body added to ignoredBodies instead of sliding to a stop against it ' +
149
+ "(regression: collision groups alone can't express excluding just one specific body while both " +
150
+ 'it and the character still need to collide with the rest of the world - see ' +
151
+ '`ICharacterController3dComponent.ignoredBodies`\'s doc)',
152
+ () => {
153
+ addFloor(0, 20, 0);
154
+ const wall = addWall(2);
155
+ const character = factory.createCharacterController(CHAR_OPTIONS, {
156
+ position: { x: 0, y: 0, z: HALF_HEIGHT + 0.5 },
157
+ });
158
+ character.addToWorld({ physicsWorld: world } as any);
159
+ character.ignoredBodies.add(wall);
160
+ settleWorld();
161
+ character.move({ x: 0, y: 0, z: -1 });
162
+
163
+ character.move({ x: 5, y: 0, z: 0 });
164
+
165
+ // unlike the baseline test above, nothing stopped it short of the wall's x=2 near face
166
+ expect(character.position.x).toBeCloseTo(5);
167
+ },
168
+ );
169
+
170
+ it('removing a body from ignoredBodies makes it block the character again', () => {
171
+ addFloor(0, 20, 0);
172
+ const wall = addWall(2);
173
+ const character = factory.createCharacterController(CHAR_OPTIONS, {
174
+ position: { x: 0, y: 0, z: HALF_HEIGHT + 0.5 },
175
+ });
176
+ character.addToWorld({ physicsWorld: world } as any);
177
+ character.ignoredBodies.add(wall);
178
+ character.ignoredBodies.delete(wall);
179
+ settleWorld();
180
+ character.move({ x: 0, y: 0, z: -1 });
181
+
182
+ character.move({ x: 5, y: 0, z: 0 });
183
+
184
+ expect(character.position.x).toBeLessThan(1.6);
185
+ });
186
+
147
187
  it('should step up a ledge shorter than maxStepHeight without getting stuck', () => {
148
188
  const maxStepHeight = 0.3;
149
189
  const stepZ = 0.2; // below maxStepHeight
@@ -0,0 +1,147 @@
1
+ import {
2
+ CharacterController3dEntity,
3
+ Grabbable3dEntity,
4
+ KeyboardInput,
5
+ MouseInput,
6
+ ObjectGrabController,
7
+ Pnt3,
8
+ Point3,
9
+ Qtrn,
10
+ } from '@gg-web-engine/core';
11
+ import { Rapier3dWorldComponent } from '../../src';
12
+
13
+ /**
14
+ * Regression coverage for the Rapier3d-specific counterpart of
15
+ * `packages/ammo/test/components/ammo-world.component.spec.ts`'s "should still grab a small target
16
+ * sitting closer than the ray origin overshoots to" test - both reproduce the same real scene (the
17
+ * `portal-room` example's pedestal + radio prop), but they can't share a test body: the two adapters
18
+ * were broken by, and fixed for, two different bugs at two different layers.
19
+ *
20
+ * Ammo's gap lived in `AmmoWorldComponent.raycast()` itself - Bullet's plain `rayTest` can't detect a
21
+ * shape already containing the ray's origin at all, so a ray landing *inside* the small prop found
22
+ * nothing until the adapter grew its own `solidRayFallback`. Rapier never had that gap -
23
+ * `Rapier3dWorldComponent.raycast()`'s `castRay(..., solid: true, ...)` already reports a distance-`0`
24
+ * hit for whatever shape contains the ray's origin, prop included, with no adapter-level fix needed.
25
+ *
26
+ * Rapier's bug was one level up, in `ObjectGrabController.tryGrab()` itself: `solid: true` doesn't
27
+ * just handle a ray landing inside the prop, it makes *any* containing shape win over anything
28
+ * farther away - so with the camera sitting inside the holder's own capsule (the ordinary
29
+ * first-person case), `tryGrab()`'s first, un-skipped cast always self-hit that capsule before ever
30
+ * reaching the prop, on every single attempt, close range or not. Recovering from that self-hit is
31
+ * `characterControllerSelfHitSkip()`'s job (see `gg-engine-core-development`'s own notes on it) - so
32
+ * these tests exercise that recovery through the real `ObjectGrabController` + real
33
+ * `Rapier3dWorldComponent` + real character-controller collider, not `Rapier3dWorldComponent.raycast()`
34
+ * in isolation (which, per the above, was never the part that was broken).
35
+ */
36
+ describe('ObjectGrabController + Rapier3dWorldComponent - end-to-end pick-up range', () => {
37
+ const setupPedestalAndRadio = (world: Rapier3dWorldComponent, radioPosition: Point3) => {
38
+ const pedestal = world.factory.createRigidBody(
39
+ { shape: { shape: 'CYLINDER', radius: 0.5, height: 0.9 }, body: { dynamic: false, mass: 0 } },
40
+ { position: { x: 0, y: 0, z: 0.45 } },
41
+ );
42
+ pedestal.addToWorld({ physicsWorld: world } as any);
43
+
44
+ const propBody = world.factory.createRigidBody(
45
+ { shape: { shape: 'BOX', dimensions: { x: 0.41, y: 0.23, z: 0.49 } }, body: { dynamic: true, mass: 3 } },
46
+ { position: radioPosition },
47
+ );
48
+ propBody.addToWorld({ physicsWorld: world } as any);
49
+ return new Grabbable3dEntity({ objectBody: propBody });
50
+ };
51
+
52
+ it('grabs a small prop resting on a pedestal even standing very close to it', async () => {
53
+ const world = new Rapier3dWorldComponent();
54
+ await world.init();
55
+ world.gravity = { x: 0, y: 0, z: 0 };
56
+
57
+ // Same geometry as the `portal-room` example (and the Ammo regression test referenced above): a
58
+ // pedestal with a small radio prop resting on top of it.
59
+ const prop = setupPedestalAndRadio(world, { x: 0, y: 0, z: 1.145 });
60
+
61
+ // player capsule radius 0.4 + centersDistance 1.0, matching both the example's own player
62
+ // options and the Ammo test's holder.
63
+ const characterController = world.factory.createCharacterController(
64
+ { radius: 0.4, centersDistance: 1.0 },
65
+ { position: { x: 0, y: -1.1, z: 1.0 } },
66
+ );
67
+ const holder = new CharacterController3dEntity(
68
+ { radius: 0.4, centersDistance: 1.0, walkSpeed: 4, jumpSpeed: 5, gravity: 9.82 },
69
+ null,
70
+ characterController,
71
+ );
72
+ holder.onSpawned({ physicsWorld: world } as any);
73
+ world.simulate(1);
74
+
75
+ // playerDistance = 1.1 (same number as the Ammo test) - close enough that the camera (same
76
+ // height as the prop, a purely horizontal look - no pitch needed to reproduce this) sits well
77
+ // within the holder capsule's own radius from its central axis, so `tryGrab()`'s first cast is
78
+ // guaranteed to self-hit that capsule before the retry this test is actually exercising kicks in.
79
+ const cameraPosition = { x: 0, y: -1.1, z: 1.145 };
80
+ const camera = { position: cameraPosition, rotation: Qtrn.lookAt(cameraPosition, { x: 0, y: 0, z: 1.145 }) } as any;
81
+
82
+ const keyboard = new KeyboardInput();
83
+ keyboard.start();
84
+ const mouseInput = new MouseInput();
85
+ mouseInput.start();
86
+ const controller = new ObjectGrabController(keyboard, mouseInput, camera, holder);
87
+ controller.onSpawned({ physicsWorld: world } as any);
88
+
89
+ keyboard.emulateKeyDown('KeyE');
90
+
91
+ expect(controller.heldObject).toBe(prop);
92
+ });
93
+
94
+ it(
95
+ 'grabs the prop at the exact position/rotation a real gameplay report found it ungrabbable at ' +
96
+ "(regression: a flat `radius`-sized self-hit skip - characterControllerSelfHitSkip()'s first " +
97
+ 'implementation - cleared the capsule fine for a purely horizontal look, but the real ' +
98
+ "first-person camera here sits 0.7m above the capsule center (`PlayerCharacterController`'s " +
99
+ 'default eyeHeight) - taller than the capsule half-height (0.5m) - so it starts inside the ' +
100
+ "rounded top cap, not the cylindrical body; the camera's real downward pitch toward the low " +
101
+ "radio then meant a flat radius skip landed the retry ray's start point still measurably " +
102
+ 'inside the capsule, self-hitting again and leaving the radio permanently ungrabbable at this ' +
103
+ 'exact spot. Fixed by sphere-tracing the capsule\'s own exact geometry instead of guessing a ' +
104
+ "flat distance - see `characterControllerSelfHitSkip()`'s own doc)",
105
+ async () => {
106
+ const world = new Rapier3dWorldComponent();
107
+ await world.init();
108
+ world.gravity = { x: 0, y: 0, z: -9.82 };
109
+
110
+ // Exact positions read from the in-game dev console's entity inspector during the failing
111
+ // report, not guessed.
112
+ const radioPosition = { x: -0.010856885462999344, y: 0.004407098516821861, z: 0.8990808129310608 };
113
+ const prop = setupPedestalAndRadio(world, radioPosition);
114
+
115
+ const playerPosition = { x: 0.03676546365022659, y: -0.9093552231788635, z: 0.9099816679954529 };
116
+ const playerRotation = { x: 0, y: 0, z: 0.01860076382738503, w: 0.9998269908264321 };
117
+ const characterController = world.factory.createCharacterController(
118
+ { radius: 0.4, centersDistance: 1.0 },
119
+ { position: playerPosition, rotation: playerRotation },
120
+ );
121
+ const holder = new CharacterController3dEntity(
122
+ { radius: 0.4, centersDistance: 1.0, walkSpeed: 4, jumpSpeed: 4, gravity: 9.82 },
123
+ null,
124
+ characterController,
125
+ );
126
+ holder.onSpawned({ physicsWorld: world } as any);
127
+ world.simulate(1);
128
+
129
+ // First-person camera at `PlayerCharacterController`'s default eyeHeight (0.7) above the
130
+ // player capsule's own center, looking at the radio - reproducing the actual aim, not just the
131
+ // position.
132
+ const cameraPosition = Pnt3.add(playerPosition, Pnt3.scalarMult(Pnt3.Z, 0.7));
133
+ const camera = { position: cameraPosition, rotation: Qtrn.lookAt(cameraPosition, radioPosition) } as any;
134
+
135
+ const keyboard = new KeyboardInput();
136
+ keyboard.start();
137
+ const mouseInput = new MouseInput();
138
+ mouseInput.start();
139
+ const controller = new ObjectGrabController(keyboard, mouseInput, camera, holder);
140
+ controller.onSpawned({ physicsWorld: world } as any);
141
+
142
+ keyboard.emulateKeyDown('KeyE');
143
+
144
+ expect(controller.heldObject).toBe(prop);
145
+ },
146
+ );
147
+ });
@@ -5,7 +5,16 @@ import { Rapier3dWorldComponent } from '../../src';
5
5
  // maps to world -Z at identity, in this Z-up engine), a degenerate/arbitrary case for
6
6
  // `PlayerCharacterController.reset()`'s yaw derivation; every real app sets an actual look
7
7
  // direction, so this is the representative case.
8
- const fakeCamera = () => ({ position: { x: 0, y: 0, z: 0 }, rotation: Qtrn.lookAt(Pnt3.O, Pnt3.Y) }) as any;
8
+ const fakeCamera = () =>
9
+ ({
10
+ position: { x: 0, y: 0, z: 0 },
11
+ rotation: Qtrn.lookAt(Pnt3.O, Pnt3.Y),
12
+ // `PlayerCharacterController`'s view-mode toggle calls these on its own camera - stubbed here
13
+ // purely so constructing it doesn't throw; this suite doesn't assert on render-layer state.
14
+ enableRenderLayer: () => {},
15
+ disableRenderLayer: () => {},
16
+ isRenderLayerEnabled: () => true,
17
+ }) as any;
9
18
 
10
19
  const setup = async () => {
11
20
  const world = new Rapier3dWorldComponent();
@@ -0,0 +1,185 @@
1
+ import { Pnt3 } from '@gg-web-engine/core';
2
+ import { Rapier3dFactory, Rapier3dWorldComponent } from '../../src';
3
+ import { Rapier3dRigidBodyComponent } from '../../src/components/rapier-3d-rigid-body.component';
4
+
5
+ describe('Rapier3dRigidBodyComponent collision events', () => {
6
+ let world: Rapier3dWorldComponent;
7
+ let factory: Rapier3dFactory;
8
+
9
+ beforeEach(async () => {
10
+ if (world) {
11
+ world.dispose();
12
+ }
13
+ world = new Rapier3dWorldComponent();
14
+ factory = new Rapier3dFactory(world);
15
+ await world.init();
16
+ });
17
+
18
+ afterAll(() => {
19
+ world.dispose();
20
+ });
21
+
22
+ // Small steps, mirroring Rapier3dTriggerComponent's own spec: a single huge `simulate()` call
23
+ // detects a contact a whole step late (narrow-phase runs against start-of-step positions), which
24
+ // would make a "did it fire at all" assertion flaky. `world.simulate()` now dispatches collision
25
+ // events internally (see `Rapier3dWorldComponent.dispatchCollisionEvents`), so no extra per-step
26
+ // hook is needed here the way `Rapier3dTriggerComponent.checkOverlaps()` needs calling for triggers.
27
+ const advance = (totalMs: number, stepMs = 16) => {
28
+ for (let elapsed = 0; elapsed < totalMs; elapsed += stepMs) {
29
+ world.simulate(stepMs);
30
+ }
31
+ };
32
+
33
+ const createFloor = () => {
34
+ const floor = factory.createRigidBody(
35
+ { shape: { shape: 'BOX', dimensions: { x: 20, y: 20, z: 1 } }, body: { dynamic: false } },
36
+ { position: { x: 0, y: 0, z: -0.5 } },
37
+ );
38
+ floor.addToWorld({ physicsWorld: world } as any);
39
+ return floor;
40
+ };
41
+
42
+ const createBall = (z: number) => {
43
+ // restitution 0 so the ball settles without bouncing, keeping the "no further onCollisionStart
44
+ // once resting" assertion below unambiguous.
45
+ const ball = factory.createRigidBody(
46
+ { shape: { shape: 'SPHERE', radius: 1 }, body: { dynamic: true, mass: 1, restitution: 0 } },
47
+ { position: { x: 0, y: 0, z } },
48
+ );
49
+ ball.addToWorld({ physicsWorld: world } as any);
50
+ return ball;
51
+ };
52
+
53
+ it('fires onCollisionStart on the falling body with a resolved otherBody, positive impulse, and a physically sane contact', () => {
54
+ const floor = createFloor();
55
+ const ball = createBall(5);
56
+
57
+ const ballEvents: any[] = [];
58
+ ball.onCollisionStart.subscribe(e => ballEvents.push(e));
59
+
60
+ advance(2000);
61
+
62
+ expect(ballEvents.length).toBeGreaterThanOrEqual(1);
63
+ const event = ballEvents[0];
64
+ expect(event.otherBody).toBe(floor);
65
+ expect(event.impulse).toBeGreaterThan(0);
66
+ // The ball starts above the floor - `normal` points away from the ball (this body) towards the
67
+ // floor (`otherBody`), so it should point roughly straight down.
68
+ expect(event.normal.z).toBeLessThan(-0.9);
69
+ expect(Math.abs(event.normal.x)).toBeLessThan(0.2);
70
+ expect(Math.abs(event.normal.y)).toBeLessThan(0.2);
71
+ // contact point should sit right around the floor's top surface (z = 0), not somewhere wild.
72
+ expect(event.position.z).toBeGreaterThan(-1);
73
+ expect(event.position.z).toBeLessThan(1);
74
+ });
75
+
76
+ it('fires the reciprocal onCollisionStart on the other body too, with a consistently-oriented normal/relativeVelocity', () => {
77
+ const floor = createFloor();
78
+ const ball = createBall(5);
79
+
80
+ const ballEvents: any[] = [];
81
+ const floorEvents: any[] = [];
82
+ ball.onCollisionStart.subscribe(e => ballEvents.push(e));
83
+ floor.onCollisionStart.subscribe(e => floorEvents.push(e));
84
+
85
+ advance(2000);
86
+
87
+ expect(ballEvents.length).toBeGreaterThanOrEqual(1);
88
+ expect(floorEvents.length).toBeGreaterThanOrEqual(1);
89
+ const ballEvent = ballEvents[0];
90
+ const floorEvent = floorEvents[0];
91
+
92
+ expect(floorEvent.otherBody).toBe(ball);
93
+ expect(ballEvent.otherBody).toBe(floor);
94
+
95
+ // floor's normal points away from the floor towards the ball above it (up); the ball's points
96
+ // away from the ball towards the floor below it (down) - opposite directions.
97
+ expect(floorEvent.normal.z).toBeGreaterThan(0.9);
98
+ expect(ballEvent.normal.z).toBeLessThan(-0.9);
99
+
100
+ // relativeVelocity is "otherBody's velocity relative to this body", read from each body's own
101
+ // `linvel()` *after* this step's solve already ran (see `Rapier3dWorldComponent.
102
+ // dispatchCollisionEvents`'s doc) - not the pre-response approach velocity the ball actually hit
103
+ // at, since Rapier's constraint solver has already resolved the contact (and, with the default
104
+ // Baumgarte-style bias, can leave a small outward/separating residual velocity behind) by the
105
+ // time `drainCollisionEvents` reports it as `started`. What must still hold regardless: the two
106
+ // reciprocal readings are exact negations of each other (floor is stationary throughout, so this
107
+ // is really just `-ball.linvel().z` vs `ball.linvel().z`).
108
+ expect(floorEvent.relativeVelocity.z).toBeCloseTo(-ballEvent.relativeVelocity.z, 5);
109
+
110
+ expect(floorEvent.impulse).toBeCloseTo(ballEvent.impulse, 5);
111
+ });
112
+
113
+ it('does not keep re-firing onCollisionStart for a body resting stably on another', () => {
114
+ const floor = createFloor();
115
+ const ball = createBall(3);
116
+ void floor;
117
+
118
+ let startCount = 0;
119
+ ball.onCollisionStart.subscribe(() => startCount++);
120
+
121
+ advance(1500); // fall and land
122
+ const countAfterLanding = startCount;
123
+ expect(countAfterLanding).toBeGreaterThanOrEqual(1);
124
+
125
+ advance(1500); // keep resting, several hundred more simulate() calls
126
+ expect(startCount).toBe(countAfterLanding);
127
+ });
128
+
129
+ it('fires onCollisionEnd when a body is knocked away and separates from what it was touching', () => {
130
+ const floor = createFloor();
131
+ const ball = createBall(3);
132
+
133
+ let endedWith: Rapier3dRigidBodyComponent | null | undefined;
134
+ let endCount = 0;
135
+ ball.onCollisionEnd.subscribe(other => {
136
+ endCount++;
137
+ endedWith = other;
138
+ });
139
+
140
+ advance(1500); // let it land and settle
141
+ expect(endCount).toBe(0);
142
+
143
+ // zero gravity so the teleported ball doesn't immediately fall back down and re-collide within
144
+ // this test's own observation window
145
+ world.gravity = Pnt3.O;
146
+ ball.position = { x: 0, y: 0, z: 20 };
147
+ advance(500);
148
+
149
+ expect(endCount).toBeGreaterThanOrEqual(1);
150
+ expect(endedWith).toBe(floor);
151
+ });
152
+
153
+ it("does not fire a rigid body's onCollisionStart for a trigger overlap (triggers stay sensor-only)", () => {
154
+ world.gravity = Pnt3.O;
155
+ const trigger = factory.createTrigger({ shape: 'BOX', dimensions: { x: 10, y: 10, z: 10 } });
156
+ trigger.addToWorld({ physicsWorld: world } as any);
157
+
158
+ const ball = createBall(0); // spawned already overlapping the trigger volume
159
+
160
+ let ballCollisionStarts = 0;
161
+ ball.onCollisionStart.subscribe(() => ballCollisionStarts++);
162
+ let triggerEnters = 0;
163
+ trigger.onEntityEntered.subscribe(() => triggerEnters++);
164
+
165
+ advance(500);
166
+ trigger.checkOverlaps();
167
+
168
+ expect(triggerEnters).toBeGreaterThanOrEqual(1);
169
+ expect(ballCollisionStarts).toBe(0);
170
+ });
171
+
172
+ it('completes onCollisionStart/onCollisionEnd on dispose, matching matter/ammo', () => {
173
+ const ball = createBall(0);
174
+
175
+ let startCompleted = false;
176
+ let endCompleted = false;
177
+ ball.onCollisionStart.subscribe({ complete: () => (startCompleted = true) });
178
+ ball.onCollisionEnd.subscribe({ complete: () => (endCompleted = true) });
179
+
180
+ ball.dispose();
181
+
182
+ expect(startCompleted).toBe(true);
183
+ expect(endCompleted).toBe(true);
184
+ });
185
+ });
@@ -2,7 +2,6 @@ import { Pnt3 } from '@gg-web-engine/core';
2
2
  import { Rapier3dFactory, Rapier3dWorldComponent } from '../../src';
3
3
 
4
4
  describe(`Rapier3dTriggerComponent`, () => {
5
-
6
5
  let world: Rapier3dWorldComponent;
7
6
  let factory: Rapier3dFactory;
8
7
  beforeEach(async () => {
@@ -39,20 +38,23 @@ describe(`Rapier3dTriggerComponent`, () => {
39
38
  it(`should detect object intersection`, async () => {
40
39
  const trigger = factory.createTrigger({ shape: 'BOX', dimensions: { x: 10, y: 10, z: 10 } });
41
40
  trigger.addToWorld({ physicsWorld: world } as any);
42
- const ball = factory.createRigidBody({
43
- shape: { shape: 'SPHERE', radius: 1 },
44
- body: { dynamic: true, mass: 1 },
45
- }, { position: { x: 0, y: 0, z: 12 } });
41
+ const ball = factory.createRigidBody(
42
+ {
43
+ shape: { shape: 'SPHERE', radius: 1 },
44
+ body: { dynamic: true, mass: 1 },
45
+ },
46
+ { position: { x: 0, y: 0, z: 12 } },
47
+ );
46
48
  ball.addToWorld({ physicsWorld: world } as any);
47
49
  ball.linearVelocity = { x: 0, y: 0, z: -10 };
48
50
  let enterRegistered = false;
49
51
  let exitRegistered = false;
50
- trigger.onEntityEntered.subscribe(((obj) => {
52
+ trigger.onEntityEntered.subscribe(obj => {
51
53
  enterRegistered = obj === ball;
52
- }));
53
- trigger.onEntityLeft.subscribe(((obj) => {
54
+ });
55
+ trigger.onEntityLeft.subscribe(obj => {
54
56
  exitRegistered = obj === ball;
55
- }));
57
+ });
56
58
  advance(500, () => trigger.checkOverlaps()); // trigger entity performs that on tick
57
59
  expect(enterRegistered).toBe(false);
58
60
  expect(exitRegistered).toBe(false);
@@ -65,16 +67,19 @@ describe(`Rapier3dTriggerComponent`, () => {
65
67
  it(`should detect end of object intersection`, async () => {
66
68
  const trigger = factory.createTrigger({ shape: 'BOX', dimensions: { x: 10, y: 10, z: 10 } });
67
69
  trigger.addToWorld({ physicsWorld: world } as any);
68
- const ball = factory.createRigidBody({
69
- shape: { shape: 'SPHERE', radius: 1 },
70
- body: { dynamic: true, mass: 1 },
71
- }, { position: { x: 0, y: 0, z: 12 } });
70
+ const ball = factory.createRigidBody(
71
+ {
72
+ shape: { shape: 'SPHERE', radius: 1 },
73
+ body: { dynamic: true, mass: 1 },
74
+ },
75
+ { position: { x: 0, y: 0, z: 12 } },
76
+ );
72
77
  ball.addToWorld({ physicsWorld: world } as any);
73
78
  ball.linearVelocity = { x: 0, y: 0, z: -10 };
74
79
  let exitRegistered = false;
75
- trigger.onEntityLeft.subscribe(((obj) => {
80
+ trigger.onEntityLeft.subscribe(obj => {
76
81
  exitRegistered = obj === ball;
77
- }));
82
+ });
78
83
  advance(1000, () => trigger.checkOverlaps());
79
84
  expect(exitRegistered).toBe(false);
80
85
  advance(1000, () => trigger.checkOverlaps());
@@ -84,15 +89,18 @@ describe(`Rapier3dTriggerComponent`, () => {
84
89
  it(`should fire object intersection if spawned inside`, async () => {
85
90
  const trigger = factory.createTrigger({ shape: 'BOX', dimensions: { x: 10, y: 10, z: 10 } });
86
91
  trigger.addToWorld({ physicsWorld: world } as any);
87
- const ball = factory.createRigidBody({
88
- shape: { shape: 'SPHERE', radius: 1 },
89
- body: { dynamic: true, mass: 1 },
90
- }, { position: { x: 0, y: 0, z: 0 } });
92
+ const ball = factory.createRigidBody(
93
+ {
94
+ shape: { shape: 'SPHERE', radius: 1 },
95
+ body: { dynamic: true, mass: 1 },
96
+ },
97
+ { position: { x: 0, y: 0, z: 0 } },
98
+ );
91
99
  ball.addToWorld({ physicsWorld: world } as any);
92
100
  let enterRegistered = false;
93
- trigger.onEntityEntered.subscribe(((obj) => {
101
+ trigger.onEntityEntered.subscribe(obj => {
94
102
  enterRegistered = obj === ball;
95
- }));
103
+ });
96
104
  world.simulate(1000);
97
105
  trigger.checkOverlaps();
98
106
  expect(enterRegistered).toBe(true);
@@ -101,15 +109,18 @@ describe(`Rapier3dTriggerComponent`, () => {
101
109
  it(`should fire end of object intersection if trigger removed`, async () => {
102
110
  const trigger = factory.createTrigger({ shape: 'BOX', dimensions: { x: 10, y: 10, z: 10 } });
103
111
  trigger.addToWorld({ physicsWorld: world } as any);
104
- const ball = factory.createRigidBody({
105
- shape: { shape: 'SPHERE', radius: 1 },
106
- body: { dynamic: true, mass: 1 },
107
- }, { position: { x: 0, y: 0, z: 0 } });
112
+ const ball = factory.createRigidBody(
113
+ {
114
+ shape: { shape: 'SPHERE', radius: 1 },
115
+ body: { dynamic: true, mass: 1 },
116
+ },
117
+ { position: { x: 0, y: 0, z: 0 } },
118
+ );
108
119
  ball.addToWorld({ physicsWorld: world } as any);
109
120
  let exitRegistered = false;
110
- trigger.onEntityLeft.subscribe(((obj) => {
121
+ trigger.onEntityLeft.subscribe(obj => {
111
122
  exitRegistered = obj === ball;
112
- }));
123
+ });
113
124
  world.simulate(1);
114
125
  trigger.checkOverlaps();
115
126
  trigger.removeFromWorld({ physicsWorld: world } as any);
@@ -119,15 +130,18 @@ describe(`Rapier3dTriggerComponent`, () => {
119
130
  it(`should fire end of object intersection if object removed`, async () => {
120
131
  const trigger = factory.createTrigger({ shape: 'BOX', dimensions: { x: 10, y: 10, z: 10 } });
121
132
  trigger.addToWorld({ physicsWorld: world } as any);
122
- const ball = factory.createRigidBody({
123
- shape: { shape: 'SPHERE', radius: 1 },
124
- body: { dynamic: true, mass: 1 },
125
- }, { position: { x: 0, y: 0, z: 0 } });
133
+ const ball = factory.createRigidBody(
134
+ {
135
+ shape: { shape: 'SPHERE', radius: 1 },
136
+ body: { dynamic: true, mass: 1 },
137
+ },
138
+ { position: { x: 0, y: 0, z: 0 } },
139
+ );
126
140
  ball.addToWorld({ physicsWorld: world } as any);
127
141
  let exitRegistered = false;
128
- trigger.onEntityLeft.subscribe(((obj) => {
142
+ trigger.onEntityLeft.subscribe(obj => {
129
143
  exitRegistered = obj === ball;
130
- }));
144
+ });
131
145
  world.simulate(1);
132
146
  trigger.checkOverlaps();
133
147
  ball.removeFromWorld({ physicsWorld: world } as any);
@@ -135,4 +149,25 @@ describe(`Rapier3dTriggerComponent`, () => {
135
149
  trigger.checkOverlaps();
136
150
  expect(exitRegistered).toBe(true);
137
151
  });
152
+
153
+ it('completes onEntityEntered/onEntityLeft (and the inherited collision subjects) on dispose', () => {
154
+ const trigger = factory.createTrigger({ shape: 'BOX', dimensions: { x: 10, y: 10, z: 10 } });
155
+ trigger.addToWorld({ physicsWorld: world } as any);
156
+
157
+ let enterCompleted = false;
158
+ let leftCompleted = false;
159
+ let startCompleted = false;
160
+ let endCompleted = false;
161
+ trigger.onEntityEntered.subscribe({ complete: () => (enterCompleted = true) });
162
+ trigger.onEntityLeft.subscribe({ complete: () => (leftCompleted = true) });
163
+ trigger.onCollisionStart.subscribe({ complete: () => (startCompleted = true) });
164
+ trigger.onCollisionEnd.subscribe({ complete: () => (endCompleted = true) });
165
+
166
+ trigger.dispose();
167
+
168
+ expect(enterCompleted).toBe(true);
169
+ expect(leftCompleted).toBe(true);
170
+ expect(startCompleted).toBe(true);
171
+ expect(endCompleted).toBe(true);
172
+ });
138
173
  });
@@ -87,6 +87,34 @@ describe('Rapier3dWorldComponent', () => {
87
87
  });
88
88
  });
89
89
 
90
+ describe('Sleeping bodies', () => {
91
+ it(
92
+ 'wakes a sleeping body when position/rotation/velocity is set programmatically ' +
93
+ '(regression: Rapier skips a sleeping body\'s island entirely during step(), so a ' +
94
+ 'transform/velocity write passing wakeUp=false was silently never simulated)',
95
+ () => {
96
+ const ball = world.factory.createRigidBody(
97
+ { shape: { shape: 'SPHERE', radius: 1 }, body: { dynamic: true, mass: 5 } },
98
+ { position: { x: 0, y: 0, z: 0 } },
99
+ );
100
+ ball.addToWorld({ physicsWorld: world } as any);
101
+
102
+ // let it fall asleep: zero gravity (see beforeEach) and zero velocity means nothing ever
103
+ // disturbs it, so Rapier's own sleep threshold/timer runs out on its own.
104
+ for (let i = 0; i < 200; i++) {
105
+ world.simulate(60);
106
+ }
107
+ expect((ball as any).nativeBody.isSleeping()).toBe(true);
108
+
109
+ ball.linearVelocity = { x: 1, y: 0, z: 0 };
110
+ for (let i = 0; i < 60; i++) {
111
+ world.simulate(60);
112
+ }
113
+ expect(ball.position.x).toBeGreaterThan(0.5);
114
+ },
115
+ );
116
+ });
117
+
90
118
  describe('Raycast', () => {
91
119
 
92
120
  it('should return no hit when ray does not intersect any object', () => {