@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.
- package/README.md +1 -0
- package/dist/index.js +203 -4
- package/package.json +7 -2
- package/templates/controllers/NOTICE.md +65 -0
- package/templates/controllers/assets/animation-library.glb +0 -0
- package/templates/controllers/assets/character.glb +0 -0
- package/templates/controllers/assets/default-avatar.vrm +0 -0
- package/templates/controllers/character/character-animations.ts +682 -0
- package/templates/controllers/character/character-controller.ts +1636 -0
- package/templates/controllers/character/follow-camera.ts +644 -0
- package/templates/controllers/character/keyboard-input.ts +277 -0
- package/templates/controllers/character/presets.ts +176 -0
- package/templates/controllers/character/touch-joystick.ts +387 -0
- package/templates/controllers/character/vrm/capsule-fit.ts +52 -0
- package/templates/controllers/character/vrm/foot-ik.ts +341 -0
- package/templates/controllers/character/vrm/vrm-loader.ts +44 -0
- package/templates/controllers/character/vrm/vrm-retarget.ts +195 -0
- package/templates/controllers/drone/drone-controller.ts +1073 -0
- package/templates/controllers/drone/presets.ts +225 -0
- package/templates/controllers/interact/enter-exit.ts +502 -0
- package/templates/controllers/shared/colliders.ts +456 -0
- package/templates/controllers/shared/math.ts +230 -0
- package/templates/controllers/shared/physics-world.ts +622 -0
- package/templates/controllers/vehicle/presets.ts +297 -0
- package/templates/controllers/vehicle/vehicle-controller.ts +615 -0
- package/templates/controllers/vehicle/wheel.ts +1200 -0
- package/templates/skills/genex-getting-started/SKILL.md +5 -0
- package/templates/skills/genex-threejs-character-controller/SKILL.md +205 -0
- package/templates/skills/genex-threejs-character-controller/references/animations.md +235 -0
- package/templates/skills/genex-threejs-character-controller/references/tuning-and-presets.md +102 -0
- package/templates/skills/genex-threejs-character-controller/references/wiring.md +198 -0
- package/templates/skills/genex-threejs-embed-auth/SKILL.md +126 -54
- package/templates/skills/genex-threejs-multiplayer/SKILL.md +17 -11
- package/templates/skills/genex-threejs-physics-rapier/SKILL.md +128 -0
- package/templates/skills/genex-threejs-physics-rapier/references/colliders-from-assets.md +202 -0
- package/templates/skills/genex-threejs-physics-rapier/references/physics-setup.md +207 -0
- package/templates/skills/genex-threejs-skill-router/SKILL.md +3 -0
- package/templates/skills/genex-threejs-skill-router/references/routing-map.md +15 -7
- package/templates/skills/genex-threejs-vehicle-controllers/SKILL.md +110 -0
- package/templates/skills/genex-threejs-vehicle-controllers/references/car.md +162 -0
- package/templates/skills/genex-threejs-vehicle-controllers/references/drone.md +150 -0
- 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
|
+
}
|