@genex-ai/cli-demo 0.11.0 → 0.14.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +1 -0
  2. package/dist/index.js +203 -4
  3. package/package.json +7 -2
  4. package/templates/controllers/NOTICE.md +65 -0
  5. package/templates/controllers/assets/animation-library.glb +0 -0
  6. package/templates/controllers/assets/character.glb +0 -0
  7. package/templates/controllers/assets/default-avatar.vrm +0 -0
  8. package/templates/controllers/character/character-animations.ts +682 -0
  9. package/templates/controllers/character/character-controller.ts +1636 -0
  10. package/templates/controllers/character/follow-camera.ts +644 -0
  11. package/templates/controllers/character/keyboard-input.ts +277 -0
  12. package/templates/controllers/character/presets.ts +176 -0
  13. package/templates/controllers/character/touch-joystick.ts +387 -0
  14. package/templates/controllers/character/vrm/capsule-fit.ts +52 -0
  15. package/templates/controllers/character/vrm/foot-ik.ts +341 -0
  16. package/templates/controllers/character/vrm/vrm-loader.ts +44 -0
  17. package/templates/controllers/character/vrm/vrm-retarget.ts +195 -0
  18. package/templates/controllers/drone/drone-controller.ts +1073 -0
  19. package/templates/controllers/drone/presets.ts +225 -0
  20. package/templates/controllers/interact/enter-exit.ts +502 -0
  21. package/templates/controllers/shared/colliders.ts +456 -0
  22. package/templates/controllers/shared/math.ts +230 -0
  23. package/templates/controllers/shared/physics-world.ts +622 -0
  24. package/templates/controllers/vehicle/presets.ts +297 -0
  25. package/templates/controllers/vehicle/vehicle-controller.ts +615 -0
  26. package/templates/controllers/vehicle/wheel.ts +1200 -0
  27. package/templates/skills/genex-getting-started/SKILL.md +5 -0
  28. package/templates/skills/genex-threejs-character-controller/SKILL.md +205 -0
  29. package/templates/skills/genex-threejs-character-controller/references/animations.md +235 -0
  30. package/templates/skills/genex-threejs-character-controller/references/tuning-and-presets.md +102 -0
  31. package/templates/skills/genex-threejs-character-controller/references/wiring.md +198 -0
  32. package/templates/skills/genex-threejs-embed-auth/SKILL.md +126 -54
  33. package/templates/skills/genex-threejs-multiplayer/SKILL.md +17 -11
  34. package/templates/skills/genex-threejs-physics-rapier/SKILL.md +128 -0
  35. package/templates/skills/genex-threejs-physics-rapier/references/colliders-from-assets.md +202 -0
  36. package/templates/skills/genex-threejs-physics-rapier/references/physics-setup.md +207 -0
  37. package/templates/skills/genex-threejs-skill-router/SKILL.md +3 -0
  38. package/templates/skills/genex-threejs-skill-router/references/routing-map.md +15 -7
  39. package/templates/skills/genex-threejs-vehicle-controllers/SKILL.md +110 -0
  40. package/templates/skills/genex-threejs-vehicle-controllers/references/car.md +162 -0
  41. package/templates/skills/genex-threejs-vehicle-controllers/references/drone.md +150 -0
  42. package/templates/skills/genex-threejs-vehicle-controllers/references/enter-exit.md +199 -0
@@ -0,0 +1,1200 @@
1
+ // SPDX-FileCopyrightText: 2023-2026 Erdong Chen
2
+ // SPDX-License-Identifier: MIT
3
+ // Vanilla-TypeScript port of the ecctrl vehicle controller (React/R3F removed).
4
+ // This file is the shapecast wheel: suspension spring/damper, slip-curve tire
5
+ // model with friction ellipse and tire relaxation, speed-sensitive steering,
6
+ // wheel spin integration, moving-platform following, and the wheel-model
7
+ // visual sync. Upstream typo'd names are corrected in our public API:
8
+ // `rayHitFriciton` -> `rayHitFriction`, `wheelModelReversRotation` ->
9
+ // `wheelModelReverseRotation`, `wheelModelUpdate` -> `updateModel`; the
10
+ // rigid-body userData key `ecctrl` is renamed to `controller` (de-branding).
11
+
12
+ import * as THREE from "three";
13
+ import RAPIER from "@dimforge/rapier3d-compat";
14
+ import type {
15
+ World,
16
+ RigidBody,
17
+ Collider,
18
+ ColliderShapeCastHit,
19
+ RayColliderIntersection,
20
+ Ray,
21
+ Cylinder,
22
+ } from "@dimforge/rapier3d-compat";
23
+ import {
24
+ remap,
25
+ bakeCurveLUT,
26
+ evaluateCurveLUT,
27
+ type CurveData,
28
+ type CurveLUT,
29
+ } from "../shared/math.ts";
30
+
31
+ const clamp = THREE.MathUtils.clamp;
32
+
33
+ /**
34
+ * Drivetrain config pushed into drive wheels by the vehicle controller.
35
+ * Users normally never build this by hand — `VehicleController` derives it
36
+ * from `CarConfig` and re-pushes it on every gear change.
37
+ */
38
+ export type DriveWheelConfig = {
39
+ /** Peak engine torque share for THIS wheel (N·m), split by drive weights. */
40
+ maxDriveTorque: number;
41
+ /** Wheel angular velocity at engine redline in top of current gear (rad/s). */
42
+ maxWheelAngVel: number;
43
+ /** Baked engine torque curve, sampled by |wheelAngVel| / maxWheelAngVel. */
44
+ engineTorqueCurve: CurveLUT;
45
+ /** Torque multiplier while reversing (1 = same punch as forward). */
46
+ reverseTorqueScale: number;
47
+ /** Scales the reverse speed cap (0.3 = reverse tops out at 30%). */
48
+ reverseRPMScale: number;
49
+ /** Current gear ratio x final drive ratio. */
50
+ driveRatio: number;
51
+ };
52
+
53
+ /**
54
+ * Steering config shared (by reference) across all steer wheels — the vehicle
55
+ * controller updates `maxWheelAngVel` in place on gear changes.
56
+ */
57
+ export type SteerWheelConfig = {
58
+ /** Baked steer-angle falloff curve over forward-speed ratio (speed-sensitive steering). */
59
+ steerAngleCurve: CurveLUT;
60
+ /** How fast the wheel slews toward its target angle (rad/s). */
61
+ steerRate: number;
62
+ /** Max steer angle at standstill (rad). */
63
+ maxSteerAngle: number;
64
+ /** Theoretical top wheel spin (rad/s); normalizes the speed ratio. */
65
+ maxWheelAngVel: number;
66
+ };
67
+
68
+ /**
69
+ * Per-wheel options. Everything except `position` has an upstream default.
70
+ *
71
+ * Tuning hints:
72
+ * - "car bottoms out / sits too low" -> raise `springK` (scale with chassis mass!).
73
+ * - "car pogo-bounces" -> raise `dampingC` (keep it below `2*sqrt(springK * massPerWheel)`).
74
+ * - "too slidey / too grippy" -> `tireGripFactor` (averaged with ground friction).
75
+ * - "drifts too easily sideways" -> raise `latFrictionEllipseScale`.
76
+ * - "wheels dig into curbs" -> longer `rayLength` (more suspension travel).
77
+ */
78
+ export type WheelOptions = {
79
+ /** Stable id used as the key in the vehicle's wheel map. Default: random UUID. */
80
+ id?: string;
81
+ /** Optional display name (not used by physics). */
82
+ name?: string;
83
+ /** Master enable; a disabled wheel skips its whole update. Default true. */
84
+ enable?: boolean;
85
+ /** REQUIRED. Wheel mount point in chassis-local space (axle position, +Z forward). */
86
+ position: THREE.Vector3;
87
+
88
+ /** "shapeCast" (cylinder sweep, default; handles curbs/side contact) or "rayCast" (cheaper). */
89
+ groundDetection?: "shapeCast" | "rayCast";
90
+ /** Wheel radius (m). Default 0.5. */
91
+ rayShapeR?: number;
92
+ /** Cylinder HALF-height = half the tire width (m). Default 0.15. */
93
+ rayShapeH?: number;
94
+ /** Suspension travel below the axle (m). Default 0.5. */
95
+ rayLength?: number;
96
+ /** Suspension spring constant. Default 180 (assumes a very light chassis — presets scale it). */
97
+ springK?: number;
98
+ /** Suspension damping. Default 16. Upstream note: max at 2*sqrt(K*mass). */
99
+ dampingC?: number;
100
+
101
+ /** Flip drive torque sign (for mirrored wheel setups). Default false. */
102
+ driveInvert?: boolean;
103
+ /** Does the engine power this wheel? Default false. */
104
+ driveWheel?: boolean;
105
+ /** Torque split weight among drive wheels. Default 1. Bigger = more of the engine. */
106
+ driveTorqueWeight?: number;
107
+ /** Flip steer direction. Default false. */
108
+ steerInvert?: boolean;
109
+ /** Does this wheel steer? Default false. */
110
+ steerWheel?: boolean;
111
+ /** Does this wheel brake? Default false. */
112
+ brakeWheel?: boolean;
113
+ /** Max brake torque (N·m). Default 40. */
114
+ maxBrakeTorque?: number;
115
+
116
+ /** Rolling drag while free-rolling. Default 0.007. */
117
+ rollingResistanceCoef?: number;
118
+
119
+ /** Below this contact speed (m/s) the tire blends toward full static grip. Default 0.4. */
120
+ lowVelThreshold?: number;
121
+ /** Tire grip, averaged with ground friction: (surface + grip) * 0.5. Default 1.5. */
122
+ tireGripFactor?: number;
123
+ /** Scales the longitudinal (accel/brake) half of the friction ellipse. Default 1. */
124
+ lngFrictionEllipseScale?: number;
125
+ /** Scales the lateral (cornering) half of the friction ellipse. Default 1. */
126
+ latFrictionEllipseScale?: number;
127
+ /** Longitudinal tire relaxation rate (smaller = snappier response). Default 0.05. */
128
+ relaxLngRate?: number;
129
+ /** Lateral tire relaxation rate. Default 0.1. */
130
+ relaxLatRate?: number;
131
+ /** Relaxation floor so low-speed tires stay responsive. Default 0.3. */
132
+ minLngRelaxCoeff?: number;
133
+ /** Relaxation floor, lateral. Default 0.3. */
134
+ minLatRelaxCoeff?: number;
135
+ /** Longitudinal slip curve. Default (0,0)->(0.25,1)->(1,0.7). */
136
+ lngSlipRatioCurveData?: CurveData;
137
+ /** Lateral slip curve. Default (0,0)->(0.15,1)->(1,0.9). */
138
+ latSlipRatioCurveData?: CurveData;
139
+
140
+ /** Inherit velocity from dynamic/kinematic bodies stood on. Default true. */
141
+ followPlatform?: boolean;
142
+ /** Platform-influence falloff over (platform mass / vehicle mass). */
143
+ massRatioFallOffCurveData?: CurveData;
144
+ /** Push wheel load back onto dynamic bodies stood on. Default true. */
145
+ applyCounterMass?: boolean;
146
+ /** Push tire friction back onto dynamic bodies stood on. Default true. */
147
+ applyCounterFriction?: boolean;
148
+
149
+ /** Drive the visual groups (suspension bounce + wheel spin). Default true. (upstream `wheelModelUpdate`) */
150
+ updateModel?: boolean;
151
+ /** VISUAL mass proxy density — fattens effective inertia; creates no collider. Default 1.5. */
152
+ wheelModelDensity?: number;
153
+ /** Visual wheel radius for the model rest offset. Default 0.5. */
154
+ wheelModelRadius?: number;
155
+ /** Suspension visual smoothing rate (1 - exp(-rate*dt)). Default 10. */
156
+ wheelModelLerpPosRate?: number;
157
+ /** Spin the wheel mesh the other way. Default false. (upstream `wheelModelReversRotation`) */
158
+ wheelModelReverseRotation?: boolean;
159
+ };
160
+
161
+ /**
162
+ * What the wheel needs from its vehicle. `VehicleController` satisfies this
163
+ * structurally (it exposes `world`, `body`, `chassisObject`, `gravityMag`).
164
+ */
165
+ export type WheelVehicleContext = {
166
+ readonly world: World;
167
+ readonly body: RigidBody;
168
+ readonly chassisObject: THREE.Object3D;
169
+ readonly gravityMag: number;
170
+ };
171
+
172
+ /**
173
+ * Local (type-only) view of the shared rigid-body userData contract owned by
174
+ * `shared/physics-world.ts` (`ControllerUserData`). Runtime key is
175
+ * `controller` (upstream used `ecctrl`). Wheel shapecasts skip bodies with
176
+ * `excludeRay` or `excludeVehicleRay` — the on-foot character body should set
177
+ * `{ controller: { excludeVehicleRay: true } }`.
178
+ */
179
+ type WheelUserData = {
180
+ controller?: {
181
+ excludeRay?: boolean;
182
+ excludeCharacterRay?: boolean;
183
+ excludeVehicleRay?: boolean;
184
+ };
185
+ };
186
+
187
+ const DEFAULT_LNG_SLIP_CURVE: CurveData = {
188
+ points: [
189
+ { x: 0, y: 0, r_out: 1.45 },
190
+ { x: 0.25, y: 1, r_in: 0, r_out: 0 },
191
+ { x: 1, y: 0.7, r_in: 0 },
192
+ ],
193
+ };
194
+ const DEFAULT_LAT_SLIP_CURVE: CurveData = {
195
+ points: [
196
+ { x: 0, y: 0, r_out: 1.45 },
197
+ { x: 0.15, y: 1, r_in: 0, r_out: 0 },
198
+ { x: 1, y: 0.9, r_in: 0 },
199
+ ],
200
+ };
201
+ const DEFAULT_MASS_RATIO_FALL_OFF_CURVE: CurveData = {
202
+ points: [
203
+ { x: 0, y: 0.5, r_out: 0 },
204
+ { x: 0.5, y: 1, r_out: 0 },
205
+ { x: 1, y: 1, r_in: 0 },
206
+ ],
207
+ };
208
+
209
+ /**
210
+ * One shapecast wheel. Constructed via `VehicleController.addWheel()`.
211
+ *
212
+ * Scene-graph pose derivation (the load-bearing design): the wheel's world
213
+ * pose comes from `wheelGroup.getWorldPosition/getWorldQuaternion`, whose
214
+ * parent chain is the vehicle's `chassisObject` — which the controller syncs
215
+ * from the rigid body at the top of ITS `update()`, BEFORE any wheel updates.
216
+ * Steering mutates `wheelGroup`'s local rotation via `rotateY`; the
217
+ * accumulated group rotation is the authoritative steer pose (the
218
+ * `steerAngle` number is bookkeeping for increments and reporting).
219
+ *
220
+ * Add your wheel mesh as a child of `modelObject` (it spins around local X;
221
+ * `suspensionGroup` above it bounces on local Y with the suspension).
222
+ */
223
+ export class ShapeCastWheel {
224
+ readonly id: string;
225
+ readonly name: string;
226
+ /** Master enable; `update()` early-outs when false. */
227
+ enabled: boolean;
228
+
229
+ /** Steering parent, child of the chassis at the wheel mount point. */
230
+ readonly wheelGroup = new THREE.Group();
231
+ /** Suspension bounce group (local Y), child of `wheelGroup`. */
232
+ readonly suspensionGroup = new THREE.Group();
233
+ /** Add your wheel mesh here; spins around local X. Child of `suspensionGroup`. */
234
+ readonly modelObject = new THREE.Group();
235
+
236
+ /** Flags the vehicle brain reads for demand routing / torque split. */
237
+ readonly driveWheel: boolean;
238
+ readonly steerWheel: boolean;
239
+ readonly brakeWheel: boolean;
240
+ readonly driveTorqueWeight: number;
241
+
242
+ // --- vehicle context ---
243
+ private readonly vehicle: WheelVehicleContext;
244
+
245
+ // --- options ---
246
+ private readonly groundDetection: "shapeCast" | "rayCast";
247
+ private readonly rayShapeR: number;
248
+ private readonly rayShapeH: number;
249
+ private readonly rayLength: number;
250
+ private readonly springK: number;
251
+ private readonly dampingC: number;
252
+ private readonly driveInvert: boolean;
253
+ private readonly steerInvert: boolean;
254
+ private readonly maxBrakeTorque: number;
255
+ private readonly rollingResistanceCoef: number;
256
+ private readonly lowVelThreshold: number;
257
+ private readonly tireGripFactor: number;
258
+ private readonly lngFrictionEllipseScale: number;
259
+ private readonly latFrictionEllipseScale: number;
260
+ private readonly relaxLngRate: number;
261
+ private readonly relaxLatRate: number;
262
+ private readonly minLngRelaxCoeff: number;
263
+ private readonly minLatRelaxCoeff: number;
264
+ private readonly followPlatform: boolean;
265
+ private readonly applyCounterMass: boolean;
266
+ private readonly applyCounterFriction: boolean;
267
+ private readonly updateModel: boolean;
268
+ private readonly wheelModelRadius: number;
269
+ private readonly wheelModelLerpPosRate: number;
270
+ private readonly wheelModelReverseRotation: boolean;
271
+
272
+ // --- derived wheel constants (wheelInertia = 0.5 * m * r^2) ---
273
+ private readonly wheelMass: number;
274
+ private readonly wheelInertia: number;
275
+
276
+ // --- baked curves ---
277
+ private readonly lngSlipRatioCurve: CurveLUT;
278
+ private readonly latSlipRatioCurve: CurveLUT;
279
+ private readonly massRatioFallOffCurve: CurveLUT;
280
+
281
+ // --- shapecast primitives (constructed once; Ray wraps LIVE refs to
282
+ // rayOrigin/rayDirection — mutate the vectors, never re-create the Ray) ---
283
+ private readonly rayShape: Cylinder;
284
+ private readonly rayCastRay: Ray;
285
+ private readonly rotZ90 = new THREE.Quaternion().setFromAxisAngle(
286
+ new THREE.Vector3(0, 0, 1),
287
+ Math.PI / 2
288
+ );
289
+
290
+ // --- vehicle info scratch (refreshed every update for the freshest pose) ---
291
+ private readonly vehiclePos = new THREE.Vector3();
292
+ private readonly vehicleQuat = new THREE.Quaternion();
293
+ private readonly vehicleLinVel = new THREE.Vector3();
294
+ private readonly vehicleAngVel = new THREE.Vector3();
295
+ private readonly vehicleXAxis = new THREE.Vector3();
296
+ private readonly vehicleYAxis = new THREE.Vector3();
297
+ private readonly vehicleZAxis = new THREE.Vector3();
298
+
299
+ // --- wheel physical state ---
300
+ private effectiveInertia = 0;
301
+ private _wheelAngVel = 0;
302
+ private readonly supportPoint = new THREE.Vector3();
303
+
304
+ // --- friction state ---
305
+ private frictionCoef = 0;
306
+ private readonly _lngAxis = new THREE.Vector3();
307
+ private readonly _latAxis = new THREE.Vector3();
308
+ private readonly lngFrictionImp = new THREE.Vector3();
309
+ private readonly latFrictionImp = new THREE.Vector3();
310
+ private _lngSlipRatio = 0;
311
+ private _latSlipRatio = 0;
312
+ private _slipStrength = 0;
313
+ private smoothedLngImpulse = 0;
314
+ private smoothedLatImpulse = 0;
315
+ private desiredLngImpulse = 0;
316
+ private desiredLatImpulse = 0;
317
+
318
+ // --- steering state ---
319
+ private _steerAngle = 0;
320
+ private steerTarget = 0;
321
+ private steerIncrement = 0;
322
+ private steerDemand = 0;
323
+ private steerWheelConfig: SteerWheelConfig | null = null;
324
+
325
+ // --- drive state ---
326
+ private _driveTorque = 0;
327
+ private driveDemand = 0;
328
+ private driveWheelConfig: DriveWheelConfig | null = null;
329
+
330
+ // --- brake state ---
331
+ private _brakeTorque = 0;
332
+ private brakeDemand = 0;
333
+
334
+ // --- shapecast scratch/state ---
335
+ private readonly distFromRayOriginToVehicle = new THREE.Vector3();
336
+ private readonly angvelToLinvel = new THREE.Vector3();
337
+ private readonly floatingImpulse = new THREE.Vector3();
338
+ private readonly rayOrigin = new THREE.Vector3();
339
+ private readonly rayRotation = new THREE.Quaternion();
340
+ private readonly rayDirection = new THREE.Vector3();
341
+ private readonly rayOriginVelocity = new THREE.Vector3();
342
+ private readonly rayUpAxis = new THREE.Vector3();
343
+ private readonly rayFWDAxis = new THREE.Vector3();
344
+ private readonly rayLeftAxis = new THREE.Vector3();
345
+ private _shapeRayHit: ColliderShapeCastHit | null = null;
346
+ private _rayHit: RayColliderIntersection | null = null;
347
+ private _suspensionToi = 0;
348
+ private _rayHitBody: RigidBody | null = null;
349
+ private readonly rayShapeCenter = new THREE.Vector3();
350
+ private readonly stableRayHitPoint = new THREE.Vector3();
351
+ private readonly targetRayHitPoint = new THREE.Vector3();
352
+ private readonly rayHitPointOffset = new THREE.Vector3();
353
+ private readonly rayHitPointPosition = new THREE.Vector3();
354
+ private readonly rayHitPointVelocity = new THREE.Vector3();
355
+ private readonly rayHitPointVelOnPlane = new THREE.Vector3();
356
+ private readonly rayHitPointNormal = new THREE.Vector3();
357
+ private _rayHitFriction = 0;
358
+
359
+ // --- moving platform state ---
360
+ private massRatio = 1;
361
+ private _isOnMovingObject = false;
362
+ private wheelSupportForceMag = 0;
363
+ private readonly wheelSupportImpulse = new THREE.Vector3();
364
+ private readonly wheelFrictionImpulse = new THREE.Vector3();
365
+ private readonly movingObjectPosition = new THREE.Vector3();
366
+ private readonly movingObjectVelocity = new THREE.Vector3();
367
+ private readonly movingObjectVelocityOnPlane = new THREE.Vector3();
368
+ private readonly movingObjectLinearVelocity = new THREE.Vector3();
369
+ private readonly movingObjectAngularVelocity = new THREE.Vector3();
370
+ private readonly distanceFromOriginToObjectPoint = new THREE.Vector3();
371
+ private readonly movingObjectAngvelToLinvel = new THREE.Vector3();
372
+
373
+ // --- world pose scratch ---
374
+ private readonly worldPos = new THREE.Vector3();
375
+ private readonly worldQuat = new THREE.Quaternion();
376
+
377
+ // --- published state (one-frame-stale, mirrors upstream wheelInfo) ---
378
+ // Upstream copies every VALUE-typed field into `wheelInfo` at the TOP of the
379
+ // wheel's frame (updateVehicleInfo), BEFORE floatVehicle/solveWheelRotation
380
+ // overwrite the live fields — so the vehicle brain (and user code) always
381
+ // reads LAST step's values. Vector fields are shared by reference upstream
382
+ // (mutated in place), so their getters stay live. Do NOT "fix" this by
383
+ // reading the live fields: impulse gating on contact transitions and
384
+ // RPM-threshold shift timing depend on the stale snapshot.
385
+ private publishedRayHit: ColliderShapeCastHit | RayColliderIntersection | null =
386
+ null;
387
+ private publishedRayHitBody: RigidBody | null = null;
388
+ private publishedRayHitFriction = 0;
389
+ private publishedIsOnPlatform = false;
390
+ private publishedLngSlipRatio = 0;
391
+ private publishedLatSlipRatio = 0;
392
+ private publishedSlipStrength = 0;
393
+ private publishedEffInertia = 0;
394
+ private publishedSteerAngle = 0;
395
+ private publishedDriveTorque = 0;
396
+ private publishedBrakeTorque = 0;
397
+ private publishedWheelAngVel = 0;
398
+ private publishedWheelLinVel = 0;
399
+
400
+ /** Ground-query filter: skip excluded bodies (userData key `controller`). */
401
+ private readonly rayFilter = (collider: Collider): boolean => {
402
+ const userData = collider.parent()?.userData as WheelUserData | undefined;
403
+ return !(
404
+ userData?.controller?.excludeRay || userData?.controller?.excludeVehicleRay
405
+ );
406
+ };
407
+
408
+ constructor(vehicle: WheelVehicleContext, options: WheelOptions) {
409
+ this.vehicle = vehicle;
410
+
411
+ this.id = options.id ?? THREE.MathUtils.generateUUID();
412
+ this.name = options.name ?? "";
413
+ this.enabled = options.enable ?? true;
414
+
415
+ this.groundDetection = options.groundDetection ?? "shapeCast";
416
+ this.rayShapeR = options.rayShapeR ?? 0.5;
417
+ this.rayShapeH = options.rayShapeH ?? 0.15;
418
+ this.rayLength = options.rayLength ?? 0.5;
419
+ this.springK = options.springK ?? 180;
420
+ this.dampingC = options.dampingC ?? 16; // max at 2*sqrt(K*mass)
421
+
422
+ this.driveInvert = options.driveInvert ?? false;
423
+ this.driveWheel = options.driveWheel ?? false;
424
+ this.driveTorqueWeight = options.driveTorqueWeight ?? 1;
425
+ this.steerInvert = options.steerInvert ?? false;
426
+ this.steerWheel = options.steerWheel ?? false;
427
+ this.brakeWheel = options.brakeWheel ?? false;
428
+ this.maxBrakeTorque = options.maxBrakeTorque ?? 40;
429
+
430
+ this.rollingResistanceCoef = options.rollingResistanceCoef ?? 0.007;
431
+
432
+ this.lowVelThreshold = options.lowVelThreshold ?? 0.4;
433
+ this.tireGripFactor = options.tireGripFactor ?? 1.5;
434
+ this.lngFrictionEllipseScale = options.lngFrictionEllipseScale ?? 1;
435
+ this.latFrictionEllipseScale = options.latFrictionEllipseScale ?? 1;
436
+ this.relaxLngRate = options.relaxLngRate ?? 0.05;
437
+ this.relaxLatRate = options.relaxLatRate ?? 0.1;
438
+ this.minLngRelaxCoeff = options.minLngRelaxCoeff ?? 0.3;
439
+ this.minLatRelaxCoeff = options.minLatRelaxCoeff ?? 0.3;
440
+
441
+ this.followPlatform = options.followPlatform ?? true;
442
+ this.applyCounterMass = options.applyCounterMass ?? true;
443
+ this.applyCounterFriction = options.applyCounterFriction ?? true;
444
+
445
+ this.updateModel = options.updateModel ?? true;
446
+ this.wheelModelRadius = options.wheelModelRadius ?? 0.5;
447
+ this.wheelModelLerpPosRate = options.wheelModelLerpPosRate ?? 10;
448
+ this.wheelModelReverseRotation = options.wheelModelReverseRotation ?? false;
449
+
450
+ // Derived wheel constants (visual density proxy -> inertia; no collider).
451
+ const wheelModelDensity = options.wheelModelDensity ?? 1.5;
452
+ const wheelVolume =
453
+ Math.PI * this.rayShapeR * this.rayShapeR * (this.rayShapeH * 2);
454
+ this.wheelMass = wheelModelDensity * wheelVolume;
455
+ this.wheelInertia = 0.5 * this.wheelMass * this.rayShapeR * this.rayShapeR;
456
+
457
+ // Bake curve LUTs.
458
+ const lngData = options.lngSlipRatioCurveData ?? DEFAULT_LNG_SLIP_CURVE;
459
+ this.lngSlipRatioCurve = bakeCurveLUT(lngData.points, lngData.samples ?? 50);
460
+ const latData = options.latSlipRatioCurveData ?? DEFAULT_LAT_SLIP_CURVE;
461
+ this.latSlipRatioCurve = bakeCurveLUT(latData.points, latData.samples ?? 50);
462
+ const massData =
463
+ options.massRatioFallOffCurveData ?? DEFAULT_MASS_RATIO_FALL_OFF_CURVE;
464
+ this.massRatioFallOffCurve = bakeCurveLUT(
465
+ massData.points,
466
+ massData.samples ?? 50
467
+ );
468
+
469
+ // Cylinder ctor order is (halfHeight, radius) — swapping the args gives a
470
+ // pancake wheel that "works" until side contacts.
471
+ this.rayShape = new RAPIER.Cylinder(this.rayShapeH, this.rayShapeR);
472
+ // The Ray holds LIVE references to rayOrigin/rayDirection.
473
+ this.rayCastRay = new RAPIER.Ray(this.rayOrigin, this.rayDirection);
474
+
475
+ // Build the visual hierarchy; the caller (VehicleController.addWheel)
476
+ // parents `wheelGroup` under the chassis object.
477
+ this.wheelGroup.position.copy(options.position);
478
+ this.wheelGroup.add(this.suspensionGroup);
479
+ this.suspensionGroup.add(this.modelObject);
480
+ }
481
+
482
+ /**
483
+ * Full per-step wheel pipeline. Called by `VehicleController.update()`
484
+ * (children-first order) once per fixed physics step, BEFORE `world.step()`.
485
+ */
486
+ update(): void {
487
+ if (!this.enabled) return;
488
+ const body = this.vehicle.body;
489
+ const gravityMag = this.vehicle.gravityMag;
490
+
491
+ // 1. Refresh vehicle pose/velocity info + publish LAST step's wheel state
492
+ // (the snapshot the vehicle brain reads — upstream wheelInfo).
493
+ this.updateVehicleInfo(body);
494
+
495
+ // 2. Update shapecast pose/dir/axes/velocity (applies LAST frame's steer increment).
496
+ this.updateShapeCastDir();
497
+
498
+ // 3. Convert demands into drive torque / steer target / brake torque.
499
+ this.handleUserInput();
500
+
501
+ // 4. Slew the steer angle (increment applied to the group NEXT frame).
502
+ this.steeringWheel();
503
+
504
+ // 5. Cast, find contact, compute the suspension (floating) impulse.
505
+ this.floatVehicle(body);
506
+
507
+ // 6. Detect moving platforms and their contact-point velocity.
508
+ this.isOnMovingObjectDetect(body);
509
+
510
+ // 7. Push wheel load back onto the stood-on dynamic body (previous frame's force).
511
+ this.applyMassOnStandCollider();
512
+
513
+ // 8. Push tire friction back onto the stood-on dynamic body (previous frame's impulses).
514
+ this.applyFrictionOnStandCollider();
515
+
516
+ // 9. Relative contact velocity (platform-adjusted).
517
+ this.computeRelativeVelocity();
518
+
519
+ // 10. Contact friction coefficient (surface + tire grip average).
520
+ this.computeContactFriction();
521
+
522
+ // 11. Slip-curve tire model -> lng/lat friction impulses.
523
+ this.computeWheelFrictionImpulse(gravityMag);
524
+
525
+ // 12. Integrate wheel spin (drive/brake/rolling-resistance/friction reaction).
526
+ this.solveWheelRotation();
527
+
528
+ // 13. Sync the visual groups (suspension bounce + spin).
529
+ this.updateWheelModel();
530
+ }
531
+
532
+ /** Remove the wheel's groups from the scene graph. */
533
+ dispose(): void {
534
+ this.wheelGroup.removeFromParent();
535
+ }
536
+
537
+ // --- demand/config setters (called by the vehicle brain) ---
538
+ setDriveDemand(v: number): void {
539
+ this.driveDemand = v;
540
+ }
541
+ setBrakeDemand(v: number): void {
542
+ this.brakeDemand = v;
543
+ }
544
+ setSteerDemand(v: number): void {
545
+ this.steerDemand = v;
546
+ }
547
+ setDriveWheelConfig(cfg: DriveWheelConfig): void {
548
+ this.driveWheelConfig = cfg;
549
+ }
550
+ setSteerWheelConfig(cfg: SteerWheelConfig): void {
551
+ this.steerWheelConfig = cfg;
552
+ }
553
+
554
+ // --- readonly state ---
555
+ // Value-typed getters return the ONE-FRAME-STALE published snapshot taken at
556
+ // the top of update() (upstream wheelInfo semantics); vector getters return
557
+ // live internal instances shared by reference (copy, never mutate).
558
+ get rayHit(): ColliderShapeCastHit | RayColliderIntersection | null {
559
+ return this.publishedRayHit;
560
+ }
561
+ get rayHitBody(): RigidBody | null {
562
+ return this.publishedRayHitBody;
563
+ }
564
+ /** Friction application point (center-section projected + side blended). */
565
+ get rayHitPos(): THREE.Vector3 {
566
+ return this.rayHitPointPosition;
567
+ }
568
+ get rayHitNormal(): THREE.Vector3 {
569
+ return this.rayHitPointNormal;
570
+ }
571
+ /** Ground collider friction at the contact. (upstream `rayHitFriciton`) */
572
+ get rayHitFriction(): number {
573
+ return this.publishedRayHitFriction;
574
+ }
575
+ get rayOriginVel(): THREE.Vector3 {
576
+ return this.rayOriginVelocity;
577
+ }
578
+ /** Relative (platform-adjusted) contact velocity. */
579
+ get rayHitPointVel(): THREE.Vector3 {
580
+ return this.rayHitPointVelocity;
581
+ }
582
+ get isOnPlatform(): boolean {
583
+ return this.publishedIsOnPlatform;
584
+ }
585
+ /** Suspension impulse — applied by the vehicle at `supPos`. */
586
+ get floatImp(): THREE.Vector3 {
587
+ return this.floatingImpulse;
588
+ }
589
+ get lngFricImp(): THREE.Vector3 {
590
+ return this.lngFrictionImp;
591
+ }
592
+ get latFricImp(): THREE.Vector3 {
593
+ return this.latFrictionImp;
594
+ }
595
+ get lngAxis(): THREE.Vector3 {
596
+ return this._lngAxis;
597
+ }
598
+ get latAxis(): THREE.Vector3 {
599
+ return this._latAxis;
600
+ }
601
+ get lngSlipRatio(): number {
602
+ return this.publishedLngSlipRatio;
603
+ }
604
+ get latSlipRatio(): number {
605
+ return this.publishedLatSlipRatio;
606
+ }
607
+ /** max(lngSlipRatio, latSlipRatio) — handy for skid VFX/SFX triggers. */
608
+ get slipStrength(): number {
609
+ return this.publishedSlipStrength;
610
+ }
611
+ /** effectiveInertia = 0.5*m_wheel*r^2 + (load/g)*r^2. */
612
+ get effInertia(): number {
613
+ return this.publishedEffInertia;
614
+ }
615
+ /** Suspension application point (shape center + side-contact offset). */
616
+ get supPos(): THREE.Vector3 {
617
+ return this.supportPoint;
618
+ }
619
+ get steerAngle(): number {
620
+ return this.publishedSteerAngle;
621
+ }
622
+ get driveTorque(): number {
623
+ return this.publishedDriveTorque;
624
+ }
625
+ get brakeTorque(): number {
626
+ return this.publishedBrakeTorque;
627
+ }
628
+ /** Wheel spin (rad/s). */
629
+ get wheelAngVel(): number {
630
+ return this.publishedWheelAngVel;
631
+ }
632
+ /** Wheel surface speed = wheelAngVel * rayShapeR (m/s). */
633
+ get wheelLinVel(): number {
634
+ return this.publishedWheelLinVel;
635
+ }
636
+ /** Current suspension hit distance (0 when airborne). Port-only extension
637
+ * (no upstream wheelInfo field), so it reads LIVE, not the snapshot. */
638
+ get suspensionToi(): number {
639
+ return this._suspensionToi;
640
+ }
641
+
642
+ // ------------------------------------------------------------------
643
+ // Pipeline internals (order and formulas mirror upstream exactly)
644
+ // ------------------------------------------------------------------
645
+
646
+ private updateVehicleInfo(body: RigidBody): void {
647
+ this.vehiclePos.copy(body.translation());
648
+ this.vehicleQuat.copy(body.rotation());
649
+
650
+ this.vehicleYAxis.set(0, 1, 0).applyQuaternion(this.vehicleQuat);
651
+ this.vehicleXAxis.set(1, 0, 0).applyQuaternion(this.vehicleQuat);
652
+ this.vehicleZAxis.set(0, 0, 1).applyQuaternion(this.vehicleQuat);
653
+
654
+ this.vehicleLinVel.copy(body.linvel());
655
+ this.vehicleAngVel.copy(body.angvel());
656
+
657
+ // Publish LAST step's value-typed wheel state (see the published-state
658
+ // field block for why this snapshot must happen HERE, before floatVehicle
659
+ // and solveWheelRotation mutate the live fields).
660
+ this.publishedRayHit =
661
+ this.groundDetection === "rayCast" ? this._rayHit : this._shapeRayHit;
662
+ this.publishedRayHitBody = this._rayHitBody;
663
+ this.publishedRayHitFriction = this._rayHitFriction;
664
+ this.publishedIsOnPlatform = this._isOnMovingObject;
665
+ this.publishedLngSlipRatio = this._lngSlipRatio;
666
+ this.publishedLatSlipRatio = this._latSlipRatio;
667
+ this.publishedSlipStrength = this._slipStrength;
668
+ this.publishedEffInertia = this.effectiveInertia;
669
+ this.publishedSteerAngle = this._steerAngle;
670
+ this.publishedDriveTorque = this._driveTorque;
671
+ this.publishedBrakeTorque = this._brakeTorque;
672
+ this.publishedWheelAngVel = this._wheelAngVel;
673
+ this.publishedWheelLinVel = this._wheelAngVel * this.rayShapeR;
674
+ }
675
+
676
+ private updateShapeCastDir(): void {
677
+ // Steer and gather world pos/quat. NOTE: applies LAST frame's increment —
678
+ // the group's accumulated local rotation is the authoritative steer pose.
679
+ // Requires the chassis matrixWorld already synced from the rigid body
680
+ // (VehicleController.update() step A).
681
+ if (this.steerWheel) this.wheelGroup.rotateY(this.steerIncrement);
682
+ this.wheelGroup.getWorldPosition(this.worldPos);
683
+ this.wheelGroup.getWorldQuaternion(this.worldQuat);
684
+
685
+ // Update shape cast current info: pos/dir/axes.
686
+ this.rayOrigin.copy(this.worldPos);
687
+ this.rayDirection.set(0, -1, 0).applyQuaternion(this.worldQuat);
688
+ this.rayUpAxis.copy(this.rayDirection).negate();
689
+ this.rayFWDAxis.set(0, 0, 1).applyQuaternion(this.worldQuat);
690
+ this.rayLeftAxis.crossVectors(this.rayUpAxis, this.rayFWDAxis).normalize();
691
+
692
+ // Ray origin velocity = linvel + angvel x r.
693
+ this.distFromRayOriginToVehicle.copy(this.rayOrigin).sub(this.vehiclePos);
694
+ this.angvelToLinvel.crossVectors(
695
+ this.vehicleAngVel,
696
+ this.distFromRayOriginToVehicle
697
+ );
698
+ this.rayOriginVelocity.copy(this.vehicleLinVel).add(this.angvelToLinvel);
699
+ }
700
+
701
+ private handleUserInput(): void {
702
+ const currDriveConfig = this.driveWheelConfig;
703
+ const currSteerConfig = this.steerWheelConfig;
704
+
705
+ // Drive torque from demand, torque split, gear ratio, reverse scaling and
706
+ // the engine torque curve over |wheelAngVel|/maxAngVel. NOTE: intentionally
707
+ // NOT zeroed when the guard fails (faithful to upstream).
708
+ if (this.driveWheel && currDriveConfig && currDriveConfig.maxDriveTorque !== 0) {
709
+ const maxAngVel =
710
+ currDriveConfig.maxWheelAngVel *
711
+ (this.driveDemand < 0 ? currDriveConfig.reverseRPMScale : 1);
712
+ const angvelRatio =
713
+ maxAngVel > 0 ? Math.abs(this._wheelAngVel) / maxAngVel : 1;
714
+ this._driveTorque =
715
+ this.driveDemand *
716
+ currDriveConfig.maxDriveTorque *
717
+ currDriveConfig.driveRatio *
718
+ (this.driveDemand < 0 ? currDriveConfig.reverseTorqueScale : 1) *
719
+ evaluateCurveLUT(angvelRatio, currDriveConfig.engineTorqueCurve) *
720
+ (this.driveInvert ? -1 : 1);
721
+ }
722
+
723
+ // Speed-sensitive steering: normalized by the CHASSIS forward speed over
724
+ // the car's theoretical top speed (maxWheelAngVel * r), not the wheel spin.
725
+ if (this.steerWheel && currSteerConfig) {
726
+ const steerMaxWheelAngVel = currSteerConfig.maxWheelAngVel;
727
+ const speedRatio =
728
+ steerMaxWheelAngVel > 0
729
+ ? clamp(
730
+ this.vehicleLinVel.dot(this.vehicleZAxis) /
731
+ (steerMaxWheelAngVel * this.rayShapeR),
732
+ 0,
733
+ 1
734
+ )
735
+ : 0;
736
+ this.steerTarget =
737
+ this.steerDemand *
738
+ currSteerConfig.maxSteerAngle *
739
+ evaluateCurveLUT(speedRatio, currSteerConfig.steerAngleCurve) *
740
+ (this.steerInvert ? -1 : 1);
741
+ }
742
+
743
+ // Brake: simply max torque scaled by demand.
744
+ if (this.brakeWheel) {
745
+ this._brakeTorque = this.brakeDemand * this.maxBrakeTorque;
746
+ }
747
+ }
748
+
749
+ private steeringWheel(): void {
750
+ const angleDiff = this.steerTarget - this._steerAngle;
751
+ const maxIncrement =
752
+ (this.steerWheelConfig?.steerRate ?? 0) * this.vehicle.world.timestep;
753
+ this.steerIncrement =
754
+ Math.sign(angleDiff) * Math.min(Math.abs(angleDiff), maxIncrement);
755
+
756
+ // Group rotation happens NEXT frame in updateShapeCastDir().
757
+ this._steerAngle += this.steerIncrement;
758
+ }
759
+
760
+ private floatVehicle(body: RigidBody): void {
761
+ const world = this.vehicle.world;
762
+
763
+ // Cast the wheel detection shape/ray.
764
+ if (this.groundDetection === "rayCast") {
765
+ this._rayHit = world.castRayAndGetNormal(
766
+ this.rayCastRay,
767
+ this.rayLength + this.rayShapeR,
768
+ false,
769
+ RAPIER.QueryFilterFlags.EXCLUDE_SENSORS,
770
+ undefined,
771
+ undefined,
772
+ body,
773
+ this.rayFilter
774
+ );
775
+ } else {
776
+ this._shapeRayHit = world.castShape(
777
+ this.rayOrigin,
778
+ // rotZ90 applied in LOCAL space aligns the cylinder's Y principal
779
+ // axis with the wheel's local X axle (multiply, NOT premultiply).
780
+ this.rayRotation.copy(this.worldQuat).multiply(this.rotZ90),
781
+ this.rayDirection,
782
+ this.rayShape,
783
+ 0, // targetDistance
784
+ this.rayLength, // maxToi
785
+ false, // stopAtPenetration
786
+ RAPIER.QueryFilterFlags.EXCLUDE_SENSORS,
787
+ undefined,
788
+ undefined,
789
+ body,
790
+ this.rayFilter
791
+ );
792
+ }
793
+
794
+ // Retrieve ray hit collider and distance (rayCast hit uses camelCase
795
+ // `timeOfImpact`, shapecast hit uses snake_case `time_of_impact`).
796
+ const hitCollider =
797
+ this.groundDetection === "rayCast"
798
+ ? this._rayHit?.collider
799
+ : this._shapeRayHit?.collider;
800
+ const hitDistance =
801
+ this.groundDetection === "rayCast"
802
+ ? this._rayHit?.timeOfImpact
803
+ : this._shapeRayHit?.time_of_impact;
804
+
805
+ if (hitCollider && hitDistance != null) {
806
+ // The ray starts at the axle, the shapecast at the rim — hence the -rayShapeR.
807
+ this._suspensionToi =
808
+ this.groundDetection === "rayCast"
809
+ ? Math.max(0, hitDistance - this.rayShapeR)
810
+ : hitDistance;
811
+ this._rayHitBody = hitCollider.parent();
812
+ // Raw ray hit point.
813
+ if (this.groundDetection === "rayCast") {
814
+ this.targetRayHitPoint
815
+ .copy(this.rayOrigin)
816
+ .addScaledVector(this.rayDirection, hitDistance);
817
+ } else {
818
+ this.targetRayHitPoint.copy(this._shapeRayHit!.witness1);
819
+ }
820
+ // Hit normal.
821
+ this.rayHitPointNormal
822
+ .copy(
823
+ this.groundDetection === "rayCast"
824
+ ? this._rayHit!.normal
825
+ : this._shapeRayHit!.normal1
826
+ )
827
+ .normalize();
828
+ // Shape center at suspension hit distance.
829
+ this.rayShapeCenter
830
+ .copy(this.rayOrigin)
831
+ .addScaledVector(this.rayDirection, this._suspensionToi);
832
+ // Stable center-section hit point + side-contact blending (shapecast only).
833
+ let supportOffset = 0;
834
+ if (this.groundDetection === "rayCast") {
835
+ this.stableRayHitPoint.copy(this.targetRayHitPoint);
836
+ } else {
837
+ // Project the raw witness back to the wheel center section.
838
+ const rawOffset = clamp(
839
+ this.rayHitPointOffset
840
+ .copy(this.targetRayHitPoint)
841
+ .sub(this.rayShapeCenter)
842
+ .dot(this.rayLeftAxis),
843
+ -this.rayShapeH,
844
+ this.rayShapeH
845
+ );
846
+ this.stableRayHitPoint
847
+ .copy(this.targetRayHitPoint)
848
+ .addScaledVector(this.rayLeftAxis, -rawOffset);
849
+
850
+ // Blend side support only when the normal shows side contact.
851
+ const normalSide = this.rayHitPointNormal.dot(this.rayLeftAxis);
852
+ const normalFwd = this.rayHitPointNormal.dot(this.rayFWDAxis);
853
+ const sideWeight = clamp(
854
+ Math.abs(normalSide) /
855
+ Math.sqrt(Math.max(1 - normalFwd * normalFwd, 1e-6)),
856
+ 0,
857
+ 1
858
+ );
859
+ supportOffset = -Math.abs(rawOffset) * Math.sign(normalSide) * sideWeight;
860
+ }
861
+ // Final friction point and support point (two DIFFERENT points — mixing
862
+ // them up reintroduces contact-patch jacking while steering).
863
+ this.rayHitPointPosition
864
+ .copy(this.stableRayHitPoint)
865
+ .addScaledVector(this.rayLeftAxis, supportOffset);
866
+ this.supportPoint
867
+ .copy(this.rayShapeCenter)
868
+ .addScaledVector(this.rayLeftAxis, supportOffset);
869
+ // Ground friction at contact.
870
+ if (this._rayHitFriction !== hitCollider.friction())
871
+ this._rayHitFriction = hitCollider.friction() ?? 0;
872
+ // Spring + damping. NOTE: damping projects LAST frame's relative contact
873
+ // velocity onto rayUpAxis while the impulse points along the hit normal —
874
+ // the mixed frames are intentional (part of the ride feel).
875
+ const springForce =
876
+ this.springK * Math.max(0, this.rayLength - this._suspensionToi);
877
+ const dampingForce =
878
+ this.dampingC * this.rayHitPointVelocity.dot(this.rayUpAxis);
879
+ this.floatingImpulse
880
+ .copy(this.rayHitPointNormal)
881
+ .multiplyScalar(springForce - dampingForce)
882
+ .multiplyScalar(world.timestep);
883
+ } else {
884
+ // Reset contact state when no hit. Smoothed friction impulses are NOT
885
+ // reset — and because the vehicle gates on the PUBLISHED (one-frame-
886
+ // stale) rayHit, the stale lng/lat impulses ARE applied once more on
887
+ // the contact-loss step (faithful upstream behavior).
888
+ this._rayHitBody = null;
889
+ this._suspensionToi = 0;
890
+ this._rayHitFriction = 0;
891
+ this.floatingImpulse.set(0, 0, 0);
892
+ }
893
+ }
894
+
895
+ private isOnMovingObjectDetect(body: RigidBody): void {
896
+ const hitBody = this._rayHitBody;
897
+ // Dynamic (0) and position-kinematic (2) bodies count as platforms.
898
+ if (
899
+ this.followPlatform &&
900
+ hitBody &&
901
+ (hitBody.bodyType() === RAPIER.RigidBodyType.Dynamic ||
902
+ hitBody.bodyType() === RAPIER.RigidBodyType.KinematicPositionBased)
903
+ ) {
904
+ this._isOnMovingObject = true;
905
+
906
+ // Mass-ratio falloff (dynamic only; kinematic platforms pin ratio at 1).
907
+ if (hitBody.bodyType() === RAPIER.RigidBodyType.Dynamic) {
908
+ const ratio = clamp(hitBody.mass() / Math.max(body.mass(), 1e-6), 0, 1);
909
+ this.massRatio = evaluateCurveLUT(ratio, this.massRatioFallOffCurve);
910
+ } else {
911
+ this.massRatio = 1;
912
+ }
913
+
914
+ // Standing-point velocity = linvel + angvel x r, scaled by mass ratio.
915
+ this.movingObjectPosition.copy(hitBody.translation());
916
+ this.distanceFromOriginToObjectPoint
917
+ .copy(this.rayOrigin)
918
+ .sub(this.movingObjectPosition);
919
+ this.movingObjectLinearVelocity.copy(hitBody.linvel());
920
+ this.movingObjectAngularVelocity.copy(hitBody.angvel());
921
+ this.movingObjectAngvelToLinvel.crossVectors(
922
+ this.movingObjectAngularVelocity,
923
+ this.distanceFromOriginToObjectPoint
924
+ );
925
+ this.movingObjectVelocity
926
+ .copy(this.movingObjectLinearVelocity)
927
+ .add(this.movingObjectAngvelToLinvel)
928
+ .multiplyScalar(this.massRatio);
929
+ this.movingObjectVelocityOnPlane
930
+ .copy(this.movingObjectVelocity)
931
+ .projectOnPlane(this.rayHitPointNormal);
932
+ } else {
933
+ this._isOnMovingObject = false;
934
+ this.movingObjectVelocity.set(0, 0, 0);
935
+ this.movingObjectVelocityOnPlane.set(0, 0, 0);
936
+ this.massRatio = 1;
937
+ }
938
+ }
939
+
940
+ private applyMassOnStandCollider(): void {
941
+ const hitBody = this._rayHitBody;
942
+ // Counter impulses go only to dynamic bodies; wake flag `true` when
943
+ // poking OTHER bodies. Uses PREVIOUS frame's wheelSupportForceMag.
944
+ if (
945
+ !hitBody ||
946
+ hitBody.bodyType() !== RAPIER.RigidBodyType.Dynamic ||
947
+ !this.applyCounterMass
948
+ )
949
+ return;
950
+ this.wheelSupportImpulse
951
+ .copy(this.rayHitPointNormal)
952
+ .multiplyScalar(
953
+ -1 * this.wheelSupportForceMag * this.vehicle.world.timestep * this.massRatio
954
+ );
955
+ if (this.wheelSupportForceMag > 0)
956
+ hitBody.applyImpulseAtPoint(
957
+ this.wheelSupportImpulse,
958
+ this.rayHitPointPosition,
959
+ true
960
+ );
961
+ }
962
+
963
+ // Upstream method name is typo'd `applyFricitonOnStandCollider`.
964
+ private applyFrictionOnStandCollider(): void {
965
+ const hitBody = this._rayHitBody;
966
+ if (
967
+ !hitBody ||
968
+ hitBody.bodyType() !== RAPIER.RigidBodyType.Dynamic ||
969
+ !this.applyCounterFriction
970
+ )
971
+ return;
972
+ // PREVIOUS frame's friction impulses.
973
+ this.wheelFrictionImpulse
974
+ .addVectors(this.lngFrictionImp, this.latFrictionImp)
975
+ .multiplyScalar(-1 * this.massRatio);
976
+ if (this.wheelFrictionImpulse.lengthSq() > 1e-4)
977
+ hitBody.applyImpulseAtPoint(
978
+ this.wheelFrictionImpulse,
979
+ this.rayHitPointPosition,
980
+ true
981
+ );
982
+ }
983
+
984
+ private computeRelativeVelocity(): void {
985
+ this.rayHitPointVelocity.copy(this.rayOriginVelocity);
986
+ this.rayHitPointVelOnPlane
987
+ .copy(this.rayHitPointVelocity)
988
+ .projectOnPlane(this.rayHitPointNormal);
989
+ if (this._isOnMovingObject && this.followPlatform) {
990
+ this.rayHitPointVelocity.sub(this.movingObjectVelocity);
991
+ this.rayHitPointVelOnPlane.sub(this.movingObjectVelocityOnPlane);
992
+ }
993
+ }
994
+
995
+ private computeContactFriction(): void {
996
+ if (this._rayHitBody)
997
+ this.frictionCoef = Math.max(
998
+ (this._rayHitFriction + this.tireGripFactor) * 0.5,
999
+ 0
1000
+ );
1001
+ else this.frictionCoef = 0;
1002
+ }
1003
+
1004
+ private computeWheelFrictionImpulse(gravityMag: number): void {
1005
+ const timestep = this.vehicle.world.timestep;
1006
+
1007
+ // Airborne: keep rolling resistance / effective inertia sane, zero slip.
1008
+ // (Friction impulses keep stale values — the stale-rayHit gate in the
1009
+ // vehicle applies them exactly once more on the contact-loss step, as
1010
+ // upstream does.)
1011
+ if (!this._rayHitBody) {
1012
+ this.wheelSupportForceMag = this.wheelMass * gravityMag;
1013
+ this.effectiveInertia =
1014
+ this.wheelInertia +
1015
+ (this.wheelSupportForceMag / gravityMag) * this.rayShapeR * this.rayShapeR;
1016
+ this._lngSlipRatio = 0;
1017
+ this._latSlipRatio = 0;
1018
+ this._slipStrength = 0;
1019
+ return;
1020
+ }
1021
+
1022
+ // Wheel support force from the floating impulse.
1023
+ const floatingImpMag = Math.max(
1024
+ this.floatingImpulse.dot(this.rayHitPointNormal),
1025
+ 0
1026
+ );
1027
+ this.wheelSupportForceMag = floatingImpMag / timestep;
1028
+
1029
+ // effectiveInertia = 0.5*m*r^2 + (load/g)*r^2.
1030
+ this.effectiveInertia =
1031
+ this.wheelInertia +
1032
+ (this.wheelSupportForceMag / gravityMag) * this.rayShapeR * this.rayShapeR;
1033
+
1034
+ // Longitudinal and lateral axes on the contact plane.
1035
+ this._lngAxis
1036
+ .copy(this.rayFWDAxis)
1037
+ .projectOnPlane(this.rayHitPointNormal)
1038
+ .normalize();
1039
+ this._latAxis
1040
+ .copy(this.rayLeftAxis)
1041
+ .projectOnPlane(this.rayHitPointNormal)
1042
+ .normalize();
1043
+ // Contact point velocities.
1044
+ const lngContactVel = this.rayHitPointVelocity.dot(this._lngAxis);
1045
+ const latContactVel = this.rayHitPointVelocity.dot(this._latAxis);
1046
+ const lngContactVelAbs = Math.abs(lngContactVel);
1047
+ const latContactVelAbs = Math.abs(latContactVel);
1048
+ // Wheel surface speed and slip.
1049
+ const wheelLinVel = this._wheelAngVel * this.rayShapeR;
1050
+ const slipDiff = wheelLinVel - lngContactVel;
1051
+ const slipDiffAbs = Math.abs(slipDiff);
1052
+
1053
+ // Slip ratios and slip-curve values.
1054
+ this._lngSlipRatio = slipDiffAbs / Math.max(lngContactVelAbs, 1e-4);
1055
+ this._latSlipRatio =
1056
+ latContactVelAbs === 0 && lngContactVelAbs === 0
1057
+ ? 0
1058
+ : clamp(Math.atan2(latContactVelAbs, lngContactVelAbs) / (Math.PI / 2), 0, 1);
1059
+ this._slipStrength = Math.max(this._lngSlipRatio, this._latSlipRatio);
1060
+ const lngSlipValue = evaluateCurveLUT(this._lngSlipRatio, this.lngSlipRatioCurve);
1061
+ const latSlipValue = evaluateCurveLUT(this._latSlipRatio, this.latSlipRatioCurve);
1062
+
1063
+ // Static friction blend at low speed.
1064
+ const lngStaticWeight = clamp(
1065
+ 1.0 - Math.max(slipDiffAbs, lngContactVelAbs) / this.lowVelThreshold,
1066
+ 0,
1067
+ 1
1068
+ );
1069
+ const finalLngSlipValue = remap(lngStaticWeight, 0, 1, lngSlipValue, 1);
1070
+ const latStaticWeight = clamp(
1071
+ 1.0 - Math.max(latContactVelAbs, lngContactVelAbs) / this.lowVelThreshold,
1072
+ 0,
1073
+ 1
1074
+ );
1075
+ const finalLatSlipValue = remap(latStaticWeight, 0, 1, latSlipValue, 1);
1076
+
1077
+ // Friction ellipse: max allowed impulse per axis.
1078
+ const maxLngImp =
1079
+ this.wheelSupportForceMag *
1080
+ finalLngSlipValue *
1081
+ this.frictionCoef *
1082
+ timestep *
1083
+ this.lngFrictionEllipseScale;
1084
+ const maxLatImp =
1085
+ this.wheelSupportForceMag *
1086
+ finalLatSlipValue *
1087
+ this.frictionCoef *
1088
+ timestep *
1089
+ this.latFrictionEllipseScale;
1090
+
1091
+ // Desired impulses from slip and load.
1092
+ this.desiredLngImpulse =
1093
+ (slipDiff * this.effectiveInertia) / (this.rayShapeR * this.rayShapeR);
1094
+ this.desiredLatImpulse = latContactVel * (this.wheelSupportForceMag / gravityMag);
1095
+
1096
+ // Clamp within the ellipse. (Degenerate 0/0 -> NaN -> no clamp; Infinity ->
1097
+ // zeroed impulses. JS semantics make this safe — do NOT add guards.)
1098
+ const ellipseUsage = Math.sqrt(
1099
+ (this.desiredLngImpulse / maxLngImp) * (this.desiredLngImpulse / maxLngImp) +
1100
+ (this.desiredLatImpulse / maxLatImp) * (this.desiredLatImpulse / maxLatImp)
1101
+ );
1102
+ if (ellipseUsage > 1.0) {
1103
+ this.desiredLngImpulse /= ellipseUsage;
1104
+ this.desiredLatImpulse /= ellipseUsage;
1105
+ }
1106
+
1107
+ // Tire relaxation: keep low-speed tires responsive while still allowing
1108
+ // speed-based relaxation.
1109
+ const lngCoeff = clamp(
1110
+ Math.max(
1111
+ this.minLngRelaxCoeff,
1112
+ (lngContactVelAbs / Math.max(this.relaxLngRate, 1e-6)) * timestep
1113
+ ),
1114
+ 0,
1115
+ 1
1116
+ );
1117
+ const latCoeff = clamp(
1118
+ Math.max(
1119
+ this.minLatRelaxCoeff,
1120
+ (latContactVelAbs / Math.max(this.relaxLatRate, 1e-6)) * timestep
1121
+ ),
1122
+ 0,
1123
+ 1
1124
+ );
1125
+ this.smoothedLngImpulse +=
1126
+ (this.desiredLngImpulse - this.smoothedLngImpulse) * lngCoeff;
1127
+ this.smoothedLatImpulse +=
1128
+ (this.desiredLatImpulse - this.smoothedLatImpulse) * latCoeff;
1129
+
1130
+ // Final impulses. The lateral one OPPOSES lateral contact velocity via the
1131
+ // explicit minus; the longitudinal has no minus because slipDiff already
1132
+ // encodes direction.
1133
+ this.lngFrictionImp.copy(this._lngAxis).multiplyScalar(this.smoothedLngImpulse);
1134
+ this.latFrictionImp.copy(this._latAxis).multiplyScalar(-this.smoothedLatImpulse);
1135
+ }
1136
+
1137
+ private solveWheelRotation(): void {
1138
+ const timestep = this.vehicle.world.timestep;
1139
+
1140
+ // Define wheel state (no engine spin in air).
1141
+ const isDriving =
1142
+ this.driveWheel && Math.abs(this._driveTorque) > 0 && this._rayHitBody;
1143
+ const isBraking =
1144
+ this.brakeWheel && Math.abs(this._brakeTorque) > 0 && this._rayHitBody;
1145
+ const isFreeRolling = !isDriving && !isBraking;
1146
+
1147
+ // Friction reaction torque — IMPULSE-based, NO timestep factor: the
1148
+ // impulse divided by effectiveInertia/r (with the extra r) is a Δ(rad/s).
1149
+ if (this._rayHitBody)
1150
+ this._wheelAngVel -=
1151
+ (this.lngFrictionImp.dot(this._lngAxis) * this.rayShapeR) /
1152
+ this.effectiveInertia;
1153
+
1154
+ // Rolling resistance: on ground when free-rolling, or in air while spinning.
1155
+ if (
1156
+ (this._rayHitBody && isFreeRolling) ||
1157
+ (!this._rayHitBody && this._wheelAngVel !== 0)
1158
+ ) {
1159
+ const rollingResistTorque =
1160
+ -this.rollingResistanceCoef * this.wheelSupportForceMag * this._wheelAngVel;
1161
+ this._wheelAngVel +=
1162
+ (rollingResistTorque / this.effectiveInertia) * timestep;
1163
+ }
1164
+
1165
+ // Engine torque.
1166
+ if (isDriving && !isBraking)
1167
+ this._wheelAngVel += (this._driveTorque / this.effectiveInertia) * timestep;
1168
+
1169
+ // Brake torque — clamped so braking can never reverse the spin in one step.
1170
+ if (isBraking) {
1171
+ const appliedBrakeTorque = this._brakeTorque * -Math.sign(this._wheelAngVel);
1172
+ this._wheelAngVel +=
1173
+ Math.min(
1174
+ Math.abs(this._wheelAngVel),
1175
+ Math.abs(appliedBrakeTorque / this.effectiveInertia) * timestep
1176
+ ) * -Math.sign(this._wheelAngVel);
1177
+ }
1178
+ }
1179
+
1180
+ private updateWheelModel(): void {
1181
+ if (!this.updateModel) return;
1182
+ const timestep = this.vehicle.world.timestep;
1183
+ // Suspension bounce.
1184
+ const hasContact =
1185
+ this.groundDetection === "rayCast" ? this._rayHit : this._shapeRayHit;
1186
+ const offsetY = hasContact
1187
+ ? -(this.rayLength + this.rayShapeR) +
1188
+ this.wheelModelRadius +
1189
+ (this.rayLength - this._suspensionToi)
1190
+ : -(this.rayLength + this.rayShapeR) + this.wheelModelRadius;
1191
+ this.suspensionGroup.position.y = THREE.MathUtils.lerp(
1192
+ this.suspensionGroup.position.y,
1193
+ offsetY,
1194
+ 1 - Math.exp(-this.wheelModelLerpPosRate * timestep)
1195
+ );
1196
+ // Wheel spin.
1197
+ this.modelObject.rotation.x +=
1198
+ this._wheelAngVel * timestep * (this.wheelModelReverseRotation ? -1 : 1);
1199
+ }
1200
+ }