@gg-web-engine/rapier3d 0.0.72 → 0.0.73

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,4 +1,4 @@
1
- import { CharacterController3dOptions, CollisionGroup, DebugBody3DSettings, Entity3d, ICharacterController3dComponent, Point3, Point4 } from '@gg-web-engine/core';
1
+ import { CharacterController3dOptions, CollisionGroup, DebugBody3DSettings, ICharacterController3dComponent, IEntity, 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
4
  import { Rapier3dRigidBodyComponent } from './rapier-3d-rigid-body.component';
@@ -22,18 +22,23 @@ import { Rapier3dGgWorld, Rapier3dPhysicsTypeDocRepo } from '../types';
22
22
  * specific to this component; a normal per-frame game loop that calls `physicsWorld.simulate()`
23
23
  * every tick already satisfies it after the first tick.
24
24
  *
25
- * Note: unlike `Rapier3dRigidBodyComponent`/`Rapier3dTriggerComponent`, this component's native body
26
- * handle is *not* registered in `Rapier3dWorldComponent.handleIdEntityMap` - `world.raycast()` cannot
27
- * currently resolve a hit against a character controller back to this component (it will simply be
28
- * absent from `RaycastResult.hitBody`). Wiring that up would require widening the reverse-map's and
29
- * `raycast()`'s return-type generics repo-wide for a corner case outside this interface's contract;
30
- * left as a documented limitation rather than done speculatively.
25
+ * Note: this component's native body handle *is* registered in
26
+ * `Rapier3dWorldComponent.handleIdEntityMap` (see `addToWorld`/`removeFromWorld` below), alongside
27
+ * ordinary `Rapier3dRigidBodyComponent`/`Rapier3dTriggerComponent` handles - this is what lets a
28
+ * `Trigger3dEntity`'s `onEntityEntered`/`onEntityLeft` fire for a player walking through it, not just
29
+ * for ordinary rigid bodies/vehicle chassis (`Rapier3dWorldComponent.dispatchCollisionEvents` resolves
30
+ * a sensor-overlap pair's components through this same map). `world.raycast()` deliberately still does
31
+ * *not* resolve a hit against a character controller back to this component (filtered out in
32
+ * `raycast()` itself) - widening that too would mean widening the public `raycast()` return-type
33
+ * generic repo-wide for a case outside `IPhysicsWorldComponent.raycast`'s own documented contract
34
+ * (`PTypeDoc['rigidBody'] | PTypeDoc['trigger']`), so it's left resolving to `hitBody: undefined`
35
+ * there, same as before.
31
36
  */
32
37
  export declare class Rapier3dCharacterControllerComponent implements ICharacterController3dComponent<Rapier3dPhysicsTypeDocRepo> {
33
38
  protected readonly world: Rapier3dWorldComponent;
34
39
  protected readonly options: Required<CharacterController3dOptions>;
35
40
  protected _bodyDescr: RigidBodyDesc;
36
- entity: Entity3d | null;
41
+ entity: IEntity | null;
37
42
  name: string;
38
43
  readonly radius: number;
39
44
  readonly centersDistance: number;
@@ -1,5 +1,5 @@
1
1
  import { BitMask, DebugBody3DSettings, Pnt3, Qtrn, warnOnce, } from '@gg-web-engine/core';
2
- import { Quaternion, RigidBodyDesc, Vector3, } from '@dimforge/rapier3d-compat';
2
+ import { ActiveCollisionTypes, ActiveEvents, QueryFilterFlags, Quaternion, RigidBodyDesc, Vector3, } from '@dimforge/rapier3d-compat';
3
3
  /**
4
4
  * A capsule-shaped kinematic character controller backed by Rapier's own `KinematicCharacterController`
5
5
  * (`world.createCharacterController`). `move()` is made fully synchronous (see
@@ -19,12 +19,17 @@ import { Quaternion, RigidBodyDesc, Vector3, } from '@dimforge/rapier3d-compat';
19
19
  * specific to this component; a normal per-frame game loop that calls `physicsWorld.simulate()`
20
20
  * every tick already satisfies it after the first tick.
21
21
  *
22
- * Note: unlike `Rapier3dRigidBodyComponent`/`Rapier3dTriggerComponent`, this component's native body
23
- * handle is *not* registered in `Rapier3dWorldComponent.handleIdEntityMap` - `world.raycast()` cannot
24
- * currently resolve a hit against a character controller back to this component (it will simply be
25
- * absent from `RaycastResult.hitBody`). Wiring that up would require widening the reverse-map's and
26
- * `raycast()`'s return-type generics repo-wide for a corner case outside this interface's contract;
27
- * left as a documented limitation rather than done speculatively.
22
+ * Note: this component's native body handle *is* registered in
23
+ * `Rapier3dWorldComponent.handleIdEntityMap` (see `addToWorld`/`removeFromWorld` below), alongside
24
+ * ordinary `Rapier3dRigidBodyComponent`/`Rapier3dTriggerComponent` handles - this is what lets a
25
+ * `Trigger3dEntity`'s `onEntityEntered`/`onEntityLeft` fire for a player walking through it, not just
26
+ * for ordinary rigid bodies/vehicle chassis (`Rapier3dWorldComponent.dispatchCollisionEvents` resolves
27
+ * a sensor-overlap pair's components through this same map). `world.raycast()` deliberately still does
28
+ * *not* resolve a hit against a character controller back to this component (filtered out in
29
+ * `raycast()` itself) - widening that too would mean widening the public `raycast()` return-type
30
+ * generic repo-wide for a case outside `IPhysicsWorldComponent.raycast`'s own documented contract
31
+ * (`PTypeDoc['rigidBody'] | PTypeDoc['trigger']`), so it's left resolving to `hitBody: undefined`
32
+ * there, same as before.
28
33
  */
29
34
  export class Rapier3dCharacterControllerComponent {
30
35
  get up() {
@@ -211,7 +216,18 @@ export class Rapier3dCharacterControllerComponent {
211
216
  }
212
217
  }
213
218
  const desired = new Vector3(desiredTranslation.x, desiredTranslation.y, desiredTranslation.z);
214
- this._nativeController.computeColliderMovement(this._nativeCollider, desired, undefined, undefined, this.ignoredBodiesFilterPredicate());
219
+ this._nativeController.computeColliderMovement(this._nativeCollider, desired,
220
+ // `computeColliderMovement`'s own default (no `filterFlags`) treats a sensor collider as a
221
+ // solid obstacle, exactly like any real one - confirmed empirically (a character walking
222
+ // straight at a `Trigger`'s volume physically stopped dead at its boundary instead of walking
223
+ // through it, well before Rapier's own `KINEMATIC_FIXED` intersection event ever had a chance
224
+ // to fire - see `Rapier3dWorldComponent.handleIdEntityMap`'s doc for that separate, now also
225
+ // fixed, half of this). `EXCLUDE_SENSORS` is required so a character can walk into/through a
226
+ // trigger's volume at all, matching what "trigger" means everywhere else in this engine (a
227
+ // sensor with no collision response, see `ITrigger3dComponent`) and what `AmmoCharacterControllerComponent`'s
228
+ // own sweep-based mover already does (Ammo's `CF_NO_CONTACT_RESPONSE` ghost trigger was never a
229
+ // solid obstacle to begin with).
230
+ QueryFilterFlags.EXCLUDE_SENSORS, undefined, this.ignoredBodiesFilterPredicate());
215
231
  const computed = this._nativeController.computedMovement();
216
232
  const current = this._nativeBody.translation();
217
233
  const next = new Vector3(current.x + computed.x, current.y + computed.y, current.z + computed.z);
@@ -352,6 +368,11 @@ export class Rapier3dCharacterControllerComponent {
352
368
  bd.setTranslation(pos.x, pos.y, pos.z);
353
369
  bd.setRotation(new Quaternion(rot.x, rot.y, rot.z, rot.w));
354
370
  const comp = new Rapier3dCharacterControllerComponent(this.world, this.options, bd);
371
+ // `this.options.up` is only ever read once, in the constructor - the live `up` setter (used by
372
+ // any caller that rotates the character after construction) never writes back to it, so it goes
373
+ // stale the moment `up` changes; copy the CURRENT value here instead, the same way
374
+ // `collisionGroups` already does below.
375
+ comp.up = this.up;
355
376
  comp.collisionGroups = this.collisionGroups;
356
377
  return comp;
357
378
  }
@@ -366,6 +387,24 @@ export class Rapier3dCharacterControllerComponent {
366
387
  radius: this.radius,
367
388
  centersDistance: this.centersDistance,
368
389
  })[0];
390
+ // Both needed for `Rapier3dWorldComponent.dispatchCollisionEvents` to ever see a pair involving
391
+ // this character controller (sensor overlap *or* real contact):
392
+ // - `ActiveCollisionTypes` gates which pairs even reach narrow-phase at all, gated by the two
393
+ // bodies' *rigid-body* types - Rapier's own default (`ActiveCollisionTypes.DEFAULT`) is only
394
+ // `DYNAMIC_DYNAMIC | DYNAMIC_FIXED | DYNAMIC_KINEMATIC`; this `kinematicPositionBased` body
395
+ // paired against a `static` trigger is `KINEMATIC_FIXED`, which `DEFAULT` excludes entirely -
396
+ // confirmed empirically (a character parked motionless inside a trigger's volume for a full
397
+ // second of simulated time never fired `onEntityEntered` until this was set). `.ALL` covers
398
+ // every other rigid-body-type combination this character could ever meet too (another
399
+ // kinematic character, a `Trigger` that's itself kinematic, etc.), not just this one pairing.
400
+ // - `ActiveEvents.COLLISION_EVENTS` is the separate, per-*collider*, "actually emit an event for
401
+ // an allowed pair" flag - a trigger's own sensor collider already sets it (see
402
+ // `Rapier3dFactory.createTrigger`) and one side being enough is normally sufficient (see
403
+ // `Rapier3dFactory.createRigidBody`'s doc), but this collider sets it too regardless, so a real
404
+ // (non-sensor) contact against another kinematic body - which would have neither side pre-set -
405
+ // still produces events consistently.
406
+ colliderDescr.setActiveCollisionTypes(ActiveCollisionTypes.ALL);
407
+ colliderDescr.setActiveEvents(ActiveEvents.COLLISION_EVENTS);
369
408
  this._nativeCollider = nativeWorld.createCollider(colliderDescr, this._nativeBody);
370
409
  this._nativeCollider.setCollisionGroups(this.collisionGroups);
371
410
  this._nativeController = nativeWorld.createCharacterController(this.options.offset);
@@ -377,6 +416,7 @@ export class Rapier3dCharacterControllerComponent {
377
416
  if (this.options.snapToGroundDistance > 0) {
378
417
  this._nativeController.enableSnapToGround(this.options.snapToGroundDistance);
379
418
  }
419
+ this.world.handleIdEntityMap.set(this._nativeBody.handle, this);
380
420
  // Rapier's own `KinematicCharacterController` has a built-in equivalent of `pushMass`
381
421
  // (`setApplyImpulsesToDynamicBodies(true)` + `setCharacterMass(...)`) that looked like the
382
422
  // obvious way to implement pushing here - no hand-rolled logic needed, unlike
@@ -405,6 +445,7 @@ export class Rapier3dCharacterControllerComponent {
405
445
  this._nativeController = null;
406
446
  }
407
447
  if (this._nativeBody) {
448
+ this.world.handleIdEntityMap.delete(this._nativeBody.handle);
408
449
  if (this._nativeCollider) {
409
450
  this.world.nativeWorld.removeCollider(this._nativeCollider, false);
410
451
  this._nativeCollider = null;
@@ -3,26 +3,71 @@ import { DynamicRayCastVehicleController } from '@dimforge/rapier3d-compat';
3
3
  import { Rapier3dRigidBodyComponent } from './rapier-3d-rigid-body.component';
4
4
  import { Rapier3dWorldComponent } from './rapier-3d-world.component';
5
5
  import { Rapier3dGgWorld, Rapier3dPhysicsTypeDocRepo } from '../types';
6
+ /**
7
+ * Rapier's `DynamicRayCastVehicleController` (`world.createVehicleController`) is a thin wrapper
8
+ * around wheel raycasting only - nothing steps it automatically as part of `World.step()`.
9
+ * Instead, `updateVehicle(dt, ...)` must be called once per tick *before* `world.step()` - it directly writes the chassis's own
10
+ * `linvel`/`angvel` from that tick's suspension/engine/brake forces, which `world.step()` then
11
+ * integrates like any other dynamic body's velocity. This component registers itself into
12
+ * `Rapier3dWorldComponent.raycastVehicles` on `addToWorld`/`removeFromWorld` so the world component
13
+ * can drive that call centrally from `simulate()` - see that class's doc.
14
+ *
15
+ * Like `Rapier3dCharacterControllerComponent`/`Rapier3dTriggerComponent`, this class and
16
+ * `Rapier3dWorldComponent` import each other (the world needs this class purely as a type for its
17
+ * `raycastVehicles` set, the vehicle needs the world's concrete type for its constructor/`addToWorld`
18
+ * parameter) - this circular import is an established, safe pattern in this package (see those two
19
+ * classes), not specific to this one.
20
+ */
6
21
  export declare class Rapier3dRaycastVehicleComponent extends Rapier3dRigidBodyComponent implements IRaycastVehicleComponent<Rapier3dPhysicsTypeDocRepo> {
7
22
  protected readonly world: Rapier3dWorldComponent;
8
23
  private chassisBody;
9
24
  protected _nativeVehicle: DynamicRayCastVehicleController | null;
10
25
  get nativeVehicle(): DynamicRayCastVehicleController | null;
11
- private wheelDescr;
26
+ private readonly wheels;
12
27
  constructor(world: Rapier3dWorldComponent, chassisBody: Rapier3dRigidBodyComponent);
13
28
  get wheelSpeed(): number;
14
29
  addToWorld(world: Rapier3dGgWorld): void;
15
30
  removeFromWorld(world: Rapier3dGgWorld, dispose?: boolean): void;
31
+ /**
32
+ * Called once per `simulate()` tick by `Rapier3dWorldComponent`, *before* `World.step()` - see
33
+ * this class's own doc for why. `filterGroups` threads this vehicle's own collision groups
34
+ * (`this.collisionGroups`, inherited from `Rapier3dRigidBodyComponent` and already packed in the
35
+ * `InteractionGroups` layout Rapier expects) into the wheels' own suspension raycasts, so a
36
+ * vehicle in one collision group doesn't get held up by suspension force from a floor it isn't
37
+ * meant to interact with - without this, only the chassis's own broadphase collision would
38
+ * respect collision groups, not the ray-cast-based wheel/ground detection (see
39
+ * `gg-engine-physics-adapter`'s testing guidance on this). `EXCLUDE_SENSORS` keeps a `Trigger`'s
40
+ * sensor volume from ever acting as solid ground for a wheel, mirroring
41
+ * `Rapier3dCharacterControllerComponent.move()`'s identical guard.
42
+ */
43
+ stepVehicleController(dt: number): void;
44
+ private attachWheel;
16
45
  addWheel(options: WheelOptions, suspensionOptions: SuspensionOptions): void;
17
46
  setSteering(wheelIndex: number, steering: number): void;
18
47
  applyEngineForce(wheelIndex: number, force: number): void;
19
48
  applyBrake(wheelIndex: number, force: number): void;
20
49
  isWheelTouchesGround(wheelIndex: number): boolean;
50
+ /**
51
+ * Rapier's controller exposes no single call that bakes suspension travel, steering and roll into
52
+ * one transform for rendering - only the individual pieces
53
+ * (`wheelHardPoint`/`wheelSuspensionLength`/`wheelDirectionCs`/`wheelAxleCs`/`wheelSteering`/
54
+ * `wheelRotation`), which this method composes by hand:
55
+ * - **Position**: `wheelHardPoint` is already world-space (the ray-cast's own start point, fixed
56
+ * relative to the chassis) - moving it `wheelSuspensionLength` further along the *world-space*
57
+ * suspension direction (`wheelDirectionCs` rotated by the chassis's current rotation) lands
58
+ * exactly on the wheel's current (compressed-by-however-much) center, airborne or grounded alike.
59
+ * - **Rotation**: composed as chassis rotation ∘ steering (about the chassis's local up axis,
60
+ * `Pnt3.Z` - only ever nonzero for wheels `RaycastVehicle3dEntity` actually steers) ∘ roll (about
61
+ * this wheel's own configured local axle, `wheelRotation`'s accumulated spin angle). This is a
62
+ * best-effort reconstruction, not something read back verbatim from the native engine - document
63
+ * as a known limitation rather than chasing exactness, same spirit as
64
+ * `Rapier3dCharacterControllerComponent`'s ground-normal approximation.
65
+ */
21
66
  getWheelTransform(wheelIndex: number): {
22
67
  position: Point3;
23
68
  rotation: Point4;
24
69
  };
25
70
  resetSuspension(): void;
26
71
  clone(): Rapier3dRaycastVehicleComponent;
27
- dispose(): void;
72
+ resetMotion(): void;
28
73
  }
@@ -1,6 +1,21 @@
1
1
  import { Pnt3, Qtrn, } from '@gg-web-engine/core';
2
- import { Vector3 } from '@dimforge/rapier3d-compat';
2
+ import { QueryFilterFlags } from '@dimforge/rapier3d-compat';
3
3
  import { Rapier3dRigidBodyComponent } from './rapier-3d-rigid-body.component';
4
+ /**
5
+ * Rapier's `DynamicRayCastVehicleController` (`world.createVehicleController`) is a thin wrapper
6
+ * around wheel raycasting only - nothing steps it automatically as part of `World.step()`.
7
+ * Instead, `updateVehicle(dt, ...)` must be called once per tick *before* `world.step()` - it directly writes the chassis's own
8
+ * `linvel`/`angvel` from that tick's suspension/engine/brake forces, which `world.step()` then
9
+ * integrates like any other dynamic body's velocity. This component registers itself into
10
+ * `Rapier3dWorldComponent.raycastVehicles` on `addToWorld`/`removeFromWorld` so the world component
11
+ * can drive that call centrally from `simulate()` - see that class's doc.
12
+ *
13
+ * Like `Rapier3dCharacterControllerComponent`/`Rapier3dTriggerComponent`, this class and
14
+ * `Rapier3dWorldComponent` import each other (the world needs this class purely as a type for its
15
+ * `raycastVehicles` set, the vehicle needs the world's concrete type for its constructor/`addToWorld`
16
+ * parameter) - this circular import is an established, safe pattern in this package (see those two
17
+ * classes), not specific to this one.
18
+ */
4
19
  export class Rapier3dRaycastVehicleComponent extends Rapier3dRigidBodyComponent {
5
20
  get nativeVehicle() {
6
21
  return this._nativeVehicle;
@@ -10,86 +25,179 @@ export class Rapier3dRaycastVehicleComponent extends Rapier3dRigidBodyComponent
10
25
  this.world = world;
11
26
  this.chassisBody = chassisBody;
12
27
  this._nativeVehicle = null;
13
- this.wheelDescr = [];
28
+ this.wheels = [];
14
29
  }
15
30
  get wheelSpeed() {
16
31
  var _a;
17
- return (((_a = this.nativeVehicle) === null || _a === void 0 ? void 0 : _a.currentVehicleSpeed()) || 0) / 3.6;
32
+ return ((_a = this.nativeVehicle) === null || _a === void 0 ? void 0 : _a.currentVehicleSpeed()) || 0;
18
33
  }
19
34
  addToWorld(world) {
20
35
  super.addToWorld(world);
21
- this._nativeVehicle = world.physicsWorld.nativeWorld.createVehicleController(this._nativeBody);
36
+ const nativeWorld = this.world.nativeWorld;
37
+ this._nativeVehicle = nativeWorld.createVehicleController(this._nativeBody);
38
+ // matches this engine's own Z-up/Y-forward convention (see `RaycastVehicle3dEntity`'s own doc:
39
+ // "car mesh and physics body direction has to be pointing: y front, z up") - axis indices are
40
+ // 0 = x, 1 = y, 2 = z.
22
41
  this._nativeVehicle.indexUpAxis = 2;
42
+ // this pinned `@dimforge/rapier3d-compat` build's own setter is genuinely named
43
+ // `setIndexForwardAxis` (a setter *property*, not a typo introduced by this adapter) - see
44
+ // `ray_cast_vehicle_controller.d.ts`'s `set setIndexForwardAxis(axis: number)`.
23
45
  this._nativeVehicle.setIndexForwardAxis = 1;
24
- for (const descr of this.wheelDescr) {
25
- this._nativeVehicle.addWheel(...descr);
46
+ for (const wheel of this.wheels) {
47
+ this.attachWheel(this._nativeVehicle, wheel);
26
48
  }
27
- setInterval(() => this._nativeVehicle.updateVehicle(0.01), 10);
49
+ this.world.raycastVehicles.add(this);
28
50
  }
29
51
  removeFromWorld(world, dispose) {
30
52
  if (world.physicsWorld != this.world) {
31
53
  throw new Error('Rapier3D bodies cannot be shared between different worlds');
32
54
  }
33
- if (this.nativeVehicle) {
34
- world.physicsWorld.nativeWorld.removeVehicleController(this.nativeVehicle);
55
+ this.world.raycastVehicles.delete(this);
56
+ if (this._nativeVehicle) {
57
+ this.world.nativeWorld.removeVehicleController(this._nativeVehicle);
58
+ // `removeVehicleController` only unregisters it from the world's own bookkeeping - the JS
59
+ // wrapper's own native handle still needs an explicit `.free()`, which nothing else ever
60
+ // reaches (see this class's own doc and `gg-engine-physics-adapter-rapier`). Always freed here
61
+ // (not gated behind `dispose`, mirroring `Rapier3dRigidBodyComponent.removeFromWorld`'s own
62
+ // unconditional native cleanup - a vehicle controller isn't reconstructible from a stored
63
+ // descriptor the way a rigid body is, so keeping a stale, removed-but-unfreed reference around
64
+ // for a possible future re-`addToWorld` would only leak it, never usefully resurrect it).
65
+ this._nativeVehicle.free();
66
+ this._nativeVehicle = null;
35
67
  }
36
68
  super.removeFromWorld(world, dispose);
37
- if (dispose) {
38
- this.dispose();
69
+ }
70
+ /**
71
+ * Called once per `simulate()` tick by `Rapier3dWorldComponent`, *before* `World.step()` - see
72
+ * this class's own doc for why. `filterGroups` threads this vehicle's own collision groups
73
+ * (`this.collisionGroups`, inherited from `Rapier3dRigidBodyComponent` and already packed in the
74
+ * `InteractionGroups` layout Rapier expects) into the wheels' own suspension raycasts, so a
75
+ * vehicle in one collision group doesn't get held up by suspension force from a floor it isn't
76
+ * meant to interact with - without this, only the chassis's own broadphase collision would
77
+ * respect collision groups, not the ray-cast-based wheel/ground detection (see
78
+ * `gg-engine-physics-adapter`'s testing guidance on this). `EXCLUDE_SENSORS` keeps a `Trigger`'s
79
+ * sensor volume from ever acting as solid ground for a wheel, mirroring
80
+ * `Rapier3dCharacterControllerComponent.move()`'s identical guard.
81
+ */
82
+ stepVehicleController(dt) {
83
+ if (!this._nativeVehicle) {
84
+ return;
39
85
  }
86
+ this._nativeVehicle.updateVehicle(dt, QueryFilterFlags.EXCLUDE_SENSORS, this.collisionGroups);
87
+ }
88
+ attachWheel(nativeVehicle, wheel) {
89
+ nativeVehicle.addWheel(wheel.connectionPointCs, wheel.directionCs, wheel.axleCs, wheel.restLength, wheel.radius);
90
+ const i = nativeVehicle.numWheels() - 1;
91
+ nativeVehicle.setWheelSuspensionStiffness(i, wheel.suspension.stiffness);
92
+ nativeVehicle.setWheelSuspensionRelaxation(i, wheel.suspension.damping);
93
+ nativeVehicle.setWheelSuspensionCompression(i, wheel.suspension.compression);
94
+ nativeVehicle.setWheelMaxSuspensionTravel(i, wheel.options.maxTravel);
95
+ nativeVehicle.setWheelFrictionSlip(i, wheel.options.frictionSlip);
96
+ // Rapier has no single field named "roll influence" - `WheelOptions.rollInfluence` (how much a
97
+ // wheel resists roll-inducing side force, per `IRaycastVehicleComponent`'s doc) is approximated
98
+ // with the closest native equivalent, side-friction stiffness. Best-effort, same spirit as
99
+ // `Rapier3dCharacterControllerComponent`'s documented ground-normal approximation - not
100
+ // guaranteed to produce an identical feel for a given numeric value across physics engines.
101
+ nativeVehicle.setWheelSideFrictionStiffness(i, wheel.options.rollInfluence);
40
102
  }
41
103
  addWheel(options, suspensionOptions) {
42
- const descr = [
43
- new Vector3(options.position.x, options.position.y, options.position.z),
44
- Pnt3.nZ,
45
- options.isLeft ? Pnt3.X : Pnt3.nX,
46
- suspensionOptions.restLength,
47
- options.tyreRadius,
48
- ];
49
- this.wheelDescr.push(descr);
50
- if (this.nativeVehicle) {
51
- this.nativeVehicle.addWheel(...descr);
104
+ const wheel = {
105
+ connectionPointCs: Pnt3.clone(options.position),
106
+ directionCs: Pnt3.nZ,
107
+ // One constant axle for every wheel, left or right. Confirmed empirically that flipping this
108
+ // per side (an earlier version of this method used `options.isLeft ? Pnt3.X : Pnt3.nX`, on the
109
+ // theory that it would make `getWheelTransform`'s roll rotation spin each side's mesh the
110
+ // visually correct way) breaks *driving* outright: Rapier's engine-force/friction model treats
111
+ // `axleCs` as the wheel's forward-tire-direction reference, so a flipped axle on one side
112
+ // applies that side's engine force in the opposite world direction from the other side - equal
113
+ // and opposite forward forces exactly cancel, and the chassis never moves (a real regression
114
+ // test - drive under engine force and assert net displacement - is what caught this; a
115
+ // settle-only test cannot, since it never applies engine force at all). Left/right visual
116
+ // mirroring of the wheel mesh is already handled adapter-agnostically at the entity level
117
+ // (`RaycastVehicle3dEntity`'s own `wheelLocalRotation`, derived from `WheelOptions.isLeft`), so
118
+ // `getWheelTransform` below doesn't need to (and must not) compensate for it again itself.
119
+ axleCs: Pnt3.X,
120
+ restLength: suspensionOptions.restLength,
121
+ radius: options.tyreRadius,
122
+ options,
123
+ suspension: suspensionOptions,
124
+ };
125
+ this.wheels.push(wheel);
126
+ if (this._nativeVehicle) {
127
+ this.attachWheel(this._nativeVehicle, wheel);
52
128
  }
53
129
  }
54
130
  setSteering(wheelIndex, steering) {
55
- if (!this.nativeVehicle)
56
- return;
57
- this.nativeVehicle.setWheelSteering(wheelIndex, steering);
131
+ var _a;
132
+ (_a = this.nativeVehicle) === null || _a === void 0 ? void 0 : _a.setWheelSteering(wheelIndex, steering);
58
133
  }
59
134
  applyEngineForce(wheelIndex, force) {
60
- if (!this.nativeVehicle)
61
- return;
62
- this.nativeVehicle.setWheelEngineForce(wheelIndex, force);
135
+ var _a;
136
+ (_a = this.nativeVehicle) === null || _a === void 0 ? void 0 : _a.setWheelEngineForce(wheelIndex, force);
63
137
  }
64
138
  applyBrake(wheelIndex, force) {
65
- if (!this.nativeVehicle)
66
- return;
67
- this.nativeVehicle.setWheelBrake(wheelIndex, force);
139
+ var _a;
140
+ (_a = this.nativeVehicle) === null || _a === void 0 ? void 0 : _a.setWheelBrake(wheelIndex, force);
68
141
  }
69
142
  isWheelTouchesGround(wheelIndex) {
70
- if (!this.nativeVehicle)
71
- return false;
72
- // TODO
73
- return true; //this.nativeVehicle.wheelIsInContact(wheelIndex);
143
+ var _a, _b;
144
+ return (_b = (_a = this.nativeVehicle) === null || _a === void 0 ? void 0 : _a.wheelIsInContact(wheelIndex)) !== null && _b !== void 0 ? _b : false;
74
145
  }
146
+ /**
147
+ * Rapier's controller exposes no single call that bakes suspension travel, steering and roll into
148
+ * one transform for rendering - only the individual pieces
149
+ * (`wheelHardPoint`/`wheelSuspensionLength`/`wheelDirectionCs`/`wheelAxleCs`/`wheelSteering`/
150
+ * `wheelRotation`), which this method composes by hand:
151
+ * - **Position**: `wheelHardPoint` is already world-space (the ray-cast's own start point, fixed
152
+ * relative to the chassis) - moving it `wheelSuspensionLength` further along the *world-space*
153
+ * suspension direction (`wheelDirectionCs` rotated by the chassis's current rotation) lands
154
+ * exactly on the wheel's current (compressed-by-however-much) center, airborne or grounded alike.
155
+ * - **Rotation**: composed as chassis rotation ∘ steering (about the chassis's local up axis,
156
+ * `Pnt3.Z` - only ever nonzero for wheels `RaycastVehicle3dEntity` actually steers) ∘ roll (about
157
+ * this wheel's own configured local axle, `wheelRotation`'s accumulated spin angle). This is a
158
+ * best-effort reconstruction, not something read back verbatim from the native engine - document
159
+ * as a known limitation rather than chasing exactness, same spirit as
160
+ * `Rapier3dCharacterControllerComponent`'s ground-normal approximation.
161
+ */
75
162
  getWheelTransform(wheelIndex) {
76
- if (!this.nativeVehicle)
163
+ var _a, _b, _c;
164
+ if (!this.nativeVehicle) {
77
165
  return { position: Pnt3.O, rotation: Qtrn.O };
166
+ }
78
167
  const chassisRotation = this.rotation;
79
- return {
80
- position: Pnt3.add(this.position, Pnt3.rot(this.nativeVehicle.wheelChassisConnectionPointCs(wheelIndex), chassisRotation)),
81
- rotation: chassisRotation,
82
- };
168
+ const hardPoint = this.nativeVehicle.wheelHardPoint(wheelIndex);
169
+ const directionCs = this.nativeVehicle.wheelDirectionCs(wheelIndex);
170
+ const suspensionLength = (_a = this.nativeVehicle.wheelSuspensionLength(wheelIndex)) !== null && _a !== void 0 ? _a : 0;
171
+ let position = Pnt3.O;
172
+ if (hardPoint && directionCs) {
173
+ const worldDirection = Pnt3.rot(Pnt3.clone(directionCs), chassisRotation);
174
+ position = Pnt3.add(Pnt3.clone(hardPoint), Pnt3.scalarMult(worldDirection, suspensionLength));
175
+ }
176
+ const axleCs = this.nativeVehicle.wheelAxleCs(wheelIndex);
177
+ const rollAngle = (_b = this.nativeVehicle.wheelRotation(wheelIndex)) !== null && _b !== void 0 ? _b : 0;
178
+ const steerAngle = (_c = this.nativeVehicle.wheelSteering(wheelIndex)) !== null && _c !== void 0 ? _c : 0;
179
+ const rollRotation = axleCs ? Qtrn.rotAround(Qtrn.O, Pnt3.clone(axleCs), rollAngle) : Qtrn.O;
180
+ const steerRotation = Qtrn.rotAround(Qtrn.O, Pnt3.Z, steerAngle);
181
+ const rotation = Qtrn.combineRotations(chassisRotation, steerRotation, rollRotation);
182
+ return { position, rotation };
83
183
  }
84
184
  resetSuspension() {
85
- // TODO
185
+ // No native equivalent: Rapier doesn't expose a settable "current suspension length" (only the
186
+ // rest length/travel bounds that shape it). Not load-bearing, either - the very next
187
+ // `stepVehicleController` tick re-derives every wheel's suspension length from a fresh ray-cast
188
+ // against the vehicle's (by then already reset) position, so a teleport/respawn recovers on its
189
+ // own within one tick without this. Kept as a documented no-op purely for interface conformance,
190
+ // same spirit as other best-effort gaps in this file.
86
191
  }
87
192
  clone() {
88
- return new Rapier3dRaycastVehicleComponent(this.world, this.chassisBody.clone());
193
+ const comp = new Rapier3dRaycastVehicleComponent(this.world, this.chassisBody.clone());
194
+ for (const wheel of this.wheels) {
195
+ comp.addWheel(wheel.options, wheel.suspension);
196
+ }
197
+ return comp;
89
198
  }
90
- dispose() {
91
- var _a;
92
- (_a = this.nativeVehicle) === null || _a === void 0 ? void 0 : _a.free();
93
- super.dispose();
199
+ resetMotion() {
200
+ this.resetSuspension();
201
+ super.resetMotion();
94
202
  }
95
203
  }
@@ -1,4 +1,4 @@
1
- import { Body3DOptions, CollisionEvent, CollisionGroup, DebugBody3DSettings, Entity3d, IRigidBody3dComponent, Point3, Point4, Shape3DDescriptor } from '@gg-web-engine/core';
1
+ import { Body3DOptions, BodyOptions, 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
3
  import { Observable, Subject } from 'rxjs';
4
4
  import { Rapier3dWorldComponent } from './rapier-3d-world.component';
@@ -19,6 +19,25 @@ export declare class Rapier3dRigidBodyComponent implements IRigidBody3dComponent
19
19
  get angularVelocity(): Point3;
20
20
  set angularVelocity(value: Point3);
21
21
  readonly debugBodySettings: DebugBody3DSettings;
22
+ /**
23
+ * See `IRigidBodyComponent.bodyOptions`'s own doc. Reads straight off `_bodyDescr`/
24
+ * `_colliderDescr`/`_colliderOptions` (also what `addToWorld` itself builds the native body/
25
+ * colliders from, and what `factoryProps`/`clone()` already round-trip) rather than the native
26
+ * body/colliders - this engine's own API never mutates any of `bodyType`/`mass`/`friction`/
27
+ * `restitution`/`ccd` after construction, so the stored descriptor is exactly as accurate as a
28
+ * native query would be.
29
+ *
30
+ * `mass` is **not** `_bodyDescr.mass` - unlike `packages/rapier2d`, `Rapier3dFactory.createRigidBodyDescr`
31
+ * deliberately sets mass on each collider (`ColliderDesc.setMass`), not on the body descriptor
32
+ * (see that method's own doc for why: a `RigidBodyDesc.mass` is "additional" point mass with no
33
+ * rotational inertia of its own, wrong for a body whose inertia should scale with its actual
34
+ * mass). `_bodyDescr.mass` is therefore always its unused default (`0`) regardless of what was
35
+ * actually requested - the real total is the sum of every collider's own `mass`, which
36
+ * `ColliderDesc` only carries meaningfully once `setMass`/`setMassProperties` was actually called
37
+ * on it (true for every collider of a `dynamic` body, per `createRigidBodyDescr`; a `static`/
38
+ * `kinematic_*` body never calls either, so this correctly sums to `0` for one of those instead).
39
+ */
40
+ get bodyOptions(): Readonly<BodyOptions>;
22
41
  protected _nativeBody: RigidBody | null;
23
42
  protected _nativeBodyColliders: Collider[] | null;
24
43
  get nativeBody(): RigidBody | null;
@@ -1,6 +1,20 @@
1
1
  import { BitMask, DebugBody3DSettings, Pnt3, Qtrn, } from '@gg-web-engine/core';
2
- import { ColliderDesc, Quaternion, RigidBodyDesc, RigidBodyType, Vector3, } from '@dimforge/rapier3d-compat';
2
+ import { ColliderDesc, MassPropsMode, Quaternion, RigidBodyDesc, RigidBodyType, Vector3, } from '@dimforge/rapier3d-compat';
3
3
  import { Subject } from 'rxjs';
4
+ /** Inverse of `Rapier3dFactory.createRigidBodyDescr`'s own `BodyType -> RigidBodyType` mapping -
5
+ * backs `Rapier3dRigidBodyComponent.bodyOptions`. */
6
+ function rapierBodyTypeToBodyType(status) {
7
+ switch (status) {
8
+ case RigidBodyType.Fixed:
9
+ return 'static';
10
+ case RigidBodyType.KinematicPositionBased:
11
+ return 'kinematic_pos';
12
+ case RigidBodyType.KinematicVelocityBased:
13
+ return 'kinematic_vel';
14
+ default:
15
+ return 'dynamic';
16
+ }
17
+ }
4
18
  export class Rapier3dRigidBodyComponent {
5
19
  get position() {
6
20
  return Pnt3.clone(this.nativeBody ? this.nativeBody.translation() : this._bodyDescr.translation);
@@ -62,6 +76,35 @@ export class Rapier3dRigidBodyComponent {
62
76
  this.nativeBody.setAngvel(new Vector3(value.x, value.y, value.z), true);
63
77
  }
64
78
  }
79
+ /**
80
+ * See `IRigidBodyComponent.bodyOptions`'s own doc. Reads straight off `_bodyDescr`/
81
+ * `_colliderDescr`/`_colliderOptions` (also what `addToWorld` itself builds the native body/
82
+ * colliders from, and what `factoryProps`/`clone()` already round-trip) rather than the native
83
+ * body/colliders - this engine's own API never mutates any of `bodyType`/`mass`/`friction`/
84
+ * `restitution`/`ccd` after construction, so the stored descriptor is exactly as accurate as a
85
+ * native query would be.
86
+ *
87
+ * `mass` is **not** `_bodyDescr.mass` - unlike `packages/rapier2d`, `Rapier3dFactory.createRigidBodyDescr`
88
+ * deliberately sets mass on each collider (`ColliderDesc.setMass`), not on the body descriptor
89
+ * (see that method's own doc for why: a `RigidBodyDesc.mass` is "additional" point mass with no
90
+ * rotational inertia of its own, wrong for a body whose inertia should scale with its actual
91
+ * mass). `_bodyDescr.mass` is therefore always its unused default (`0`) regardless of what was
92
+ * actually requested - the real total is the sum of every collider's own `mass`, which
93
+ * `ColliderDesc` only carries meaningfully once `setMass`/`setMassProperties` was actually called
94
+ * on it (true for every collider of a `dynamic` body, per `createRigidBodyDescr`; a `static`/
95
+ * `kinematic_*` body never calls either, so this correctly sums to `0` for one of those instead).
96
+ */
97
+ get bodyOptions() {
98
+ return {
99
+ bodyType: rapierBodyTypeToBodyType(this._bodyDescr.status),
100
+ mass: this._colliderDescr.reduce((sum, cd) => sum + (cd.massPropsMode !== MassPropsMode.Density ? cd.mass : 0), 0),
101
+ friction: this._colliderOptions.friction,
102
+ restitution: this._colliderOptions.restitution,
103
+ ccd: this._bodyDescr.ccdEnabled,
104
+ ownCollisionGroups: this.ownCollisionGroups,
105
+ interactWithCollisionGroups: this.interactWithCollisionGroups,
106
+ };
107
+ }
65
108
  get nativeBody() {
66
109
  return this._nativeBody;
67
110
  }
@@ -100,7 +143,35 @@ export class Rapier3dRigidBodyComponent {
100
143
  const d = new ColliderDesc(cd.shape);
101
144
  d.setTranslation(cd.translation.x, cd.translation.y, cd.translation.z);
102
145
  d.setRotation(Object.assign({}, cd.rotation));
103
- d.setMassProperties(cd.mass, cd.centerOfMass, cd.principalAngularInertia, cd.angularInertiaLocalFrame);
146
+ // `cd.mass`/`centerOfMass`/`principalAngularInertia`/`angularInertiaLocalFrame` are only
147
+ // meaningful once something has actually switched `cd` into `MassPropsMode.MassProps` (via
148
+ // `setMassProperties`) or `.Mass` (via `setMass`/`ColliderDesc.mass`) - otherwise they're just
149
+ // a freshly-constructed `ColliderDesc`'s zeroed placeholder defaults (confirmed empirically:
150
+ // `mass: 0`, `principalAngularInertia: {0,0,0}`), irrelevant under the default
151
+ // `MassPropsMode.Density` mode, which instead derives mass *and* rotational inertia
152
+ // automatically from the shape and `cd.density`. Unconditionally copying those placeholders via
153
+ // `setMassProperties` here used to force every `factoryProps`-built collider (every `clone()`,
154
+ // and - since `Rapier3dRaycastVehicleComponent` builds its own body this same way - every
155
+ // raycast vehicle chassis) into an explicit zero-mass, zero-rotational-inertia `MassProps` mode
156
+ // regardless of the original's real mode, silently discarding the shape-derived inertia tensor
157
+ // a normal `factory.createRigidBody()` body gets for free. The practical symptom this caused:
158
+ // Rapier's own solver reduces "torque / (zero-plus-epsilon) angular inertia" to no angular
159
+ // acceleration at all, so a vehicle chassis built this way could never yaw - confirmed
160
+ // empirically (steering a vehicle chassis produced pure sideways-sliding translation with
161
+ // `angvel` staying exactly `{0,0,0}` every tick, never even a small nonzero value, which a
162
+ // merely-large-but-nonzero inertia tensor would still have produced). Fix: only propagate
163
+ // explicit mass properties when the original was actually in one of those two modes; otherwise
164
+ // just copy `density` and let Rapier re-derive mass/inertia from the (identical) shape, exactly
165
+ // like the original was computed.
166
+ if (cd.massPropsMode === MassPropsMode.MassProps) {
167
+ d.setMassProperties(cd.mass, cd.centerOfMass, cd.principalAngularInertia, cd.angularInertiaLocalFrame);
168
+ }
169
+ else if (cd.massPropsMode === MassPropsMode.Mass) {
170
+ d.setMass(cd.mass);
171
+ }
172
+ else {
173
+ d.setDensity(cd.density);
174
+ }
104
175
  d.setFriction(cd.friction);
105
176
  d.setEnabled(cd.enabled);
106
177
  d.setRestitution(cd.restitution);
@@ -3,6 +3,7 @@ import { ColliderDesc, RigidBodyDesc } from '@dimforge/rapier3d-compat';
3
3
  import { Rapier3dRigidBodyComponent } from './rapier-3d-rigid-body.component';
4
4
  import { DebugBody3DSettings, ITrigger3dComponent, Shape3DDescriptor } from '@gg-web-engine/core';
5
5
  import { Rapier3dWorldComponent } from './rapier-3d-world.component';
6
+ import { Rapier3dCharacterControllerComponent } from './rapier-3d-character-controller.component';
6
7
  import { Rapier3dGgWorld, Rapier3dPhysicsTypeDocRepo } from '../types';
7
8
  export declare class Rapier3dTriggerComponent extends Rapier3dRigidBodyComponent implements ITrigger3dComponent<Rapier3dPhysicsTypeDocRepo> {
8
9
  protected readonly world: Rapier3dWorldComponent;
@@ -10,11 +11,11 @@ export declare class Rapier3dTriggerComponent extends Rapier3dRigidBodyComponent
10
11
  readonly shape: Shape3DDescriptor;
11
12
  protected _bodyDescr: RigidBodyDesc;
12
13
  readonly debugBodySettings: DebugBody3DSettings;
13
- get onEntityEntered(): Observable<Rapier3dRigidBodyComponent>;
14
- get onEntityLeft(): Observable<Rapier3dRigidBodyComponent>;
15
- protected readonly overlaps: Set<Rapier3dRigidBodyComponent>;
16
- protected readonly onEnter$: Subject<Rapier3dRigidBodyComponent>;
17
- protected readonly onLeft$: Subject<Rapier3dRigidBodyComponent>;
14
+ get onEntityEntered(): Observable<Rapier3dRigidBodyComponent | Rapier3dCharacterControllerComponent>;
15
+ get onEntityLeft(): Observable<Rapier3dRigidBodyComponent | Rapier3dCharacterControllerComponent>;
16
+ protected readonly overlaps: Set<Rapier3dRigidBodyComponent | Rapier3dCharacterControllerComponent>;
17
+ protected readonly onEnter$: Subject<Rapier3dRigidBodyComponent | Rapier3dCharacterControllerComponent>;
18
+ protected readonly onLeft$: Subject<Rapier3dRigidBodyComponent | Rapier3dCharacterControllerComponent>;
18
19
  constructor(world: Rapier3dWorldComponent, _colliderDescr: ColliderDesc[], shape: Shape3DDescriptor, _bodyDescr: RigidBodyDesc);
19
20
  addToWorld(world: Rapier3dGgWorld): void;
20
21
  removeFromWorld(world: Rapier3dGgWorld, dispose?: boolean): void;
@@ -35,7 +36,7 @@ export declare class Rapier3dTriggerComponent extends Rapier3dRigidBodyComponent
35
36
  * components correctly via `Collider.parent()`, and pushes matching events to whichever
36
37
  * component(s) care.
37
38
  */
38
- notifyOverlap(otherBody: Rapier3dRigidBodyComponent, started: boolean): void;
39
+ notifyOverlap(otherBody: Rapier3dRigidBodyComponent | Rapier3dCharacterControllerComponent, started: boolean): void;
39
40
  checkOverlaps(): void;
40
41
  clone(): Rapier3dTriggerComponent;
41
42
  /** Completes `onEnter$`/`onLeft$` on top of `Rapier3dRigidBodyComponent.dispose()`'s own