@genex-ai/cli-demo 0.12.1 → 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 (40) 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-physics-rapier/SKILL.md +128 -0
  33. package/templates/skills/genex-threejs-physics-rapier/references/colliders-from-assets.md +202 -0
  34. package/templates/skills/genex-threejs-physics-rapier/references/physics-setup.md +207 -0
  35. package/templates/skills/genex-threejs-skill-router/SKILL.md +3 -0
  36. package/templates/skills/genex-threejs-skill-router/references/routing-map.md +15 -7
  37. package/templates/skills/genex-threejs-vehicle-controllers/SKILL.md +110 -0
  38. package/templates/skills/genex-threejs-vehicle-controllers/references/car.md +162 -0
  39. package/templates/skills/genex-threejs-vehicle-controllers/references/drone.md +150 -0
  40. package/templates/skills/genex-threejs-vehicle-controllers/references/enter-exit.md +199 -0
@@ -0,0 +1,456 @@
1
+ // SPDX-FileCopyrightText: 2023-2026 Erdong Chen
2
+ // SPDX-License-Identifier: MIT
3
+ // Vanilla-TS port of the ecctrl controller (collider glue: re-implements the
4
+ // mesh -> collider auto-generation and the explicit collider helpers that the
5
+ // upstream React components received from their React physics wrapper,
6
+ // @react-three/rapier v2.2.0). One deliberate substitution: `mergeVertices`
7
+ // comes from `three/addons/utils/BufferGeometryUtils.js` (ships inside the
8
+ // `three` package) instead of the upstream wrapper's `three-stdlib` — same
9
+ // function, no extra dependency.
10
+ // All helpers assume `PhysicsWorld.create()` (i.e. RAPIER.init) has already
11
+ // resolved — constructing a ColliderDesc before WASM init throws.
12
+
13
+ import * as THREE from "three";
14
+ import RAPIER from "@dimforge/rapier3d-compat";
15
+ import { mergeVertices } from "three/addons/utils/BufferGeometryUtils.js";
16
+
17
+ /**
18
+ * Auto-collider shape for {@link collidersFromObject}.
19
+ * - `"cuboid"`: bounding box per mesh — cheapest, fine for crates/walls.
20
+ * - `"ball"`: bounding sphere per mesh.
21
+ * - `"hull"`: convex hull per mesh — good default for `genex model` props
22
+ * (tight fit, still convex/fast).
23
+ * - `"trimesh"`: exact triangle mesh — for static level geometry only; never
24
+ * put a trimesh on a fast dynamic body (tunneling).
25
+ */
26
+ export type AutoColliderShape = "cuboid" | "ball" | "hull" | "trimesh";
27
+
28
+ /** Options applied to every created collider. */
29
+ export interface ColliderOptions {
30
+ /**
31
+ * Friction coefficient. NEGATIVE values are legal and load-bearing: the
32
+ * character capsule ships with friction -0.5 because its traction is
33
+ * synthetic (the controller applies its own grip impulses). Do not clamp.
34
+ */
35
+ friction?: number;
36
+ /** Bounciness, 0 (dead) to 1 (superball). */
37
+ restitution?: number;
38
+ /** Mass density (kg/m^3). Mutually exclusive with `mass`. */
39
+ density?: number;
40
+ /**
41
+ * Explicit mass in kg. Mutually exclusive with `density`. Use `mass: 0` on
42
+ * sensor colliders so they add no mass to the vehicle.
43
+ */
44
+ mass?: number;
45
+ /** Sensor colliders detect overlaps but produce no contact forces. */
46
+ sensor?: boolean;
47
+ /** Rapier collision-groups bitmask. */
48
+ collisionGroups?: number;
49
+ /** Rapier solver-groups bitmask. */
50
+ solverGroups?: number;
51
+ /** Extra contact skin thickness (helps jitter at the cost of visual gap). */
52
+ contactSkin?: number;
53
+ /** `RAPIER.ActiveCollisionTypes` bitmask. */
54
+ activeCollisionTypes?: number;
55
+ frictionCombineRule?: RAPIER.CoefficientCombineRule;
56
+ restitutionCombineRule?: RAPIER.CoefficientCombineRule;
57
+ /** Translation of the collider relative to its parent body. */
58
+ position?: [number, number, number];
59
+ /** Rotation (euler XYZ, radians) relative to its parent body. */
60
+ rotation?: [number, number, number];
61
+ }
62
+
63
+ // Exact upstream error string (we support density/mass; massProperties is not ported).
64
+ const massPropertiesConflictError =
65
+ "Please pick ONLY ONE of the `density`, `mass` and `massProperties` options.";
66
+
67
+ /**
68
+ * Auto-generate one collider per visible mesh under `object3d` — the vanilla
69
+ * equivalent of the upstream `<RigidBody colliders="...">` prop. Works on GLB
70
+ * scenes, including `genex model` output.
71
+ *
72
+ * `object3d` must be the SAME object registered to `body` (their frames must
73
+ * coincide); call this right after creating the body, before the first step.
74
+ * Pass `includeInvisible: true` to also process hidden meshes.
75
+ */
76
+ export function collidersFromObject(
77
+ world: RAPIER.World,
78
+ body: RAPIER.RigidBody,
79
+ object3d: THREE.Object3D,
80
+ shape: AutoColliderShape = "cuboid",
81
+ options: ColliderOptions & { includeInvisible?: boolean } = {}
82
+ ): RAPIER.Collider[] {
83
+ const colliders: RAPIER.Collider[] = [];
84
+ object3d.updateWorldMatrix(true, false);
85
+ const invertedRootMatrix = object3d.matrixWorld.clone().invert();
86
+ const rootWorldScale = object3d.getWorldScale(new THREE.Vector3());
87
+
88
+ const colliderFromChild = (child: THREE.Object3D) => {
89
+ if (!("isMesh" in child)) return;
90
+ const mesh = child as THREE.Mesh;
91
+
92
+ const worldScale = mesh.getWorldScale(new THREE.Vector3());
93
+ mesh.updateWorldMatrix(true, false);
94
+ const relPosition = new THREE.Vector3();
95
+ const relRotation = new THREE.Quaternion();
96
+ const relScale = new THREE.Vector3();
97
+ new THREE.Matrix4()
98
+ .copy(mesh.matrixWorld)
99
+ .premultiply(invertedRootMatrix)
100
+ .decompose(relPosition, relRotation, relScale);
101
+
102
+ // Collider ARGS (half-extents/radius/vertices) are scaled by
103
+ // childWorldScale * rootWorldScale — exactly upstream, where the
104
+ // collider's object3D carries `scale = childWorldScale` (the child's
105
+ // ABSOLUTE world scale, root scale included) and sits under the
106
+ // registered root, so its getWorldScale() = rootScale * childWorldScale.
107
+ // Upstream quirk faithfully replicated, not fixed: the root scale is
108
+ // double-counted in the collider size, so with a root scaled 2x the
109
+ // colliders come out 2x larger than the rendered meshes.
110
+ const argsScale = worldScale.clone().multiply(rootWorldScale);
111
+ const { desc, offset } = descFromGeometry(mesh, shape, argsScale);
112
+ // Placement wrt the body: (relative pose plus the geometry's own offset
113
+ // scaled by the mesh's world scale), the whole sum then scaled by the
114
+ // ROOT object's world scale — exactly upstream, where the auto-collider
115
+ // props store `relPosition + offset * childWorldScale` and the collider
116
+ // setup then multiplies the position by the registered root's world
117
+ // scale. Upstream quirk faithfully replicated, not fixed: the offset
118
+ // term ends up scaled twice (childWorldScale already contains the root
119
+ // scale), same policy as the ball `radius * scale.x` quirk below.
120
+ desc.setTranslation(
121
+ (relPosition.x + offset.x * worldScale.x) * rootWorldScale.x,
122
+ (relPosition.y + offset.y * worldScale.y) * rootWorldScale.y,
123
+ (relPosition.z + offset.z * worldScale.z) * rootWorldScale.z
124
+ );
125
+ desc.setRotation({
126
+ x: relRotation.x,
127
+ y: relRotation.y,
128
+ z: relRotation.z,
129
+ w: relRotation.w,
130
+ });
131
+
132
+ const collider = world.createCollider(desc, body);
133
+ applyColliderOptions(collider, options);
134
+ colliders.push(collider);
135
+ };
136
+
137
+ if (options.includeInvisible) object3d.traverse(colliderFromChild);
138
+ else object3d.traverseVisible(colliderFromChild);
139
+
140
+ return colliders;
141
+ }
142
+
143
+ /**
144
+ * Attach a box collider. `halfExtents` are HALF sizes — a `[1, 0.4, 2.4]`
145
+ * collider is 2 x 0.8 x 4.8 units. Do not halve twice.
146
+ */
147
+ export function cuboidCollider(
148
+ world: RAPIER.World,
149
+ body: RAPIER.RigidBody,
150
+ halfExtents: [number, number, number],
151
+ options: ColliderOptions = {}
152
+ ): RAPIER.Collider {
153
+ return createFromDesc(
154
+ world,
155
+ body,
156
+ RAPIER.ColliderDesc.cuboid(halfExtents[0], halfExtents[1], halfExtents[2]),
157
+ options
158
+ );
159
+ }
160
+
161
+ /** Attach a sphere collider. */
162
+ export function ballCollider(
163
+ world: RAPIER.World,
164
+ body: RAPIER.RigidBody,
165
+ radius: number,
166
+ options: ColliderOptions = {}
167
+ ): RAPIER.Collider {
168
+ return createFromDesc(world, body, RAPIER.ColliderDesc.ball(radius), options);
169
+ }
170
+
171
+ /**
172
+ * Attach a capsule collider. Rapier arg order is `(halfHeight, radius)` — the
173
+ * REVERSE of `THREE.CapsuleGeometry(radius, length)` — and `halfHeight`
174
+ * covers the CYLINDRICAL section only: total height is
175
+ * `2 * halfHeight + 2 * radius` (so `[0.3, 0.3]` is 1.2 units tall).
176
+ */
177
+ export function capsuleCollider(
178
+ world: RAPIER.World,
179
+ body: RAPIER.RigidBody,
180
+ halfHeight: number,
181
+ radius: number,
182
+ options: ColliderOptions = {}
183
+ ): RAPIER.Collider {
184
+ return createFromDesc(
185
+ world,
186
+ body,
187
+ RAPIER.ColliderDesc.capsule(halfHeight, radius),
188
+ options
189
+ );
190
+ }
191
+
192
+ /**
193
+ * Attach a cylinder collider (arg order `(halfHeight, radius)`, same caveat
194
+ * as {@link capsuleCollider}). The vehicle enter/exit sensors use this shape.
195
+ */
196
+ export function cylinderCollider(
197
+ world: RAPIER.World,
198
+ body: RAPIER.RigidBody,
199
+ halfHeight: number,
200
+ radius: number,
201
+ options: ColliderOptions = {}
202
+ ): RAPIER.Collider {
203
+ return createFromDesc(
204
+ world,
205
+ body,
206
+ RAPIER.ColliderDesc.cylinder(halfHeight, radius),
207
+ options
208
+ );
209
+ }
210
+
211
+ /**
212
+ * Attach an exact triangle-mesh collider built from `mesh` (transform
213
+ * relative to the body's registered object is baked in, world scale applied
214
+ * to the vertices). Static level geometry only — trimeshes are hollow and
215
+ * expensive to collide against for fast dynamic bodies.
216
+ */
217
+ export function trimeshColliderFromMesh(
218
+ world: RAPIER.World,
219
+ body: RAPIER.RigidBody,
220
+ mesh: THREE.Mesh,
221
+ options: ColliderOptions = {}
222
+ ): RAPIER.Collider {
223
+ return meshColliderFromMesh(world, body, mesh, "trimesh", options);
224
+ }
225
+
226
+ /**
227
+ * Attach a convex-hull collider built from `mesh` — the best default for
228
+ * dynamic props from `genex model` GLBs (tight fit, fast, solid).
229
+ */
230
+ export function convexHullColliderFromMesh(
231
+ world: RAPIER.World,
232
+ body: RAPIER.RigidBody,
233
+ mesh: THREE.Mesh,
234
+ options: ColliderOptions = {}
235
+ ): RAPIER.Collider {
236
+ return meshColliderFromMesh(world, body, mesh, "hull", options);
237
+ }
238
+
239
+ /**
240
+ * Apply mutable options to an existing collider (upstream option order).
241
+ * Call again manually if you tune values at runtime. `density` and `mass`
242
+ * are mutually exclusive — picking both throws, exactly like upstream.
243
+ */
244
+ export function applyColliderOptions(
245
+ collider: RAPIER.Collider,
246
+ options: ColliderOptions
247
+ ): void {
248
+ if (options.sensor !== undefined) collider.setSensor(options.sensor);
249
+ if (options.collisionGroups !== undefined)
250
+ collider.setCollisionGroups(options.collisionGroups);
251
+ if (options.solverGroups !== undefined)
252
+ collider.setSolverGroups(options.solverGroups);
253
+ if (options.friction !== undefined) collider.setFriction(options.friction);
254
+ if (options.frictionCombineRule !== undefined)
255
+ collider.setFrictionCombineRule(options.frictionCombineRule);
256
+ if (options.restitution !== undefined)
257
+ collider.setRestitution(options.restitution);
258
+ if (options.restitutionCombineRule !== undefined)
259
+ collider.setRestitutionCombineRule(options.restitutionCombineRule);
260
+ if (options.activeCollisionTypes !== undefined)
261
+ collider.setActiveCollisionTypes(options.activeCollisionTypes);
262
+ if (options.contactSkin !== undefined)
263
+ collider.setContactSkin(options.contactSkin);
264
+
265
+ // Mass LAST, and exclusively.
266
+ if (options.density !== undefined) {
267
+ if (options.mass !== undefined) {
268
+ throw new Error(massPropertiesConflictError);
269
+ }
270
+ collider.setDensity(options.density);
271
+ return;
272
+ }
273
+ if (options.mass !== undefined) {
274
+ collider.setMass(options.mass);
275
+ }
276
+ }
277
+
278
+ // ---- internals ----
279
+
280
+ /**
281
+ * Shared body of trimesh/hull mesh helpers: bakes the mesh's current world
282
+ * transform relative to the body's current pose (so the collider lands where
283
+ * the mesh renders), scales vertices by the mesh's world scale, then defers
284
+ * to {@link createFromDesc} (explicit `options.position`/`rotation` override
285
+ * the baked pose).
286
+ */
287
+ function meshColliderFromMesh(
288
+ world: RAPIER.World,
289
+ body: RAPIER.RigidBody,
290
+ mesh: THREE.Mesh,
291
+ shape: "trimesh" | "hull",
292
+ options: ColliderOptions
293
+ ): RAPIER.Collider {
294
+ mesh.updateWorldMatrix(true, false);
295
+ const worldScale = mesh.getWorldScale(new THREE.Vector3());
296
+
297
+ const t = body.translation();
298
+ const r = body.rotation();
299
+ const invertedBodyMatrix = new THREE.Matrix4()
300
+ .compose(
301
+ new THREE.Vector3(t.x, t.y, t.z),
302
+ new THREE.Quaternion(r.x, r.y, r.z, r.w),
303
+ new THREE.Vector3(1, 1, 1)
304
+ )
305
+ .invert();
306
+ const relPosition = new THREE.Vector3();
307
+ const relRotation = new THREE.Quaternion();
308
+ const relScale = new THREE.Vector3();
309
+ new THREE.Matrix4()
310
+ .copy(mesh.matrixWorld)
311
+ .premultiply(invertedBodyMatrix)
312
+ .decompose(relPosition, relRotation, relScale);
313
+
314
+ const { desc } = descFromGeometry(mesh, shape, worldScale);
315
+ desc.setTranslation(relPosition.x, relPosition.y, relPosition.z);
316
+ desc.setRotation({
317
+ x: relRotation.x,
318
+ y: relRotation.y,
319
+ z: relRotation.z,
320
+ w: relRotation.w,
321
+ });
322
+ return createFromDesc(world, body, desc, options);
323
+ }
324
+
325
+ function createFromDesc(
326
+ world: RAPIER.World,
327
+ body: RAPIER.RigidBody,
328
+ desc: RAPIER.ColliderDesc,
329
+ options: ColliderOptions
330
+ ): RAPIER.Collider {
331
+ if (options.position) {
332
+ desc.setTranslation(
333
+ options.position[0],
334
+ options.position[1],
335
+ options.position[2]
336
+ );
337
+ }
338
+ if (options.rotation) {
339
+ const quat = new THREE.Quaternion().setFromEuler(
340
+ new THREE.Euler(
341
+ options.rotation[0],
342
+ options.rotation[1],
343
+ options.rotation[2],
344
+ "XYZ"
345
+ )
346
+ );
347
+ desc.setRotation({ x: quat.x, y: quat.y, z: quat.z, w: quat.w });
348
+ }
349
+ const collider = world.createCollider(desc, body);
350
+ applyColliderOptions(collider, options);
351
+ return collider;
352
+ }
353
+
354
+ /**
355
+ * Build a ColliderDesc from a mesh's geometry for the given auto shape,
356
+ * mirroring the upstream args + scaling exactly (`scale` is whatever scale
357
+ * the caller wants baked into the args — the auto-collider path passes
358
+ * childWorldScale * rootWorldScale to replicate upstream's double-counted
359
+ * root scale; the explicit mesh helpers pass the mesh's world scale):
360
+ * - cuboid: bounding-box half extents, scaled per-axis; offset = box center.
361
+ * - ball: bounding-sphere radius scaled by `scale.x` ONLY (upstream quirk —
362
+ * non-uniformly scaled spheres are wrong upstream too; replicated, not
363
+ * fixed); offset = sphere center.
364
+ * - trimesh/hull: vertices scaled component-wise by `scale`; no offset.
365
+ */
366
+ function descFromGeometry(
367
+ mesh: THREE.Mesh,
368
+ shape: AutoColliderShape,
369
+ scale: THREE.Vector3
370
+ ): { desc: RAPIER.ColliderDesc; offset: THREE.Vector3 } {
371
+ const geometry = mesh.geometry;
372
+ switch (shape) {
373
+ case "cuboid": {
374
+ geometry.computeBoundingBox();
375
+ const boundingBox = geometry.boundingBox;
376
+ if (!boundingBox)
377
+ throw new Error(
378
+ `Could not compute a bounding box for mesh "${mesh.name}"`
379
+ );
380
+ const size = boundingBox.getSize(new THREE.Vector3());
381
+ return {
382
+ desc: RAPIER.ColliderDesc.cuboid(
383
+ (size.x / 2) * scale.x,
384
+ (size.y / 2) * scale.y,
385
+ (size.z / 2) * scale.z
386
+ ),
387
+ offset: boundingBox.getCenter(new THREE.Vector3()),
388
+ };
389
+ }
390
+ case "ball": {
391
+ geometry.computeBoundingSphere();
392
+ const boundingSphere = geometry.boundingSphere;
393
+ if (!boundingSphere)
394
+ throw new Error(
395
+ `Could not compute a bounding sphere for mesh "${mesh.name}"`
396
+ );
397
+ return {
398
+ desc: RAPIER.ColliderDesc.ball(boundingSphere.radius * scale.x),
399
+ offset: boundingSphere.center.clone(),
400
+ };
401
+ }
402
+ case "trimesh": {
403
+ // Non-indexed geometry is WELDED via mergeVertices (upstream behavior)
404
+ // rather than given a fabricated sequential index — welding removes
405
+ // duplicate vertices and matters for internal-edge behavior.
406
+ const clonedGeometry = geometry.index
407
+ ? geometry.clone()
408
+ : mergeVertices(geometry);
409
+ const index = clonedGeometry.index;
410
+ if (!index)
411
+ throw new Error(
412
+ `Could not build a triangle index for mesh "${mesh.name}"`
413
+ );
414
+ const desc = RAPIER.ColliderDesc.trimesh(
415
+ scaledPositions(clonedGeometry, scale),
416
+ new Uint32Array(index.array)
417
+ );
418
+ return { desc, offset: new THREE.Vector3() };
419
+ }
420
+ case "hull": {
421
+ const clonedGeometry = geometry.clone();
422
+ const desc = RAPIER.ColliderDesc.convexHull(
423
+ scaledPositions(clonedGeometry, scale)
424
+ );
425
+ if (!desc)
426
+ throw new Error(
427
+ `Failed to build a convex hull for mesh "${mesh.name}" ` +
428
+ "(degenerate or coplanar geometry?)"
429
+ );
430
+ return { desc, offset: new THREE.Vector3() };
431
+ }
432
+ }
433
+ }
434
+
435
+ /**
436
+ * Copy the position attribute into a fresh Float32Array, scaled
437
+ * component-wise by the mesh's world scale. Reads via getX/getY/getZ so
438
+ * interleaved buffer attributes cannot silently corrupt the vertex data
439
+ * (raw `.array` access on an interleaved attribute returns the whole
440
+ * interleaved buffer).
441
+ */
442
+ function scaledPositions(
443
+ geometry: THREE.BufferGeometry,
444
+ scale: THREE.Vector3
445
+ ): Float32Array {
446
+ const attribute = geometry.attributes.position;
447
+ if (!attribute)
448
+ throw new Error("Geometry has no position attribute to build a collider from");
449
+ const out = new Float32Array(attribute.count * 3);
450
+ for (let i = 0; i < attribute.count; i++) {
451
+ out[i * 3] = attribute.getX(i) * scale.x;
452
+ out[i * 3 + 1] = attribute.getY(i) * scale.y;
453
+ out[i * 3 + 2] = attribute.getZ(i) * scale.z;
454
+ }
455
+ return out;
456
+ }
@@ -0,0 +1,230 @@
1
+ // SPDX-FileCopyrightText: 2023-2026 Erdong Chen
2
+ // SPDX-License-Identifier: MIT
3
+ // Vanilla-TS port of the ecctrl controller (pure math helpers: value remapping,
4
+ // antipodal-safe vector slerp, and the weighted-Hermite curve LUT used by the
5
+ // tire slip curves, engine torque curve, steer-angle curve, and the character's
6
+ // platform mass-ratio falloff).
7
+
8
+ import * as THREE from "three";
9
+
10
+ /**
11
+ * Linearly remap `value` from the range [inMin, inMax] to [outMin, outMax].
12
+ *
13
+ * Deliberately does NOT clamp — out-of-range input extrapolates linearly.
14
+ * The tire static-friction blend relies on this pass-through behavior.
15
+ */
16
+ export const remap = (
17
+ value: number,
18
+ inMin: number,
19
+ inMax: number,
20
+ outMin: number,
21
+ outMax: number
22
+ ) => {
23
+ return ((value - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin;
24
+ };
25
+
26
+ /**
27
+ * Build a tunable falloff curve `x => a * exp(-((x + c) / d) ** t) + b`.
28
+ *
29
+ * Tuning hints: `a` sets the peak height, `b` the floor the curve settles to,
30
+ * `c` shifts the curve left/right, `d` stretches it wider, and `t` controls
31
+ * how sharply it drops (higher = steeper cliff).
32
+ */
33
+ export const dynamicCurve = (
34
+ a: number,
35
+ b: number,
36
+ c: number,
37
+ d: number,
38
+ t: number
39
+ ) => {
40
+ return (x: number) => a * Math.exp(-Math.pow((x + c) / d, t)) + b;
41
+ };
42
+
43
+ /**
44
+ * Create a spherical-lerp function for unit direction vectors that stays
45
+ * stable even when `start` and `end` are nearly opposite (the antipodal case
46
+ * picks a perpendicular rotation axis instead of collapsing to zero).
47
+ *
48
+ * Used for smooth gravity-direction changes on characters and vehicles.
49
+ *
50
+ * IMPORTANT: the returned function reuses ONE preallocated result vector —
51
+ * every call returns the same mutated `THREE.Vector3` instance. Callers must
52
+ * `.copy()` the result immediately. Create one factory instance per consumer
53
+ * (e.g. one class field per controller); do not share across controllers.
54
+ */
55
+ export const createSlerpVec3 = () => {
56
+ const startClone = new THREE.Vector3();
57
+ const relativeVec = new THREE.Vector3();
58
+ const resultVec3 = new THREE.Vector3();
59
+
60
+ return (
61
+ start: THREE.Vector3,
62
+ end: THREE.Vector3,
63
+ percent: number,
64
+ refAxis?: THREE.Vector3
65
+ ) => {
66
+ const dot = THREE.MathUtils.clamp(start.dot(end), -1, 1);
67
+
68
+ // When vectors are nearly opposite, find a stable perpendicular vector
69
+ if (Math.abs(dot + 1) < 0.001) {
70
+ // Choose a stable perpendicular axis
71
+ if (refAxis && Math.abs(refAxis.dot(start)) < 0.99) {
72
+ relativeVec.copy(refAxis).normalize();
73
+ } else {
74
+ if (Math.abs(start.y) > 0.99) {
75
+ relativeVec.set(1, 0, 0);
76
+ } else if (Math.abs(start.x) > 0.99) {
77
+ relativeVec.set(0, 1, 0);
78
+ } else {
79
+ relativeVec.set(0, 0, 1);
80
+ }
81
+ }
82
+ // Compute orthogonal vector
83
+ relativeVec.cross(start).normalize();
84
+ const theta = Math.PI * percent;
85
+ resultVec3
86
+ .copy(start)
87
+ .multiplyScalar(Math.cos(theta))
88
+ .addScaledVector(relativeVec, Math.sin(theta));
89
+ } else {
90
+ const theta = Math.acos(dot) * percent;
91
+ relativeVec
92
+ .copy(end)
93
+ .sub(startClone.copy(start).multiplyScalar(dot))
94
+ .normalize();
95
+ resultVec3
96
+ .copy(start)
97
+ .multiplyScalar(Math.cos(theta))
98
+ .addScaledVector(relativeVec, Math.sin(theta));
99
+ }
100
+
101
+ return resultVec3.normalize();
102
+ };
103
+ };
104
+
105
+ /**
106
+ * One control point of a weighted-Hermite curve.
107
+ *
108
+ * `r_in`/`r_out` are tangent ANGLES in radians (converted to slopes via
109
+ * `Math.tan`), not slopes. `w_in`/`w_out` blend each user tangent toward the
110
+ * segment's straight-line slope: weight 0 = straight line, weight 1 = full
111
+ * user tangent (default 1). Omitted tangents default to flat (slope 0).
112
+ */
113
+ export type CurvePoint = {
114
+ x: number;
115
+ y: number;
116
+ r_in?: number;
117
+ r_out?: number;
118
+ w_in?: number;
119
+ w_out?: number;
120
+ };
121
+
122
+ /** A baked curve: uniformly sampled lookup table over [xMin, xMax]. */
123
+ export type CurveLUT = {
124
+ lut: Float32Array;
125
+ xMin: number;
126
+ xMax: number;
127
+ samples: number;
128
+ };
129
+
130
+ /** Serializable curve definition: control points plus optional sample count. */
131
+ export type CurveData = {
132
+ points: CurvePoint[];
133
+ samples?: number;
134
+ };
135
+
136
+ /**
137
+ * Weighted cubic Hermite curve functions.
138
+ * Weight blends each user tangent toward the segment linear slope:
139
+ * weight 0 = straight line, weight 1 = user tangent.
140
+ */
141
+ function evalHermiteSegment(p0: CurvePoint, p1: CurvePoint, x: number) {
142
+ const x0 = p0.x;
143
+ const x1 = p1.x;
144
+ const dx = x1 - x0;
145
+ if (dx <= 0) return p0.y; // fallback if points overlap
146
+
147
+ const t = (x - x0) / dx;
148
+ const t2 = t * t;
149
+ const t3 = t2 * t;
150
+
151
+ // Cubic Hermite basis functions
152
+ const h00 = 2 * t3 - 3 * t2 + 1;
153
+ const h10 = t3 - 2 * t2 + t;
154
+ const h01 = -2 * t3 + 3 * t2;
155
+ const h11 = t3 - t2;
156
+
157
+ // Convert angle (rad) -> slope (dy/dx)
158
+ const m0 = p0.r_out !== undefined ? Math.tan(p0.r_out) : 0;
159
+ const m1 = p1.r_in !== undefined ? Math.tan(p1.r_in) : 0;
160
+ const w_out = p0.w_out ?? 1;
161
+ const w_in = p1.w_in ?? 1;
162
+ const linearSlope = (p1.y - p0.y) / dx;
163
+ const weightedM0 = linearSlope + (m0 - linearSlope) * w_out;
164
+ const weightedM1 = linearSlope + (m1 - linearSlope) * w_in;
165
+
166
+ return h00 * p0.y + h10 * weightedM0 * dx + h01 * p1.y + h11 * weightedM1 * dx;
167
+ }
168
+
169
+ function findSegmentByX(x: number, points: CurvePoint[]) {
170
+ let low = 0;
171
+ let high = points.length - 2;
172
+
173
+ while (low <= high) {
174
+ const mid = (low + high) >> 1;
175
+ if (x < points[mid].x) high = mid - 1;
176
+ else if (x > points[mid + 1].x) low = mid + 1;
177
+ else return mid;
178
+ }
179
+ return x < points[0].x ? 0 : points.length - 2;
180
+ }
181
+
182
+ function evalMultiPointCurveAtX(x: number, points: CurvePoint[]) {
183
+ const i = findSegmentByX(x, points);
184
+ const p0 = points[i];
185
+ const p1 = points[i + 1];
186
+ return evalHermiteSegment(p0, p1, x);
187
+ }
188
+
189
+ /**
190
+ * Bake a weighted-Hermite curve into a uniformly-sampled lookup table.
191
+ *
192
+ * The input `points` array is copied and sorted by `x` — the caller's array is
193
+ * never mutated. Throws if fewer than 2 points are given.
194
+ *
195
+ * Tuning hint: 50 samples (the default) is plenty for the smooth slip/torque
196
+ * curves the controllers ship with; raise it only if you add a curve with
197
+ * very sharp kinks and see faceting in behavior.
198
+ */
199
+ export function bakeCurveLUT(points: CurvePoint[], samples: number = 50): CurveLUT {
200
+ if (points.length < 2) throw new Error("Curve needs at least 2 points");
201
+ const sortedPoints = [...points].sort((a, b) => a.x - b.x);
202
+ const xMin = sortedPoints[0].x;
203
+ const xMax = sortedPoints[sortedPoints.length - 1].x;
204
+ const lut = new Float32Array(samples);
205
+ for (let i = 0; i < samples; i++) {
206
+ const u = i / (samples - 1);
207
+ const x = xMin + u * (xMax - xMin);
208
+ lut[i] = evalMultiPointCurveAtX(x, sortedPoints);
209
+ }
210
+ return { lut, xMin, xMax, samples };
211
+ }
212
+
213
+ /**
214
+ * Sample a baked curve at `x` with linear interpolation between LUT entries.
215
+ *
216
+ * Input outside [xMin, xMax] clamps to the end values (constant
217
+ * extrapolation) — load-bearing for tire slip ratios that exceed 1.
218
+ */
219
+ export function evaluateCurveLUT(x: number, curve: CurveLUT) {
220
+ const { lut, xMin, xMax, samples } = curve;
221
+ const u = (x - xMin) / (xMax - xMin);
222
+ if (u <= 0) return lut[0];
223
+ if (u >= 1) return lut[samples - 1];
224
+ const f = u * (samples - 1);
225
+ const i = f | 0;
226
+ const t = f - i;
227
+ const y0 = lut[i];
228
+ const y1 = lut[i + 1];
229
+ return y0 * (1 - t) + y1 * t;
230
+ }