@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,1073 @@
1
+ // SPDX-FileCopyrightText: 2023-2026 Erdong Chen
2
+ // SPDX-License-Identifier: MIT
3
+ // Vanilla-TS port of the ecctrl drone controller (PD attitude/position flight
4
+ // brain + thrust-propeller mixer; React/R3F lifecycle replaced by a plain class
5
+ // with an explicit per-physics-step update()).
6
+
7
+ import * as THREE from "three";
8
+ import RAPIER from "@dimforge/rapier3d-compat";
9
+ import { createSlerpVec3 } from "../shared/math.ts";
10
+
11
+ /**
12
+ * How the drone is flown.
13
+ *
14
+ * - `"VELOCITY"` — stick-flying: inputs command velocities (throttle/yaw/
15
+ * pitch/roll). Use while a player is on board.
16
+ * - `"POSITION"` — autopilot: the drone holds `targetPos` and faces
17
+ * `targetFwd` (set both via `setTarget`). Use for parked/idle drones.
18
+ */
19
+ export type DroneControlMode = "VELOCITY" | "POSITION";
20
+
21
+ /** Analog stick values, each axis in [-1, 1]. */
22
+ export interface DroneJoystickInput {
23
+ x: number;
24
+ y: number;
25
+ }
26
+
27
+ /**
28
+ * Drone movement input (all optional — `setMovement` merges only the keys you
29
+ * pass). Left stick = throttle (y) / yaw (x); right stick = pitch (y) /
30
+ * roll (x). Boolean keys map to full-deflection stick input.
31
+ */
32
+ export interface DroneInput {
33
+ throttleUp?: boolean;
34
+ throttleDown?: boolean;
35
+ yawLeft?: boolean;
36
+ yawRight?: boolean;
37
+ pitchForward?: boolean;
38
+ pitchBackward?: boolean;
39
+ rollLeft?: boolean;
40
+ rollRight?: boolean;
41
+ /** Left stick: y = climb/descend, x = yaw. */
42
+ joystickL?: DroneJoystickInput;
43
+ /** Right stick: y = pitch (forward is body +Z), x = roll. */
44
+ joystickR?: DroneJoystickInput;
45
+ }
46
+
47
+ /**
48
+ * Drone flight configuration (PD gains keep their upstream SCREAMING_SNAKE
49
+ * names on purpose — they are the parity-checked crown jewels).
50
+ *
51
+ * Tuning hints:
52
+ * - Feels sluggish to tilt -> raise `TILT_P`; wobbles/overshoots -> raise
53
+ * `TILT_D`.
54
+ * - `VERT_POS_*` / `HORIZ_POS_*` are POSITION-mode gains in absolute force
55
+ * units — they must scale with the drone's mass (a 10x heavier drone wants
56
+ * ~10x larger values).
57
+ * - `HORIZ_VEL_P` / `VERT_VEL_P` are VELOCITY-mode gains in acceleration
58
+ * units — mass-independent, usually fine as-is.
59
+ * - `airDragFactor` is an absolute force per (m/s): meaningful on a 2 kg
60
+ * drone, cosmetic on a 300 kg one.
61
+ */
62
+ export interface DroneConfig {
63
+ controlMode: DroneControlMode;
64
+ /** Max yaw rate in rad/s. */
65
+ maxYawRate: number;
66
+ /** Max horizontal speed in m/s (VELOCITY-mode stick target). */
67
+ maxHorizSpeed: number;
68
+ /** Max vertical speed in m/s (VELOCITY-mode stick target). */
69
+ maxVertSpeed: number;
70
+ /** Max tilt from level, in radians. Used as tan(maxTiltAngle) internally. */
71
+ maxTiltAngle: number;
72
+ /** Linear air-drag impulse coefficient (absolute, not mass-relative). */
73
+ airDragFactor: number;
74
+ TILT_P: number;
75
+ TILT_D: number;
76
+ YAW_POS_P: number;
77
+ YAW_VEL_P: number;
78
+ VERT_POS_P: number;
79
+ VERT_POS_D: number;
80
+ HORIZ_POS_P: number;
81
+ HORIZ_POS_D: number;
82
+ HORIZ_VEL_P: number;
83
+ VERT_VEL_P: number;
84
+ }
85
+
86
+ /** Library defaults (upstream values, verbatim). */
87
+ export const DEFAULT_DRONE_CONFIG: DroneConfig = {
88
+ controlMode: "VELOCITY",
89
+ maxYawRate: 2,
90
+ maxHorizSpeed: 30,
91
+ maxVertSpeed: 8,
92
+ maxTiltAngle: Math.PI / 4, // 45 degree in radian
93
+ airDragFactor: 0.2,
94
+ // PD controller setups
95
+ TILT_P: 15,
96
+ TILT_D: 3,
97
+ YAW_POS_P: 6,
98
+ YAW_VEL_P: 4,
99
+ // Position based config
100
+ VERT_POS_P: 9,
101
+ VERT_POS_D: 7,
102
+ HORIZ_POS_P: 5,
103
+ HORIZ_POS_D: 5.5,
104
+ // Velocity based config
105
+ HORIZ_VEL_P: 1,
106
+ VERT_VEL_P: 2,
107
+ };
108
+
109
+ /**
110
+ * Per-propeller construction options.
111
+ *
112
+ * `object` is the mount node — it MUST be a descendant of the drone chassis
113
+ * object, and its local +Y is the thrust axis. (Port note: replaces the
114
+ * upstream JSX `<ThrustPropeller position=...>` group; `spinModel` replaces
115
+ * the upstream `children` + `showPropellerModel` pair — passing a spinModel
116
+ * enables the spin visual.)
117
+ *
118
+ * Tuning hints: size `maxThrust` so that total thrust is about twice the
119
+ * drone's weight (hover throttle near 0.5 gives the best attitude authority).
120
+ * Diagonal propeller pairs must share the same `invertTorque` value or the
121
+ * reaction torques will not cancel and the drone yaws constantly.
122
+ */
123
+ export interface PropellerOptions {
124
+ /** Mount node (descendant of the chassis). Local +Y = thrust axis. */
125
+ object: THREE.Object3D;
126
+ name?: string;
127
+ /** Stable id; auto-generated when omitted. */
128
+ id?: string;
129
+ enable?: boolean;
130
+ /** Max thrust in newtons at full throttle. Default 500. */
131
+ maxThrust?: number;
132
+ /** Reaction torque = maxThrust * torqueRatio. Default 0.6. */
133
+ torqueRatio?: number;
134
+ /** Flip the thrust axis to local -Y. */
135
+ invertThrust?: boolean;
136
+ /** Flip the reaction-torque direction (counter-rotating propeller). */
137
+ invertTorque?: boolean;
138
+ /** Optional visual spun around its local Y by the throttle. */
139
+ spinModel?: THREE.Object3D;
140
+ propellerModelUpdate?: boolean;
141
+ /** Max visual spin in rad per 60 Hz frame. Default 50. */
142
+ propellerModelMaxSpin?: number;
143
+ propellerModelLerpSpinRate?: number;
144
+ /**
145
+ * Attach debug indicators (thrust/torque arrows + axis markers) under the
146
+ * mount. Default false (upstream demo default was true — deliberate flip).
147
+ */
148
+ debug?: boolean;
149
+ debuggerScale?: number;
150
+ debuggerArrowScale?: number;
151
+ }
152
+
153
+ /**
154
+ * Live per-propeller state. All vectors are reused internal instances updated
155
+ * every physics step — read-only for consumers (copy, never mutate).
156
+ */
157
+ export interface PropellerState {
158
+ readonly id: string;
159
+ name: string;
160
+ enable: boolean;
161
+ maxThrust: number;
162
+ torqueRatio: number;
163
+ invertThrust: boolean;
164
+ invertTorque: boolean;
165
+ /** Local potential values in vehicle space (updated every step). */
166
+ thrustPos: THREE.Vector3;
167
+ thrustDir: THREE.Vector3;
168
+ thrustPot: THREE.Vector3;
169
+ torqueDir: THREE.Vector3;
170
+ torquePot: THREE.Vector3;
171
+ /** Actual mixer output in world space (updated when impulses apply). */
172
+ worldThrustPos: THREE.Vector3;
173
+ worldThrustDir: THREE.Vector3;
174
+ worldTorqueDir: THREE.Vector3;
175
+ thrustImpulse: THREE.Vector3;
176
+ torqueImpulse: THREE.Vector3;
177
+ /** Mixer output this step, 0..1. */
178
+ finalThrottle: number;
179
+ /** Last throttle fed back via setThrottle (spin visual + sleep check). */
180
+ throttle: number;
181
+ /** Set the throttle directly (clamped to 0..1). */
182
+ setThrottle(value: number): void;
183
+ /** Max potential impulse components (signed). */
184
+ lx: number;
185
+ ly: number;
186
+ lz: number;
187
+ ax: number;
188
+ ay: number;
189
+ az: number;
190
+ }
191
+
192
+ export interface DroneControllerOptions {
193
+ world: RAPIER.World;
194
+ /**
195
+ * Caller-created DYNAMIC rigid body. Attach colliders yourself (e.g. via
196
+ * shared/colliders.ts). The controller never creates or frees the body.
197
+ */
198
+ body: RAPIER.RigidBody;
199
+ /**
200
+ * Visual root synced to the body. Register it with the physics-world
201
+ * body<->Object3D registry; propeller mounts must be its descendants.
202
+ */
203
+ chassis: THREE.Object3D;
204
+ /** Convenience: forwarded to addPropeller(). */
205
+ propellers?: PropellerOptions[];
206
+ /** Merged over DEFAULT_DRONE_CONFIG. */
207
+ config?: Partial<DroneConfig>;
208
+ enabled?: boolean;
209
+ /** Gravity-direction smoothing rate (1 - exp(-k*dt)). Default 6. */
210
+ gravityDirLerpSpeed?: number;
211
+ }
212
+
213
+ // Internal per-propeller bookkeeping (state + visuals + debug assets).
214
+ type PropellerEntry = {
215
+ state: PropellerState;
216
+ mount: THREE.Object3D;
217
+ spinModel: THREE.Object3D | null;
218
+ propellerModelUpdate: boolean;
219
+ propellerModelMaxSpin: number;
220
+ propellerModelLerpSpinRate: number;
221
+ debuggerArrowScale: number;
222
+ throttle: number;
223
+ spinVel: number;
224
+ debugGroup: THREE.Group | null;
225
+ thrustArrow: THREE.ArrowHelper | null;
226
+ torqueArrow: THREE.ArrowHelper | null;
227
+ debugDisposables: Array<{ dispose(): void }>;
228
+ };
229
+
230
+ // Debug indicator colors (upstream constants).
231
+ const EC_RED = "#FA8787";
232
+ const EC_GREEN = "#96FA87";
233
+ const EC_BLUE = "#87CEFA";
234
+ const EC_AZURE = "#F0FFFF";
235
+ const EC_MED_PURPLE = "#9370DB";
236
+
237
+ const { clamp, lerp, generateUUID } = THREE.MathUtils;
238
+
239
+ /**
240
+ * PD-controlled quadcopter (or any multi-rotor) flight controller.
241
+ *
242
+ * Physics model: each propeller contributes a thrust potential along its
243
+ * mount's local +Y plus a reaction torque; the brain computes a hover
244
+ * throttle (weight / total world-up thrust potential) and mixes per-propeller
245
+ * attitude corrections on top, clamped so attitude control never costs
246
+ * altitude (`maxSafeMix = min(1 - hover, hover)`).
247
+ *
248
+ * Loop contract: call `update()` exactly once per fixed physics step, BEFORE
249
+ * `world.step()`. All internal math uses `world.timestep` — the `dt` argument
250
+ * is accepted only for a uniform controller call shape and ignored.
251
+ */
252
+ export class DroneController {
253
+ private readonly worldRef: RAPIER.World;
254
+ private readonly bodyRef: RAPIER.RigidBody;
255
+ private readonly chassisRef: THREE.Object3D;
256
+ private config: DroneConfig;
257
+ private maxTiltTan: number;
258
+ private isEnabled: boolean;
259
+ private readonly gravityDirLerpSpeed: number;
260
+
261
+ // Vehicle snapshot (stale while the body sleeps — on purpose).
262
+ private readonly vehiclePos = new THREE.Vector3();
263
+ private readonly vehicleQuat = new THREE.Quaternion();
264
+ private readonly vehicleInvertQuat = new THREE.Quaternion();
265
+ private readonly vehicleLinVel = new THREE.Vector3();
266
+ private readonly vehicleAngVel = new THREE.Vector3();
267
+ private readonly vehicleXAxis = new THREE.Vector3();
268
+ private readonly vehicleYAxis = new THREE.Vector3();
269
+ private readonly vehicleZAxis = new THREE.Vector3();
270
+
271
+ // Gravity plumbing (world gravity only; custom gravity fields are out of scope).
272
+ private readonly upAxisVec = new THREE.Vector3();
273
+ private readonly referenceGravity = new THREE.Vector3();
274
+ private referenceGravityMag = 0;
275
+ private readonly referenceGravityDir = new THREE.Vector3();
276
+ private readonly gravityDirVec = new THREE.Vector3();
277
+ private readonly slerpVec3 = createSlerpVec3();
278
+
279
+ // Drone brain scratch (pre-allocated once; zero per-frame allocation).
280
+ private hoverThrottleValue = 0;
281
+ private readonly targetUp = new THREE.Vector3();
282
+ private readonly tiltError = new THREE.Vector3();
283
+ private readonly tiltAngVel = new THREE.Vector3();
284
+ private readonly torqueWorld = new THREE.Vector3();
285
+ private readonly torqueBody = new THREE.Vector3();
286
+ private readonly airDragImpulse = new THREE.Vector3();
287
+ private readonly worldThrustDir = new THREE.Vector3();
288
+ private readonly worldThrustPos = new THREE.Vector3();
289
+ private readonly worldTorqueDir = new THREE.Vector3();
290
+ // Position based scratch
291
+ private readonly targetPosition = new THREE.Vector3();
292
+ private readonly targetHeading = new THREE.Vector3();
293
+ private readonly targetFwdVec = new THREE.Vector3();
294
+ private readonly currentFwd = new THREE.Vector3();
295
+ private readonly posError = new THREE.Vector3();
296
+ private readonly horizPosError = new THREE.Vector3();
297
+ private readonly horizLinVel = new THREE.Vector3();
298
+ private readonly horizForce = new THREE.Vector3();
299
+ // Velocity based scratch
300
+ private readonly worldXAxis = new THREE.Vector3();
301
+ private readonly worldZAxis = new THREE.Vector3();
302
+ private readonly horizAccCmd = new THREE.Vector3();
303
+ private readonly targetLinVel = new THREE.Vector3();
304
+ private readonly linVelError = new THREE.Vector3();
305
+ // Propeller scratch
306
+ private readonly propWorldPos = new THREE.Vector3();
307
+ private readonly propWorldQuat = new THREE.Quaternion();
308
+ private readonly propLocalPos = new THREE.Vector3();
309
+ private readonly propLocalQuat = new THREE.Quaternion();
310
+ private readonly propThrustDir = new THREE.Vector3();
311
+ private readonly propThrustForce = new THREE.Vector3();
312
+ private readonly propLeverageTorque = new THREE.Vector3();
313
+ private readonly propReactionTorqueDir = new THREE.Vector3();
314
+ private readonly propReactionTorque = new THREE.Vector3();
315
+ private readonly propTorqueInfluence = new THREE.Vector3();
316
+
317
+ // Propeller overall potential (signed linear sums, absolute angular sums).
318
+ private readonly propellerPotential = {
319
+ sumLX: 0,
320
+ sumLY: 0,
321
+ sumLZ: 0,
322
+ sumAX: 0,
323
+ sumAY: 0,
324
+ sumAZ: 0,
325
+ };
326
+
327
+ // Input state (all merged in place by setMovement).
328
+ private readonly movementState: Required<
329
+ Omit<DroneInput, "joystickL" | "joystickR">
330
+ > & { joystickL: DroneJoystickInput; joystickR: DroneJoystickInput } = {
331
+ throttleUp: false,
332
+ throttleDown: false,
333
+ yawLeft: false,
334
+ yawRight: false,
335
+ pitchForward: false,
336
+ pitchBackward: false,
337
+ rollLeft: false,
338
+ rollRight: false,
339
+ joystickL: { x: 0, y: 0 },
340
+ joystickR: { x: 0, y: 0 },
341
+ };
342
+
343
+ private readonly propellers = new Map<string, PropellerEntry>();
344
+ private readonly propellerStates = new Map<string, PropellerState>();
345
+
346
+ constructor(options: DroneControllerOptions) {
347
+ this.worldRef = options.world;
348
+ this.bodyRef = options.body;
349
+ this.chassisRef = options.chassis;
350
+ this.config = { ...DEFAULT_DRONE_CONFIG, ...options.config };
351
+ this.maxTiltTan = Math.tan(this.config.maxTiltAngle);
352
+ this.isEnabled = options.enabled ?? true;
353
+ this.gravityDirLerpSpeed = options.gravityDirLerpSpeed ?? 6;
354
+ if (options.propellers) {
355
+ for (const propellerOptions of options.propellers) {
356
+ this.addPropeller(propellerOptions);
357
+ }
358
+ }
359
+ }
360
+
361
+ // ---- per-frame ----
362
+
363
+ /**
364
+ * Advance the drone one physics step. Call exactly once per fixed step,
365
+ * before `world.step()`. `dt` is ignored — `world.timestep` is the only dt.
366
+ */
367
+ update(_dt?: number): void {
368
+ // Skip the whole vehicle loop when disabled
369
+ if (!this.isEnabled) return;
370
+
371
+ // Update snapshot + gravity only while the body is awake (stale-on-sleep
372
+ // is upstream behavior — the sleep gate below still needs the old values).
373
+ if (!this.bodyRef.isSleeping()) {
374
+ this.updateVehicleInfo();
375
+ this.updateGravityInfo();
376
+ }
377
+
378
+ // Write the exact body pose onto the chassis object and refresh world
379
+ // matrices, so the propeller mounts' getWorldPosition/Quaternion reflect
380
+ // the CURRENT body pose (pre-step; the post-step registry sync agrees).
381
+ this.chassisRef.position.copy(this.vehiclePos);
382
+ this.chassisRef.quaternion.copy(this.vehicleQuat);
383
+ this.chassisRef.updateWorldMatrix(true, true);
384
+
385
+ // Per-propeller potentials/visuals must be fresh BEFORE the brain runs
386
+ // (upstream got this ordering from R3F child-effects-first registration).
387
+ const frameRateCorrection = 60 * this.worldRef.timestep;
388
+ for (const entry of this.propellers.values()) {
389
+ if (!entry.state.enable) continue;
390
+ this.updatePropellerInfo(entry);
391
+ if (entry.propellerModelUpdate && entry.spinModel) {
392
+ this.updatePropellerModel(entry, frameRateCorrection);
393
+ }
394
+ this.updateDebugger(entry);
395
+ }
396
+
397
+ // Apply drone control logics whenever there is a propeller registered
398
+ // (runs even while asleep — the mixer owns the wake check).
399
+ if (this.propellers.size > 0) this.applyDroneControl();
400
+ }
401
+
402
+ // ---- imperative handle ----
403
+
404
+ /**
405
+ * Merge movement input. Only keys that are defined are copied; joystick
406
+ * values are copied field-wise (the caller's object is never stored).
407
+ */
408
+ setMovement(movement: DroneInput): void {
409
+ const state = this.movementState;
410
+ if (movement.throttleUp !== undefined) state.throttleUp = movement.throttleUp;
411
+ if (movement.throttleDown !== undefined) state.throttleDown = movement.throttleDown;
412
+ if (movement.yawLeft !== undefined) state.yawLeft = movement.yawLeft;
413
+ if (movement.yawRight !== undefined) state.yawRight = movement.yawRight;
414
+ if (movement.pitchForward !== undefined) state.pitchForward = movement.pitchForward;
415
+ if (movement.pitchBackward !== undefined) state.pitchBackward = movement.pitchBackward;
416
+ if (movement.rollLeft !== undefined) state.rollLeft = movement.rollLeft;
417
+ if (movement.rollRight !== undefined) state.rollRight = movement.rollRight;
418
+ if (movement.joystickL) {
419
+ state.joystickL.x = movement.joystickL.x;
420
+ state.joystickL.y = movement.joystickL.y;
421
+ }
422
+ if (movement.joystickR) {
423
+ state.joystickR.x = movement.joystickR.x;
424
+ state.joystickR.y = movement.joystickR.y;
425
+ }
426
+ }
427
+
428
+ /**
429
+ * Set the POSITION-mode hold target: `pos` = hover position, `dir` = facing
430
+ * direction. Typical parking recipe: `setTarget(drone.currPos,
431
+ * drone.bodyZAxis)` then `setControlMode("POSITION")`.
432
+ */
433
+ setTarget(pos?: THREE.Vector3, dir?: THREE.Vector3): void {
434
+ if (pos) this.targetPosition.copy(pos);
435
+ if (dir) this.targetHeading.copy(dir);
436
+ }
437
+
438
+ /**
439
+ * Register a propeller. Returns its live state object. If a propeller with
440
+ * the same id already exists, that existing state is returned unchanged.
441
+ */
442
+ addPropeller(options: PropellerOptions): PropellerState {
443
+ const id = String(options.id ?? generateUUID());
444
+ const existing = this.propellers.get(id);
445
+ if (existing) return existing.state;
446
+
447
+ const entry: PropellerEntry = {
448
+ mount: options.object,
449
+ spinModel: options.spinModel ?? null,
450
+ propellerModelUpdate: options.propellerModelUpdate ?? true,
451
+ propellerModelMaxSpin: options.propellerModelMaxSpin ?? 50,
452
+ propellerModelLerpSpinRate: options.propellerModelLerpSpinRate ?? 10,
453
+ debuggerArrowScale: options.debuggerArrowScale ?? 35,
454
+ throttle: 0,
455
+ spinVel: 0,
456
+ debugGroup: null,
457
+ thrustArrow: null,
458
+ torqueArrow: null,
459
+ debugDisposables: [],
460
+ state: undefined as unknown as PropellerState,
461
+ };
462
+ const state: PropellerState = {
463
+ id,
464
+ name: options.name ?? "",
465
+ enable: options.enable ?? true,
466
+ maxThrust: options.maxThrust ?? 500,
467
+ torqueRatio: options.torqueRatio ?? 0.6,
468
+ invertThrust: options.invertThrust ?? false,
469
+ invertTorque: options.invertTorque ?? false,
470
+ thrustPos: new THREE.Vector3(),
471
+ thrustDir: new THREE.Vector3(),
472
+ thrustPot: new THREE.Vector3(),
473
+ torqueDir: new THREE.Vector3(),
474
+ torquePot: new THREE.Vector3(),
475
+ worldThrustPos: new THREE.Vector3(),
476
+ worldThrustDir: new THREE.Vector3(),
477
+ worldTorqueDir: new THREE.Vector3(),
478
+ thrustImpulse: new THREE.Vector3(),
479
+ torqueImpulse: new THREE.Vector3(),
480
+ finalThrottle: 0,
481
+ throttle: 0,
482
+ setThrottle: (value: number) => {
483
+ entry.throttle = clamp(value, 0, 1);
484
+ },
485
+ lx: 0,
486
+ ly: 0,
487
+ lz: 0,
488
+ ax: 0,
489
+ ay: 0,
490
+ az: 0,
491
+ };
492
+ entry.state = state;
493
+
494
+ if (options.debug ?? false) this.buildDebugIndicators(entry, options);
495
+
496
+ this.propellers.set(id, entry);
497
+ this.propellerStates.set(id, state);
498
+ return state;
499
+ }
500
+
501
+ /** Unregister a propeller (and dispose its debug helpers). */
502
+ removePropeller(id: string): boolean {
503
+ const entry = this.propellers.get(id);
504
+ if (!entry) return false;
505
+ this.disposePropellerDebug(entry);
506
+ this.propellerStates.delete(id);
507
+ return this.propellers.delete(id);
508
+ }
509
+
510
+ /** Switch between stick flying ("VELOCITY") and autopilot ("POSITION"). */
511
+ setControlMode(mode: DroneControlMode): void {
512
+ this.config.controlMode = mode;
513
+ }
514
+
515
+ /** Pause/resume the whole controller (no impulses while disabled). */
516
+ setEnabled(value: boolean): void {
517
+ this.isEnabled = value;
518
+ }
519
+
520
+ /**
521
+ * Merge config changes at runtime (recomputes the cached tilt limit when
522
+ * `maxTiltAngle` changes).
523
+ */
524
+ updateConfig(partial: Partial<DroneConfig>): void {
525
+ this.config = { ...this.config, ...partial };
526
+ this.maxTiltTan = Math.tan(this.config.maxTiltAngle);
527
+ }
528
+
529
+ /** Clear all propellers and debug assets. The rigid body is untouched. */
530
+ dispose(): void {
531
+ for (const entry of this.propellers.values()) {
532
+ this.disposePropellerDebug(entry);
533
+ }
534
+ this.propellers.clear();
535
+ this.propellerStates.clear();
536
+ }
537
+
538
+ // ---- readonly state getters (live internal instances; copy, never mutate) ----
539
+
540
+ get body(): RAPIER.RigidBody {
541
+ return this.bodyRef;
542
+ }
543
+ get upAxis(): THREE.Vector3 {
544
+ return this.upAxisVec;
545
+ }
546
+ get gravityDir(): THREE.Vector3 {
547
+ return this.gravityDirVec;
548
+ }
549
+ get gravityMag(): number {
550
+ return this.referenceGravityMag;
551
+ }
552
+ get currPos(): THREE.Vector3 {
553
+ return this.vehiclePos;
554
+ }
555
+ get currQuat(): THREE.Quaternion {
556
+ return this.vehicleQuat;
557
+ }
558
+ get currLinVel(): THREE.Vector3 {
559
+ return this.vehicleLinVel;
560
+ }
561
+ get currAngVel(): THREE.Vector3 {
562
+ return this.vehicleAngVel;
563
+ }
564
+ get bodyXAxis(): THREE.Vector3 {
565
+ return this.vehicleXAxis;
566
+ }
567
+ get bodyYAxis(): THREE.Vector3 {
568
+ return this.vehicleYAxis;
569
+ }
570
+ get bodyZAxis(): THREE.Vector3 {
571
+ return this.vehicleZAxis;
572
+ }
573
+ get targetPos(): THREE.Vector3 {
574
+ return this.targetPosition;
575
+ }
576
+ get targetFwd(): THREE.Vector3 {
577
+ return this.targetHeading;
578
+ }
579
+ get input(): Readonly<DroneInput> {
580
+ return this.movementState;
581
+ }
582
+ get propellersInfo(): ReadonlyMap<string, PropellerState> {
583
+ return this.propellerStates;
584
+ }
585
+ get controlMode(): DroneControlMode {
586
+ return this.config.controlMode;
587
+ }
588
+ /** Last computed hover throttle (0..1-ish; > 1 means underpowered). */
589
+ get hoverThrottle(): number {
590
+ return this.hoverThrottleValue;
591
+ }
592
+ get enabled(): boolean {
593
+ return this.isEnabled;
594
+ }
595
+
596
+ // ---- internals ----
597
+
598
+ /** Update vehicle collider pos/vel/quat/axis from the rigid body. */
599
+ private updateVehicleInfo(): void {
600
+ const translation = this.bodyRef.translation();
601
+ const rotation = this.bodyRef.rotation();
602
+ const linvel = this.bodyRef.linvel();
603
+ const angvel = this.bodyRef.angvel();
604
+ this.vehiclePos.set(translation.x, translation.y, translation.z);
605
+ this.vehicleQuat.set(rotation.x, rotation.y, rotation.z, rotation.w);
606
+ this.vehicleInvertQuat.copy(this.vehicleQuat).invert();
607
+ this.vehicleLinVel.set(linvel.x, linvel.y, linvel.z);
608
+ this.vehicleAngVel.set(angvel.x, angvel.y, angvel.z);
609
+ this.vehicleYAxis.set(0, 1, 0).applyQuaternion(this.vehicleQuat);
610
+ this.vehicleXAxis.set(1, 0, 0).applyQuaternion(this.vehicleQuat);
611
+ this.vehicleZAxis.set(0, 0, 1).applyQuaternion(this.vehicleQuat);
612
+ }
613
+
614
+ /** Update gravity/upAxis direction and value (world gravity only). */
615
+ private updateGravityInfo(): void {
616
+ const gravity = this.worldRef.gravity;
617
+ this.referenceGravity.set(gravity.x, gravity.y, gravity.z);
618
+ this.referenceGravityMag = this.referenceGravity.length();
619
+ this.referenceGravityDir.copy(this.referenceGravity).normalize();
620
+ if (this.referenceGravityDir.lengthSq() === 0) {
621
+ this.referenceGravityDir.copy(this.vehicleYAxis).negate();
622
+ }
623
+ // slerpVec3 returns a shared scratch vector — copy immediately.
624
+ this.gravityDirVec.copy(
625
+ this.slerpVec3(
626
+ this.gravityDirVec,
627
+ this.referenceGravityDir,
628
+ 1 - Math.exp(-this.gravityDirLerpSpeed * this.worldRef.timestep),
629
+ this.vehicleZAxis
630
+ )
631
+ );
632
+ this.upAxisVec.copy(this.gravityDirVec).negate();
633
+ }
634
+
635
+ /**
636
+ * Recompute one propeller's thrust/torque potentials in vehicle space.
637
+ * Runs every step even for static mounts — supports animated mounts
638
+ * (tilt-rotors), matching upstream.
639
+ */
640
+ private updatePropellerInfo(entry: PropellerEntry): void {
641
+ const state = entry.state;
642
+ // Note: upstream also copied body.angvel() here but never used it — skipped.
643
+ entry.mount.getWorldPosition(this.propWorldPos);
644
+ entry.mount.getWorldQuaternion(this.propWorldQuat);
645
+
646
+ this.propLocalPos
647
+ .subVectors(this.propWorldPos, this.vehiclePos)
648
+ .applyQuaternion(this.vehicleInvertQuat);
649
+ this.propLocalQuat.multiplyQuaternions(this.vehicleInvertQuat, this.propWorldQuat);
650
+
651
+ this.propThrustDir
652
+ .set(0, state.invertThrust ? -1 : 1, 0)
653
+ .applyQuaternion(this.propLocalQuat);
654
+ this.propThrustForce.copy(this.propThrustDir).multiplyScalar(state.maxThrust);
655
+
656
+ this.propLeverageTorque.crossVectors(this.propLocalPos, this.propThrustForce);
657
+ this.propReactionTorqueDir
658
+ .set(0, state.invertTorque ? -1 : 1, 0)
659
+ .applyQuaternion(this.propLocalQuat);
660
+ this.propReactionTorque
661
+ .copy(this.propReactionTorqueDir)
662
+ .multiplyScalar(state.maxThrust * state.torqueRatio);
663
+ this.propTorqueInfluence.copy(this.propLeverageTorque).add(this.propReactionTorque);
664
+
665
+ state.lx = this.propThrustForce.x;
666
+ state.ly = this.propThrustForce.y;
667
+ state.lz = this.propThrustForce.z;
668
+ state.ax = this.propTorqueInfluence.x;
669
+ state.ay = this.propTorqueInfluence.y;
670
+ state.az = this.propTorqueInfluence.z;
671
+
672
+ state.thrustPos.copy(this.propLocalPos);
673
+ state.thrustDir.copy(this.propThrustDir);
674
+ state.thrustPot.copy(this.propThrustForce);
675
+ state.torqueDir.copy(this.propReactionTorqueDir);
676
+ state.torquePot.copy(this.propTorqueInfluence);
677
+ state.throttle = entry.throttle;
678
+ }
679
+
680
+ /** Spin the visual propeller model by the smoothed throttle. */
681
+ private updatePropellerModel(entry: PropellerEntry, frameRateCorrection: number): void {
682
+ if (!entry.spinModel) return;
683
+ const targetVel =
684
+ entry.throttle * entry.propellerModelMaxSpin * (entry.state.invertTorque ? -1 : 1);
685
+ entry.spinVel = lerp(
686
+ entry.spinVel,
687
+ targetVel,
688
+ 1 - Math.exp(-entry.propellerModelLerpSpinRate * this.worldRef.timestep)
689
+ );
690
+ entry.spinModel.rotateY(entry.spinVel * frameRateCorrection);
691
+ }
692
+
693
+ /** Update debug arrow lengths from the current throttle. */
694
+ private updateDebugger(entry: PropellerEntry): void {
695
+ if (entry.thrustArrow) {
696
+ entry.thrustArrow.setLength(entry.throttle * entry.debuggerArrowScale);
697
+ }
698
+ if (entry.torqueArrow) {
699
+ entry.torqueArrow.setLength(
700
+ entry.throttle * entry.debuggerArrowScale * entry.state.torqueRatio
701
+ );
702
+ }
703
+ }
704
+
705
+ /** Sum all propellers' potentials (linear: signed; angular: absolute). */
706
+ private computePropellerPotential(): void {
707
+ let sumLX = 0,
708
+ sumLY = 0,
709
+ sumLZ = 0;
710
+ let sumAX = 0,
711
+ sumAY = 0,
712
+ sumAZ = 0;
713
+ // Upstream iterates the whole map, including disabled propellers — keep.
714
+ for (const entry of this.propellers.values()) {
715
+ sumLX += entry.state.lx;
716
+ sumLY += entry.state.ly;
717
+ sumLZ += entry.state.lz;
718
+ sumAX += Math.abs(entry.state.ax);
719
+ sumAY += Math.abs(entry.state.ay);
720
+ sumAZ += Math.abs(entry.state.az);
721
+ }
722
+ this.propellerPotential.sumLX = sumLX;
723
+ this.propellerPotential.sumLY = sumLY;
724
+ this.propellerPotential.sumLZ = sumLZ;
725
+ this.propellerPotential.sumAX = sumAX;
726
+ this.propellerPotential.sumAY = sumAY;
727
+ this.propellerPotential.sumAZ = sumAZ;
728
+ }
729
+
730
+ /** POSITION mode: PD-hold targetPos/targetFwd. */
731
+ private positionBasedDroneControl(weight: number, sumWorldLY: number): void {
732
+ const config = this.config;
733
+ // Compute the vertical and horizontal position difference
734
+ this.posError.subVectors(this.targetPosition, this.vehiclePos);
735
+ const vertPosErrorMag = this.posError.dot(this.upAxisVec);
736
+ this.horizPosError.copy(this.posError).projectOnPlane(this.upAxisVec);
737
+
738
+ // Compute the current vertical and horizontal linear velocity
739
+ const vertLinVelMag = this.vehicleLinVel.dot(this.upAxisVec);
740
+ this.horizLinVel.copy(this.vehicleLinVel).projectOnPlane(this.upAxisVec);
741
+
742
+ // Compute the necessary vertical hovering throttle, also clamp speed at maxVertSpeed
743
+ const vertControl = clamp(
744
+ vertPosErrorMag * config.VERT_POS_P,
745
+ -config.VERT_POS_D * config.maxVertSpeed,
746
+ config.VERT_POS_D * config.maxVertSpeed
747
+ );
748
+ const vertForceMag = weight + vertControl - vertLinVelMag * config.VERT_POS_D;
749
+ this.hoverThrottleValue = Math.max(0, vertForceMag / (sumWorldLY || 1));
750
+
751
+ // Compute the tilted target up to move horizontally, also clamp speed at
752
+ // maxHorizSpeed. NOTE: horizForce gets TWO sequential in-place clampLength
753
+ // calls (upstream behavior — keep both, in this order).
754
+ this.horizForce
755
+ .set(0, 0, 0)
756
+ .addScaledVector(this.horizPosError, config.HORIZ_POS_P)
757
+ .addScaledVector(this.horizLinVel, -config.HORIZ_POS_D)
758
+ .clampLength(0, config.HORIZ_POS_D * config.maxHorizSpeed);
759
+ this.targetUp
760
+ .copy(this.upAxisVec)
761
+ .multiplyScalar(weight)
762
+ .add(this.horizForce.clampLength(0, weight * this.maxTiltTan))
763
+ .normalize();
764
+ this.tiltError.crossVectors(this.vehicleYAxis, this.targetUp);
765
+ this.tiltAngVel.copy(this.vehicleAngVel).projectOnPlane(this.upAxisVec);
766
+
767
+ // Find yaw direction difference: yawError.
768
+ // Evaluation order matters: angleTo BEFORE cross mutates currentFwd.
769
+ this.targetFwdVec.copy(this.targetHeading).projectOnPlane(this.upAxisVec).normalize();
770
+ this.currentFwd.copy(this.vehicleZAxis).projectOnPlane(this.upAxisVec).normalize();
771
+ const yawError =
772
+ this.targetFwdVec.angleTo(this.currentFwd) *
773
+ Math.sign(this.currentFwd.cross(this.targetFwdVec).dot(this.upAxisVec));
774
+ // Find yaw speed difference: yawRateError, also clamp speed at maxYawRate
775
+ const currentYawRate = this.vehicleAngVel.dot(this.upAxisVec);
776
+ const targetYawRate = clamp(
777
+ yawError * config.YAW_POS_P,
778
+ -config.maxYawRate,
779
+ config.maxYawRate
780
+ );
781
+ const yawRateError = targetYawRate - currentYawRate;
782
+
783
+ // Combine tilt and yaw to form the torque needed to control the drone
784
+ this.torqueWorld
785
+ .set(0, 0, 0)
786
+ .addScaledVector(this.tiltError, config.TILT_P)
787
+ .addScaledVector(this.tiltAngVel, -config.TILT_D)
788
+ .addScaledVector(this.upAxisVec, yawRateError * config.YAW_VEL_P);
789
+ // Convert required torque to drone local frame
790
+ this.torqueBody.copy(this.torqueWorld).applyQuaternion(this.vehicleInvertQuat);
791
+ }
792
+
793
+ /** VELOCITY mode: sticks command velocities, PD converts to tilt/throttle. */
794
+ private velocityBasedDroneControl(weight: number, sumWorldLY: number): void {
795
+ const config = this.config;
796
+ const input = this.movementState;
797
+ // Convert user input (-1 to 1)
798
+ const throttleIn = clamp(
799
+ (input.throttleUp ? 1 : 0) - (input.throttleDown ? 1 : 0) + input.joystickL.y,
800
+ -1,
801
+ 1
802
+ );
803
+ const yawIn = clamp(
804
+ (input.yawLeft ? 1 : 0) - (input.yawRight ? 1 : 0) - input.joystickL.x,
805
+ -1,
806
+ 1
807
+ );
808
+ const pitchIn = clamp(
809
+ (input.pitchForward ? 1 : 0) - (input.pitchBackward ? 1 : 0) + input.joystickR.y,
810
+ -1,
811
+ 1
812
+ );
813
+ const rollIn = clamp(
814
+ (input.rollRight ? 1 : 0) - (input.rollLeft ? 1 : 0) + input.joystickR.x,
815
+ -1,
816
+ 1
817
+ );
818
+
819
+ // Find drone roll and pitch axis
820
+ this.worldXAxis.copy(this.vehicleXAxis).projectOnPlane(this.upAxisVec).normalize();
821
+ this.worldZAxis.copy(this.vehicleZAxis).projectOnPlane(this.upAxisVec).normalize();
822
+
823
+ // Compute the target linear velocity and delta-v based on user input
824
+ this.targetLinVel
825
+ .set(0, 0, 0)
826
+ .addScaledVector(this.worldXAxis, -rollIn * config.maxHorizSpeed)
827
+ .addScaledVector(this.worldZAxis, pitchIn * config.maxHorizSpeed)
828
+ .addScaledVector(this.upAxisVec, throttleIn * config.maxVertSpeed);
829
+ this.linVelError.subVectors(this.targetLinVel, this.vehicleLinVel);
830
+
831
+ // Use PD controls to find the needed acceleration direction
832
+ const vertAccCmd = clamp(
833
+ this.linVelError.dot(this.upAxisVec) * config.VERT_VEL_P,
834
+ -this.referenceGravityMag,
835
+ this.referenceGravityMag
836
+ );
837
+ this.horizAccCmd
838
+ .copy(this.linVelError)
839
+ .projectOnPlane(this.upAxisVec)
840
+ .multiplyScalar(config.HORIZ_VEL_P)
841
+ .clampLength(0, this.referenceGravityMag * this.maxTiltTan);
842
+
843
+ // Compute the necessary vertical hovering throttle
844
+ const verticalForceMag = weight + vertAccCmd * this.bodyRef.mass();
845
+ this.hoverThrottleValue = Math.max(0, verticalForceMag / (sumWorldLY || 1));
846
+
847
+ // Tilt the drone up axis towards the acceleration direction
848
+ this.targetUp
849
+ .copy(this.upAxisVec)
850
+ .multiplyScalar(this.referenceGravityMag)
851
+ .add(this.horizAccCmd)
852
+ .normalize();
853
+ this.tiltError.crossVectors(this.vehicleYAxis, this.targetUp);
854
+ this.tiltAngVel.copy(this.vehicleAngVel).projectOnPlane(this.upAxisVec);
855
+
856
+ // Find yaw speed difference: yawRateError
857
+ const currentYawRate = this.vehicleAngVel.dot(this.upAxisVec);
858
+ const targetYawRate = yawIn * config.maxYawRate;
859
+ const yawRateError = targetYawRate - currentYawRate;
860
+
861
+ // Combine tilt and yaw to form the torque needed to control the drone
862
+ this.torqueWorld
863
+ .set(0, 0, 0)
864
+ .addScaledVector(this.tiltError, config.TILT_P)
865
+ .addScaledVector(this.tiltAngVel, -config.TILT_D)
866
+ .addScaledVector(this.upAxisVec, yawRateError * config.YAW_VEL_P);
867
+ // Convert required torque to drone local frame
868
+ this.torqueBody.copy(this.torqueWorld).applyQuaternion(this.vehicleInvertQuat);
869
+ }
870
+
871
+ /** Mixer: hover throttle + clamped per-axis attitude mix, clamped to 0..1. */
872
+ private computePropellerFinalThrottle(state: PropellerState, maxSafeMix: number): number {
873
+ const potential = this.propellerPotential;
874
+ const mix =
875
+ (this.torqueBody.x * state.ax) / (potential.sumAX || 1) + // Pitch
876
+ (this.torqueBody.z * state.az) / (potential.sumAZ || 1) + // Roll
877
+ (this.torqueBody.y * state.ay) / (potential.sumAY || 1); // Yaw
878
+
879
+ return clamp(this.hoverThrottleValue + clamp(mix, -maxSafeMix, maxSafeMix), 0, 1);
880
+ }
881
+
882
+ /** Apply the per-propeller thrust and torque impulses. */
883
+ private applyMixerImpulse(): void {
884
+ const body = this.bodyRef;
885
+ // Compute the max mix, so the drone won't lift/lower while yaw/roll/pitch.
886
+ // Deliberately NOT floored at 0 — a negative maxSafeMix (hover > 1) pins
887
+ // the mix via clamp(mix, -m, m) exactly like upstream.
888
+ const maxSafeMix = Math.min(1.0 - this.hoverThrottleValue, this.hoverThrottleValue);
889
+
890
+ // Wake up check: only wake up when the finalThrottle has changed
891
+ if (body.isSleeping()) {
892
+ let shouldWake = false;
893
+ for (const entry of this.propellers.values()) {
894
+ const finalThrottle = this.computePropellerFinalThrottle(entry.state, maxSafeMix);
895
+ if (Math.abs(finalThrottle - entry.state.throttle) > 1e-4) {
896
+ shouldWake = true;
897
+ break;
898
+ }
899
+ }
900
+
901
+ if (!shouldWake) return;
902
+ body.wakeUp();
903
+ }
904
+
905
+ for (const entry of this.propellers.values()) {
906
+ const state = entry.state;
907
+ const finalThrottle = this.computePropellerFinalThrottle(state, maxSafeMix);
908
+
909
+ // Pass the finalThrottle back for visualization + next sleep check
910
+ state.finalThrottle = finalThrottle;
911
+ state.setThrottle(finalThrottle);
912
+
913
+ // Store the actual world-space output so users can drive effects
914
+ // without recomputing the mixer.
915
+ this.worldThrustDir.copy(state.thrustDir).applyQuaternion(this.vehicleQuat).normalize();
916
+ this.worldThrustPos
917
+ .copy(state.thrustPos)
918
+ .applyQuaternion(this.vehicleQuat)
919
+ .add(this.vehiclePos);
920
+ this.worldTorqueDir.copy(state.torqueDir).applyQuaternion(this.vehicleQuat).normalize();
921
+ state.worldThrustDir.copy(this.worldThrustDir);
922
+ state.worldThrustPos.copy(this.worldThrustPos);
923
+ state.worldTorqueDir.copy(this.worldTorqueDir);
924
+ state.thrustImpulse
925
+ .copy(this.worldThrustDir)
926
+ .multiplyScalar(state.maxThrust * finalThrottle * this.worldRef.timestep);
927
+ state.torqueImpulse
928
+ .copy(this.worldTorqueDir)
929
+ .multiplyScalar(
930
+ state.maxThrust * finalThrottle * this.worldRef.timestep * state.torqueRatio
931
+ );
932
+
933
+ // Apply physics
934
+ body.applyImpulseAtPoint(state.thrustImpulse, state.worldThrustPos, false);
935
+ body.applyTorqueImpulse(state.torqueImpulse, false);
936
+ }
937
+ }
938
+
939
+ /** Apply air drag impulse (unconditionally, even after a sleeping mixer). */
940
+ private applyAirDrag(): void {
941
+ this.airDragImpulse
942
+ .copy(this.vehicleLinVel)
943
+ .multiplyScalar(-this.config.airDragFactor * this.worldRef.timestep);
944
+ this.bodyRef.applyImpulse(this.airDragImpulse, false);
945
+ }
946
+
947
+ /** Main drone control application function (upstream sub-order, fixed). */
948
+ private applyDroneControl(): void {
949
+ // Compute propellers overall potential
950
+ this.computePropellerPotential();
951
+
952
+ // Overall potential for hovering the drone vertically:
953
+ // hover throttle = weight / sum(world-Y thrust potential)
954
+ const potential = this.propellerPotential;
955
+ const sumWorldLY =
956
+ potential.sumLX * this.vehicleXAxis.dot(this.upAxisVec) +
957
+ potential.sumLY * this.vehicleYAxis.dot(this.upAxisVec) +
958
+ potential.sumLZ * this.vehicleZAxis.dot(this.upAxisVec);
959
+ const weight = this.bodyRef.mass() * this.referenceGravityMag;
960
+
961
+ // Apply control logics based on selected control mode
962
+ switch (this.config.controlMode) {
963
+ case "POSITION":
964
+ this.positionBasedDroneControl(weight, sumWorldLY);
965
+ break;
966
+ case "VELOCITY":
967
+ this.velocityBasedDroneControl(weight, sumWorldLY);
968
+ break;
969
+ }
970
+
971
+ // Apply propellers final mixer and impulse
972
+ this.applyMixerImpulse();
973
+
974
+ // Apply air drag impulse
975
+ this.applyAirDrag();
976
+ }
977
+
978
+ // ---- debug indicators ----
979
+
980
+ /** Build the upstream debug indicator set under the propeller mount. */
981
+ private buildDebugIndicators(entry: PropellerEntry, options: PropellerOptions): void {
982
+ const scale = options.debuggerScale ?? 1;
983
+ const state = entry.state;
984
+ const group = new THREE.Group();
985
+
986
+ const thrustRingGeo = new THREE.RingGeometry(scale * 0.5, scale * 0.55, 12, 1, 0, -Math.PI);
987
+ const thrustRingMat = new THREE.MeshBasicMaterial({
988
+ color: EC_AZURE,
989
+ side: THREE.DoubleSide,
990
+ });
991
+ const thrustPointerGeo = new THREE.ConeGeometry(scale * 0.06, scale * 0.5, 8, 1, true);
992
+ const thrustIndicatorMat = new THREE.MeshBasicMaterial({
993
+ color: EC_MED_PURPLE,
994
+ side: THREE.DoubleSide,
995
+ transparent: true,
996
+ opacity: 0.3,
997
+ });
998
+ const axisPointGeo = new THREE.OctahedronGeometry(scale * 0.05, 3);
999
+ const xAxisPointMat = new THREE.MeshBasicMaterial({ color: EC_GREEN });
1000
+ const yAxisPointMat = new THREE.MeshBasicMaterial({ color: EC_BLUE });
1001
+ const zAxisPointMat = new THREE.MeshBasicMaterial({ color: EC_RED });
1002
+ entry.debugDisposables.push(
1003
+ thrustRingGeo,
1004
+ thrustRingMat,
1005
+ thrustPointerGeo,
1006
+ thrustIndicatorMat,
1007
+ axisPointGeo,
1008
+ xAxisPointMat,
1009
+ yAxisPointMat,
1010
+ zAxisPointMat
1011
+ );
1012
+
1013
+ // Thrust direction indicator
1014
+ const thrustPointer = new THREE.Mesh(thrustPointerGeo, thrustIndicatorMat);
1015
+ thrustPointer.rotation.x = state.invertThrust ? Math.PI : 0;
1016
+ thrustPointer.position.set(0, scale * 0.25 * (state.invertThrust ? -1 : 1), 0);
1017
+ group.add(thrustPointer);
1018
+
1019
+ // Torque direction indicator
1020
+ const torquePointer = new THREE.Mesh(thrustPointerGeo, thrustRingMat);
1021
+ torquePointer.rotation.x = Math.PI / 2;
1022
+ torquePointer.position.set(scale * 0.53 * (state.invertTorque ? 1 : -1), 0, scale * 0.25);
1023
+ group.add(torquePointer);
1024
+ const torqueRing = new THREE.Mesh(thrustRingGeo, thrustRingMat);
1025
+ torqueRing.rotation.x = Math.PI / 2;
1026
+ group.add(torqueRing);
1027
+
1028
+ // Axis pointers indicator
1029
+ const xAxisPoint = new THREE.Mesh(axisPointGeo, xAxisPointMat);
1030
+ xAxisPoint.position.set(scale, 0, 0);
1031
+ const yAxisPoint = new THREE.Mesh(axisPointGeo, yAxisPointMat);
1032
+ yAxisPoint.position.set(0, scale, 0);
1033
+ const zAxisPoint = new THREE.Mesh(axisPointGeo, zAxisPointMat);
1034
+ zAxisPoint.position.set(0, 0, scale);
1035
+ group.add(xAxisPoint, yAxisPoint, zAxisPoint);
1036
+
1037
+ // Current thrust/torque arrow debuggers (lengths updated per step)
1038
+ entry.thrustArrow = new THREE.ArrowHelper(
1039
+ new THREE.Vector3(0, state.invertThrust ? -1 : 1, 0),
1040
+ undefined,
1041
+ 0,
1042
+ EC_BLUE
1043
+ );
1044
+ entry.torqueArrow = new THREE.ArrowHelper(
1045
+ new THREE.Vector3(0, state.invertTorque ? -1 : 1, 0),
1046
+ undefined,
1047
+ 0,
1048
+ EC_RED
1049
+ );
1050
+ group.add(entry.thrustArrow, entry.torqueArrow);
1051
+
1052
+ entry.mount.add(group);
1053
+ entry.debugGroup = group;
1054
+ }
1055
+
1056
+ /** Remove and dispose one propeller's debug assets. */
1057
+ private disposePropellerDebug(entry: PropellerEntry): void {
1058
+ if (entry.debugGroup) {
1059
+ entry.debugGroup.removeFromParent();
1060
+ entry.debugGroup = null;
1061
+ }
1062
+ if (entry.thrustArrow) {
1063
+ entry.thrustArrow.dispose();
1064
+ entry.thrustArrow = null;
1065
+ }
1066
+ if (entry.torqueArrow) {
1067
+ entry.torqueArrow.dispose();
1068
+ entry.torqueArrow = null;
1069
+ }
1070
+ for (const disposable of entry.debugDisposables) disposable.dispose();
1071
+ entry.debugDisposables.length = 0;
1072
+ }
1073
+ }