@jgengine/core 0.18.1 → 0.19.0

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 (220) hide show
  1. package/CHANGELOG.md +158 -7
  2. package/dist/ai/decisionGraph.d.ts +48 -4
  3. package/dist/ai/decisionGraph.js +153 -23
  4. package/dist/ai/groupAssist.d.ts +10 -0
  5. package/dist/ai/groupAssist.js +11 -0
  6. package/dist/ai/pursuit.d.ts +1 -1
  7. package/dist/ai/pursuit.js +1 -1
  8. package/dist/ai/threat.d.ts +8 -0
  9. package/dist/ai/threat.js +10 -0
  10. package/dist/anim/animGraph.d.ts +11 -0
  11. package/dist/anim/animGraph.js +128 -0
  12. package/dist/anim/boneTexture.d.ts +55 -0
  13. package/dist/anim/boneTexture.js +43 -0
  14. package/dist/anim/footPlacement.d.ts +57 -0
  15. package/dist/anim/footPlacement.js +67 -0
  16. package/dist/anim/locomotionGraph.d.ts +9 -0
  17. package/dist/anim/locomotionGraph.js +29 -0
  18. package/dist/audio/audioFalloff.d.ts +2 -0
  19. package/dist/audio/doppler.d.ts +21 -0
  20. package/dist/audio/doppler.js +32 -0
  21. package/dist/audio/engineLayers.d.ts +83 -0
  22. package/dist/audio/engineLayers.js +115 -0
  23. package/dist/combat/abilityKit.d.ts +1 -0
  24. package/dist/combat/abilityKit.js +1 -0
  25. package/dist/combat/death.d.ts +7 -0
  26. package/dist/combat/shotOrigin.d.ts +6 -0
  27. package/dist/combat/shotOrigin.js +6 -0
  28. package/dist/combat/weaponFeedback.d.ts +36 -0
  29. package/dist/combat/weaponFeedback.js +42 -0
  30. package/dist/combat/weaponHandling.d.ts +161 -0
  31. package/dist/combat/weaponHandling.js +202 -0
  32. package/dist/combat/weaponPresentation.d.ts +115 -0
  33. package/dist/combat/weaponPresentation.js +96 -0
  34. package/dist/devtools/warnOnce.d.ts +8 -0
  35. package/dist/devtools/warnOnce.js +18 -0
  36. package/dist/editor/document.js +3 -1
  37. package/dist/editor/environment.js +8 -0
  38. package/dist/editor/types.d.ts +4 -0
  39. package/dist/faction/reputation.d.ts +6 -0
  40. package/dist/faction/reputation.js +11 -0
  41. package/dist/game/cameraConfig.d.ts +51 -1
  42. package/dist/game/events.d.ts +6 -0
  43. package/dist/game/lootTable.d.ts +4 -0
  44. package/dist/game/lootTable.js +16 -0
  45. package/dist/game/playableGame.d.ts +84 -17
  46. package/dist/game/toasts.d.ts +8 -0
  47. package/dist/game/toasts.js +7 -0
  48. package/dist/game/vfxInstance.d.ts +9 -0
  49. package/dist/game/vfxInstance.js +12 -1
  50. package/dist/game/viewports.d.ts +43 -0
  51. package/dist/game/viewports.js +46 -0
  52. package/dist/game/worldItem.d.ts +5 -1
  53. package/dist/game/worldItem.js +0 -1
  54. package/dist/input/actionBindings.d.ts +15 -1
  55. package/dist/input/actionBindings.js +14 -3
  56. package/dist/input/actionContexts.d.ts +25 -1
  57. package/dist/input/actionContexts.js +82 -25
  58. package/dist/input/axisInput.d.ts +4 -0
  59. package/dist/input/axisInput.js +4 -0
  60. package/dist/input/axisShaper.d.ts +73 -0
  61. package/dist/input/axisShaper.js +105 -0
  62. package/dist/input/gamepadModel.d.ts +36 -2
  63. package/dist/input/gamepadModel.js +69 -30
  64. package/dist/input/haptics.d.ts +50 -0
  65. package/dist/input/haptics.js +74 -0
  66. package/dist/input/inputBuffer.d.ts +40 -0
  67. package/dist/input/inputBuffer.js +59 -0
  68. package/dist/input/lookChannel.d.ts +10 -0
  69. package/dist/input/lookChannel.js +6 -0
  70. package/dist/input/pointer.d.ts +2 -0
  71. package/dist/input/pointer.js +4 -0
  72. package/dist/item/affix.d.ts +6 -0
  73. package/dist/item/affix.js +6 -0
  74. package/dist/meta/changelog.d.ts +1 -1
  75. package/dist/meta/changelog.js +112 -1
  76. package/dist/movement/glideModel.d.ts +3 -0
  77. package/dist/movement/glideModel.js +6 -0
  78. package/dist/movement/grappleSwing.d.ts +2 -0
  79. package/dist/movement/grappleSwing.js +5 -1
  80. package/dist/movement/movementModel.d.ts +39 -0
  81. package/dist/movement/movementModel.js +62 -19
  82. package/dist/movement/movementProbe.d.ts +51 -0
  83. package/dist/movement/movementProbe.js +119 -0
  84. package/dist/movement/playerMovement.d.ts +21 -2
  85. package/dist/movement/playerMovement.js +89 -6
  86. package/dist/movement/solidObstacles.d.ts +9 -1
  87. package/dist/movement/solidObstacles.js +44 -2
  88. package/dist/movement/voxelController.js +7 -7
  89. package/dist/nav/navFromEnvironment.d.ts +9 -3
  90. package/dist/nav/navFromEnvironment.js +18 -12
  91. package/dist/nav/navMesh.d.ts +43 -2
  92. package/dist/nav/navMesh.js +534 -128
  93. package/dist/physics/aircraftDynamics.d.ts +390 -0
  94. package/dist/physics/aircraftDynamics.js +704 -0
  95. package/dist/physics/boatDynamics.d.ts +116 -0
  96. package/dist/physics/boatDynamics.js +160 -0
  97. package/dist/physics/drivableVehicle.d.ts +22 -8
  98. package/dist/physics/drivableVehicle.js +4 -2
  99. package/dist/physics/flightDynamics.d.ts +21 -4
  100. package/dist/physics/flightDynamics.js +28 -5
  101. package/dist/physics/handlingProbe.d.ts +249 -0
  102. package/dist/physics/handlingProbe.js +606 -0
  103. package/dist/physics/kinematicVehicle.d.ts +32 -2
  104. package/dist/physics/kinematicVehicle.js +15 -0
  105. package/dist/physics/physicsBackend.d.ts +12 -0
  106. package/dist/physics/physicsBackendConformance.js +34 -0
  107. package/dist/physics/physicsWorldBackend.d.ts +1 -0
  108. package/dist/physics/physicsWorldBackend.js +33 -0
  109. package/dist/physics/vehicleBackendLink.d.ts +49 -0
  110. package/dist/physics/vehicleBackendLink.js +102 -0
  111. package/dist/physics/vehicleDynamics.d.ts +374 -0
  112. package/dist/physics/vehicleDynamics.js +737 -0
  113. package/dist/physics/worldColliders.d.ts +1 -1
  114. package/dist/physics/worldColliders.js +35 -3
  115. package/dist/puzzle/cellGrid.d.ts +15 -9
  116. package/dist/puzzle/cellGrid.js +13 -9
  117. package/dist/render/lookPreset.d.ts +9 -2
  118. package/dist/render/lookPreset.js +29 -2
  119. package/dist/render/postProcessing.d.ts +30 -0
  120. package/dist/render/postProcessing.js +1 -1
  121. package/dist/runtime/cameraDirector.d.ts +17 -3
  122. package/dist/runtime/cameraDirector.js +25 -0
  123. package/dist/runtime/commandInput.d.ts +37 -0
  124. package/dist/runtime/commandInput.js +58 -0
  125. package/dist/runtime/commandRunner.d.ts +27 -0
  126. package/dist/runtime/commandRunner.js +31 -3
  127. package/dist/runtime/context/combat.d.ts +1 -0
  128. package/dist/runtime/context/combat.js +2 -1
  129. package/dist/runtime/context/deathLoot.d.ts +2 -0
  130. package/dist/runtime/context/deathLoot.js +1 -0
  131. package/dist/runtime/gameContext.js +7 -0
  132. package/dist/runtime/gameContextTypes.d.ts +19 -3
  133. package/dist/runtime/gameRuntime.d.ts +3 -1
  134. package/dist/runtime/gameRuntime.js +4 -0
  135. package/dist/runtime/inputSnapshot.d.ts +6 -0
  136. package/dist/runtime/inputSnapshot.js +10 -0
  137. package/dist/runtime/localPlayers.d.ts +73 -0
  138. package/dist/runtime/localPlayers.js +119 -0
  139. package/dist/runtime/transport.d.ts +9 -0
  140. package/dist/runtime/transport.js +7 -1
  141. package/dist/scene/behaviorRuntime.d.ts +12 -2
  142. package/dist/scene/behaviorRuntime.js +30 -14
  143. package/dist/scene/behaviors.d.ts +5 -1
  144. package/dist/scene/sceneKinds.d.ts +9 -0
  145. package/dist/scene/vehicleSeat.d.ts +5 -1
  146. package/dist/scene/vehicleSeat.js +5 -1
  147. package/dist/sensor/concealment.d.ts +6 -0
  148. package/dist/sensor/concealment.js +8 -0
  149. package/dist/sensor/freezeMonitor.d.ts +6 -0
  150. package/dist/sensor/freezeMonitor.js +8 -0
  151. package/dist/sensor/perception.d.ts +11 -1
  152. package/dist/sensor/perception.js +57 -10
  153. package/dist/sensor/recordingBuffer.d.ts +3 -0
  154. package/dist/sensor/recordingBuffer.js +8 -0
  155. package/dist/session/roundState.d.ts +4 -0
  156. package/dist/session/roundState.js +11 -0
  157. package/dist/settings/frameRateLimit.d.ts +16 -0
  158. package/dist/settings/frameRateLimit.js +37 -0
  159. package/dist/settings/graphicsProfile.d.ts +2 -0
  160. package/dist/settings/graphicsProfile.js +12 -0
  161. package/dist/settings/graphicsSettings.d.ts +40 -0
  162. package/dist/settings/graphicsSettings.js +81 -0
  163. package/dist/settings/settingsModel.d.ts +9 -1
  164. package/dist/settings/settingsModel.js +10 -0
  165. package/dist/stats/eventMeter.d.ts +1 -1
  166. package/dist/stats/eventMeter.js +1 -1
  167. package/dist/vfx/feedbackMixer.d.ts +61 -0
  168. package/dist/vfx/feedbackMixer.js +106 -0
  169. package/dist/visibility/simulationCulling.d.ts +8 -1
  170. package/dist/visibility/simulationCulling.js +7 -0
  171. package/dist/visibility/spatialIndex.d.ts +21 -0
  172. package/dist/visibility/spatialIndex.js +16 -0
  173. package/dist/world/authoredSolids.d.ts +46 -0
  174. package/dist/world/authoredSolids.js +75 -0
  175. package/dist/world/buildingGenerator.js +2 -2
  176. package/dist/world/buildingKit.d.ts +3 -1
  177. package/dist/world/buildings.d.ts +34 -2
  178. package/dist/world/buildings.js +11 -1
  179. package/dist/world/cityKind.d.ts +7 -0
  180. package/dist/world/cityKind.js +52 -1
  181. package/dist/world/features.d.ts +29 -2
  182. package/dist/world/features.js +6 -0
  183. package/dist/world/footprintGrid.d.ts +7 -0
  184. package/dist/world/footprintGrid.js +16 -0
  185. package/dist/world/interiors.d.ts +1 -0
  186. package/dist/world/interiors.js +5 -3
  187. package/dist/world/lod.d.ts +6 -0
  188. package/dist/world/lod.js +8 -0
  189. package/dist/world/terraform.d.ts +8 -0
  190. package/dist/world/terraform.js +7 -0
  191. package/dist/world/walls.d.ts +7 -0
  192. package/dist/world/walls.js +7 -0
  193. package/dist/world/worldSolids.d.ts +84 -0
  194. package/dist/world/worldSolids.js +240 -0
  195. package/dist/world.d.ts +2 -2
  196. package/dist/world.js +1 -1
  197. package/package.json +1 -1
  198. package/skills/ce-handoff/SKILL.md +1 -1
  199. package/skills/game-design/SKILL.md +2 -2
  200. package/skills/jgengine/SKILL.md +7 -5
  201. package/skills/jgengine/api.md +118 -89
  202. package/skills/jgengine/capabilities.md +28 -0
  203. package/skills/jgengine/recipes/minimal-game.md +3 -0
  204. package/skills/jgengine-combat/SKILL.md +5 -0
  205. package/skills/jgengine-combat/api.md +138 -103
  206. package/skills/jgengine-combat/capabilities.md +29 -1
  207. package/skills/jgengine-combat/recipes/weapon-feel.md +50 -0
  208. package/skills/jgengine-gameplay/api.md +462 -436
  209. package/skills/jgengine-gameplay/capabilities.md +24 -0
  210. package/skills/jgengine-gameplay/reference-systems.md +4 -0
  211. package/skills/jgengine-world/SKILL.md +15 -3
  212. package/skills/jgengine-world/api.md +873 -626
  213. package/skills/jgengine-world/capabilities.md +130 -6
  214. package/skills/jgengine-world/recipes/character-animation.md +39 -0
  215. package/skills/jgengine-world/recipes/controls.md +44 -0
  216. package/skills/jgengine-world/recipes/flight-feel.md +76 -0
  217. package/skills/jgengine-world/recipes/npc-senses.md +43 -0
  218. package/skills/jgengine-world/recipes/vehicle-feel.md +85 -0
  219. package/skills/jgengine-world/reference.md +31 -2
  220. package/skills/jgengine-world/visibility.md +112 -0
@@ -0,0 +1,704 @@
1
+ import { uniformGravity } from "./gravityField.js";
2
+ const DEFAULT_SUBSTEP = 1 / 240;
3
+ const SEA_LEVEL_DENSITY = 1.225;
4
+ /** Quaternion for a heading about world up, forward `[sin h, 0, cos h]`. */
5
+ export function aircraftHeadingQuaternion(heading) {
6
+ return [0, Math.sin(heading / 2), 0, Math.cos(heading / 2)];
7
+ }
8
+ /**
9
+ * Quaternion from heading, nose-up pitch and right bank, rad — the inverse of a step's `heading`/`pitch`/`bank`.
10
+ * Matches three.js `Euler(-pitch, heading, bank, "YXZ")`.
11
+ */
12
+ export function aircraftAttitudeQuaternion(heading, pitch, bank) {
13
+ const [x1, y1, z1, w1] = aircraftHeadingQuaternion(heading);
14
+ const hx = -pitch / 2;
15
+ const [x2, w2] = [Math.sin(hx), Math.cos(hx)];
16
+ const hz = bank / 2;
17
+ const [z3, w3] = [Math.sin(hz), Math.cos(hz)];
18
+ const ax = w1 * x2 + x1 * w2;
19
+ const ay = y1 * w2 + z1 * x2;
20
+ const az = z1 * w2 - y1 * x2;
21
+ const aw = w1 * w2 - x1 * x2;
22
+ return [ax * w3 + ay * z3, ay * w3 - ax * z3, az * w3 + aw * z3, aw * w3 - az * z3];
23
+ }
24
+ function prepareSurface(surface) {
25
+ const [ax, ay, az] = surface.at;
26
+ let [nx, ny, nz] = surface.normal ?? [0, 1, 0];
27
+ nz = 0;
28
+ const nl = Math.hypot(nx, ny) || 1;
29
+ nx /= nl;
30
+ ny /= nl;
31
+ // Span s = n × forward; the chord always runs along body forward.
32
+ const sx = ny;
33
+ const sy = -nx;
34
+ const sz = 0;
35
+ // The lever arm at × n says which way this surface's lift turns the body; controls are signed from it.
36
+ const mx = ay * nz - az * ny;
37
+ const my = az * nx - ax * nz;
38
+ const mz = ax * ny - ay * nx;
39
+ const signed = (gain, axis) => (gain === undefined || Math.abs(axis) < 1e-9 ? 0 : Math.sign(axis) * gain);
40
+ return {
41
+ ax,
42
+ ay,
43
+ az,
44
+ nx,
45
+ ny,
46
+ nz,
47
+ sx,
48
+ sy,
49
+ sz,
50
+ area: surface.area,
51
+ slope: surface.liftSlope ?? 5,
52
+ stall: surface.stallAngle ?? 0.28,
53
+ postStall: surface.postStallLift ?? 0.6,
54
+ incidence: surface.incidence ?? 0,
55
+ cd0: surface.cd0 ?? 0.02,
56
+ k: surface.inducedDrag ?? 0.06,
57
+ kPitch: signed(surface.control?.pitch, -mx),
58
+ kRoll: signed(surface.control?.roll, mz),
59
+ kYaw: signed(surface.control?.yaw, -my),
60
+ };
61
+ }
62
+ function wrapAngle(angle) {
63
+ if (angle > Math.PI)
64
+ return angle - 2 * Math.PI;
65
+ if (angle < -Math.PI)
66
+ return angle + 2 * Math.PI;
67
+ return angle;
68
+ }
69
+ /** Lift coefficient: linear to the stall, then a flat-plate `sin 2α` curve scaled to `postStall` of peak. */
70
+ function liftCoefficient(alpha, slope, stall, postStall) {
71
+ const magnitude = Math.abs(alpha);
72
+ if (magnitude <= stall)
73
+ return { cl: slope * alpha, stalled: 0 };
74
+ const peak = slope * stall;
75
+ const plate = (Math.sin(2 * alpha) * peak * postStall) / Math.max(1e-6, Math.sin(2 * stall));
76
+ const blend = Math.exp(-(magnitude - stall) / 0.04);
77
+ return { cl: plate + (Math.sign(alpha) * peak - plate) * blend, stalled: 1 - blend };
78
+ }
79
+ /**
80
+ * Creates a {@link RigidAircraft}: a quaternion rigid body with a diagonal inertia tensor, pushed by lifting surfaces,
81
+ * fuselage drag, an engine and gravity. Controls deflect surfaces through rate-limited actuators; nothing commands a
82
+ * rotation rate, so a jet loops when its tail can push the nose around and a glider stalls when it runs out of speed.
83
+ * Fixed internal substeps make it deterministic for a given `dt` sequence.
84
+ * @capability rigid-aircraft force-and-torque aircraft — lifting surfaces with stall and drag polar, control surfaces, inertia, loops and rolls
85
+ */
86
+ export function createRigidAircraft(initial, options = {}) {
87
+ let tuning = initial;
88
+ let surfaces = initial.surfaces.map(prepareSurface);
89
+ let largest = largestSurface(surfaces);
90
+ const windAt = options.wind;
91
+ const groundAt = options.groundHeight ?? (() => 0);
92
+ const gravityField = options.gravityField ?? uniformGravity([0, -9.81, 0]);
93
+ const densityAt = options.airDensity ?? (() => SEA_LEVEL_DENSITY);
94
+ const state = fresh(options.position ?? [0, 0, 0], options.orientation ?? aircraftHeadingQuaternion(options.heading ?? 0));
95
+ if (options.velocity !== undefined)
96
+ [state.vx, state.vy, state.vz] = options.velocity;
97
+ const telemetry = { airspeed: 0, alpha: 0, sideslip: 0, gLoad: 1, stallFraction: 0, stalled: false, thrust: 0, grounded: false };
98
+ const rotorTelemetry = { thrust: 0, torque: 0, groundEffect: 1, translationalLift: 1 };
99
+ const motorTelemetry = { thrust: 0 };
100
+ const lastCommand = { pitch: 0, roll: 0, yaw: 0 };
101
+ let limited = false;
102
+ function largestSurface(list) {
103
+ let best = -1;
104
+ for (let i = 0; i < list.length; i += 1)
105
+ if (best < 0 || list[i].area > list[best].area)
106
+ best = i;
107
+ return best;
108
+ }
109
+ function fresh(position, orientation) {
110
+ const [qx, qy, qz, qw] = orientation;
111
+ const n = Math.hypot(qx, qy, qz, qw) || 1;
112
+ return {
113
+ x: position[0],
114
+ y: position[1],
115
+ z: position[2],
116
+ vx: 0,
117
+ vy: 0,
118
+ vz: 0,
119
+ qx: qx / n,
120
+ qy: qy / n,
121
+ qz: qz / n,
122
+ qw: qw / n,
123
+ wx: 0,
124
+ wy: 0,
125
+ wz: 0,
126
+ pitchDeflection: 0,
127
+ rollDeflection: 0,
128
+ yawDeflection: 0,
129
+ spool: 0,
130
+ rotorSpeed: 0,
131
+ burnTime: 0,
132
+ propellantKg: tuning.motor?.propellantKg ?? 0,
133
+ holdPitch: 0,
134
+ holdRoll: 0,
135
+ holdYaw: 0,
136
+ trimPitch: 0,
137
+ trimRoll: 0,
138
+ trimYaw: 0,
139
+ driftX: 0,
140
+ driftZ: 0,
141
+ time: 0,
142
+ };
143
+ }
144
+ // Scratch for rotating vectors without allocating in the substep.
145
+ const out = [0, 0, 0];
146
+ function toWorld(x, y, z) {
147
+ const { qx, qy, qz, qw } = state;
148
+ const tx = 2 * (qy * z - qz * y);
149
+ const ty = 2 * (qz * x - qx * z);
150
+ const tz = 2 * (qx * y - qy * x);
151
+ out[0] = x + qw * tx + (qy * tz - qz * ty);
152
+ out[1] = y + qw * ty + (qz * tx - qx * tz);
153
+ out[2] = z + qw * tz + (qx * ty - qy * tx);
154
+ }
155
+ function toBody(x, y, z) {
156
+ const { qx, qy, qz, qw } = state;
157
+ const tx = 2 * (-qy * z + qz * y);
158
+ const ty = 2 * (-qz * x + qx * z);
159
+ const tz = 2 * (-qx * y + qy * x);
160
+ out[0] = x + qw * tx + (-qy * tz + qz * ty);
161
+ out[1] = y + qw * ty + (-qz * tx + qx * tz);
162
+ out[2] = z + qw * tz + (-qx * ty + qy * tx);
163
+ }
164
+ function slew(current, target, rate, h) {
165
+ const step = rate * h;
166
+ return current + Math.max(-step, Math.min(step, target - current));
167
+ }
168
+ function substep(h, input, modifiers) {
169
+ const t = tuning;
170
+ const m = t.massKg + (t.motor === undefined ? 0 : state.propellantKg);
171
+ const controls = t.controls;
172
+ const pitchCh = controls?.pitch;
173
+ const rollCh = controls?.roll;
174
+ const yawCh = controls?.yaw;
175
+ const engine = t.engine;
176
+ const throttle = Math.max(0, Math.min(1, input.throttle));
177
+ state.spool += (throttle - state.spool) * (1 - Math.exp(-(engine?.spoolRate ?? 1.5) * h));
178
+ let wvx = state.vx;
179
+ let wvy = state.vy;
180
+ let wvz = state.vz;
181
+ if (windAt !== undefined) {
182
+ const wind = windAt([state.x, state.y, state.z]);
183
+ wvx -= wind[0];
184
+ wvy -= wind[1];
185
+ wvz -= wind[2];
186
+ }
187
+ toBody(wvx, wvy, wvz);
188
+ const bx = out[0];
189
+ const by = out[1];
190
+ const bz = out[2];
191
+ const command = t.assists === undefined ? pilotCommand(input) : assist(t.assists, input, h, bx, by, bz);
192
+ lastCommand.pitch = command.pitch;
193
+ lastCommand.roll = command.roll;
194
+ lastCommand.yaw = command.yaw;
195
+ state.pitchDeflection = slew(state.pitchDeflection, command.pitch * (pitchCh?.maxDeflection ?? 0.35), pitchCh?.rate ?? 2, h);
196
+ state.rollDeflection = slew(state.rollDeflection, command.roll * (rollCh?.maxDeflection ?? 0.35), rollCh?.rate ?? 2, h);
197
+ state.yawDeflection = slew(state.yawDeflection, command.yaw * (yawCh?.maxDeflection ?? 0.35), yawCh?.rate ?? 2, h);
198
+ const rho = densityAt(state.y);
199
+ const { wx, wy, wz } = state;
200
+ const liftScale = modifiers?.liftScale ?? 1;
201
+ const dragScale = modifiers?.dragScale ?? 1;
202
+ let fx = 0;
203
+ let fy = 0;
204
+ let fz = 0;
205
+ let mx = modifiers?.torque?.[0] ?? 0;
206
+ let my = modifiers?.torque?.[1] ?? 0;
207
+ let mz = modifiers?.torque?.[2] ?? 0;
208
+ let stalledArea = 0;
209
+ let totalArea = 0;
210
+ let largestStalled = false;
211
+ for (let i = 0; i < surfaces.length; i += 1) {
212
+ const s = surfaces[i];
213
+ totalArea += s.area;
214
+ // Local airflow includes the body's rotation (ω × r): that is where pitch, roll and yaw damping come from.
215
+ const px = bx + (wy * s.az - wz * s.ay);
216
+ const py = by + (wz * s.ax - wx * s.az);
217
+ const pz = bz + (wx * s.ay - wy * s.ax);
218
+ const un = px * s.nx + py * s.ny + pz * s.nz;
219
+ const uf = pz;
220
+ const speed2 = un * un + uf * uf;
221
+ if (speed2 < 1e-8)
222
+ continue;
223
+ const alpha = wrapAngle(Math.atan2(-un, uf) + s.incidence + s.kPitch * state.pitchDeflection + s.kRoll * state.rollDeflection + s.kYaw * state.yawDeflection);
224
+ const { cl, stalled } = liftCoefficient(alpha, s.slope, s.stall, s.postStall);
225
+ // Below ~1 m/s of local flow the angle of attack is noise, not a stall.
226
+ if (stalled > 0.5 && speed2 > 1) {
227
+ stalledArea += s.area;
228
+ if (i === largest)
229
+ largestStalled = true;
230
+ }
231
+ const cd = s.cd0 + s.k * cl * cl + 1.28 * Math.sin(alpha) ** 2 * stalled;
232
+ const speed = Math.sqrt(speed2);
233
+ // d: direction the air moves past the surface; lift is perpendicular to it, in the plane across the span.
234
+ const dx = -(un * s.nx) / speed;
235
+ const dy = -(un * s.ny) / speed;
236
+ const dz = -(un * s.nz + uf) / speed;
237
+ const lx = s.sy * dz - s.sz * dy;
238
+ const ly = s.sz * dx - s.sx * dz;
239
+ const lz = s.sx * dy - s.sy * dx;
240
+ const q = 0.5 * rho * speed2 * s.area;
241
+ const lift = q * cl * liftScale;
242
+ const drag = q * cd * dragScale;
243
+ const sfx = lift * lx + drag * dx;
244
+ const sfy = lift * ly + drag * dy;
245
+ const sfz = lift * lz + drag * dz;
246
+ fx += sfx;
247
+ fy += sfy;
248
+ fz += sfz;
249
+ mx += s.ay * sfz - s.az * sfy;
250
+ my += s.az * sfx - s.ax * sfz;
251
+ mz += s.ax * sfy - s.ay * sfx;
252
+ }
253
+ const airspeed = Math.hypot(bx, by, bz);
254
+ if (t.dragArea !== undefined && airspeed > 0) {
255
+ const k = -0.5 * rho * t.dragArea * airspeed * dragScale;
256
+ fx += k * bx;
257
+ fy += k * by;
258
+ fz += k * bz;
259
+ }
260
+ let thrust = 0;
261
+ if (engine !== undefined) {
262
+ thrust = engine.maxThrust * state.spool * (modifiers?.thrustScale ?? 1);
263
+ const [tdx, tdy, tdz] = engine.direction ?? [0, 0, 1];
264
+ const tl = Math.hypot(tdx, tdy, tdz) || 1;
265
+ const tx = (thrust * tdx) / tl;
266
+ const ty = (thrust * tdy) / tl;
267
+ const tz = (thrust * tdz) / tl;
268
+ fx += tx;
269
+ fy += ty;
270
+ fz += tz;
271
+ if (engine.at !== undefined) {
272
+ const [ax, ay, az] = engine.at;
273
+ mx += ay * tz - az * ty;
274
+ my += az * tx - ax * tz;
275
+ mz += ax * ty - ay * tx;
276
+ }
277
+ }
278
+ const motor = t.motor;
279
+ if (motor !== undefined) {
280
+ const curve = motor.thrustCurve;
281
+ const lastTime = curve.length === 0 ? 0 : curve[curve.length - 1][0];
282
+ let motorThrust = 0;
283
+ if (throttle > 0 && state.propellantKg > 0 && state.burnTime < lastTime) {
284
+ const rated = sampleCurve(curve, state.burnTime) * throttle;
285
+ const flow = (motor.massFlow * rated) / Math.max(1e-9, peakOf(curve));
286
+ const burned = Math.min(state.propellantKg, flow * h);
287
+ // The last sliver of propellant only delivers its share of the substep.
288
+ motorThrust = rated * (flow > 0 ? burned / (flow * h) : 1) * (modifiers?.thrustScale ?? 1);
289
+ state.propellantKg -= burned;
290
+ if (state.propellantKg < 1e-9)
291
+ state.propellantKg = 0;
292
+ state.burnTime += h;
293
+ }
294
+ const [nx, ny, nz] = motor.at ?? [0, 0, -1];
295
+ const gimbal = motor.gimbal ?? 0;
296
+ // A nozzle behind the centre of mass swings its thrust the opposite way to the turn it makes.
297
+ const lever = nz <= 0 ? 1 : -1;
298
+ const gp = (state.pitchDeflection / (pitchCh?.maxDeflection ?? 0.35)) * gimbal;
299
+ const gy = (state.yawDeflection / (yawCh?.maxDeflection ?? 0.35)) * gimbal;
300
+ const tx = motorThrust * lever * Math.sin(gy);
301
+ const ty = -motorThrust * lever * Math.sin(gp);
302
+ const tz = motorThrust * Math.sqrt(Math.max(0, 1 - Math.sin(gy) ** 2 - Math.sin(gp) ** 2));
303
+ fx += tx;
304
+ fy += ty;
305
+ fz += tz;
306
+ mx += ny * tz - nz * ty;
307
+ my += nz * tx - nx * tz;
308
+ mz += nx * ty - ny * tx;
309
+ thrust += motorThrust;
310
+ motorTelemetry.thrust = motorThrust;
311
+ }
312
+ const rotor = t.rotor;
313
+ if (rotor !== undefined) {
314
+ state.rotorSpeed += (throttle - state.rotorSpeed) * (1 - Math.exp(-(rotor.spoolRate ?? 0.5) * h));
315
+ const [hx, hy, hz] = rotor.at ?? [0, 1.5, 0];
316
+ const cyclic = rotor.cyclic ?? 0.15;
317
+ const tiltPitch = (state.pitchDeflection / (pitchCh?.maxDeflection ?? 0.35)) * cyclic;
318
+ const tiltRoll = (state.rollDeflection / (rollCh?.maxDeflection ?? 0.35)) * cyclic;
319
+ // Nose-up cyclic tilts the disc back (−forward), right cyclic tilts it right (−left).
320
+ let dx = -Math.sin(tiltRoll);
321
+ let dz = -Math.sin(tiltPitch);
322
+ let dy = Math.sqrt(Math.max(0, 1 - dx * dx - dz * dz));
323
+ const collective = clamp01(input.collective ?? 0);
324
+ const rpm2 = state.rotorSpeed * state.rotorSpeed;
325
+ const nominal = rotor.maxThrust * collective * rpm2;
326
+ const disc = Math.PI * rotor.radius * rotor.radius;
327
+ // Momentum theory: hover induced velocity v_i = √(T / 2ρA). Clean air arriving at about v_i adds translational
328
+ // lift; climbing through the disc at w cuts thrust by roughly w / 2v_i, which damps vertical motion.
329
+ const induced = Math.sqrt(Math.max(nominal, rotor.maxThrust * 0.05) / (2 * rho * disc));
330
+ const hubVx = bx + (wy * hz - wz * hy);
331
+ const hubVy = by + (wz * hx - wx * hz);
332
+ const hubVz = bz + (wx * hy - wy * hx);
333
+ const edgewise = Math.hypot(hubVx, hubVz);
334
+ const translational = 1 + (rotor.translationalLift ?? 0.2) * (1 - Math.exp(-((edgewise / induced) ** 2)));
335
+ const inflow = Math.max(0.2, Math.min(1.5, 1 - hubVy / (2 * induced)));
336
+ toWorld(hx, hy, hz);
337
+ const clearance = Math.max(rotor.radius / 2, state.y + out[1] - groundAt(state.x, state.z));
338
+ // Cheeseman–Bennett: T_IGE / T_OGE = 1 / (1 − (R / 4z)²).
339
+ const groundEffect = 1 / (1 - (rotor.radius / (4 * clearance)) ** 2);
340
+ const rotorThrust = nominal * translational * inflow * groundEffect;
341
+ dx *= rotorThrust;
342
+ dy *= rotorThrust;
343
+ dz *= rotorThrust;
344
+ fx += dx;
345
+ fy += dy;
346
+ fz += dz;
347
+ mx += hy * dz - hz * dy;
348
+ my += hz * dx - hx * dz;
349
+ mz += hx * dy - hy * dx;
350
+ const torque = rotor.torque * collective * rpm2;
351
+ my += torque;
352
+ const damping = rotor.damping ?? 0;
353
+ mx -= damping * wx;
354
+ mz -= damping * wz;
355
+ if (rotor.tail !== undefined) {
356
+ const [, ty, tz] = rotor.tail.at;
357
+ const side = bx + (wy * tz - wz * ty);
358
+ const tailThrust = rotor.tail.maxThrust * rpm2 * (state.yawDeflection / (yawCh?.maxDeflection ?? 0.35)) -
359
+ (rotor.tail.sideDamping ?? rotor.tail.maxThrust / 10) * state.rotorSpeed * side;
360
+ fx += tailThrust;
361
+ // A sideways force along +left at the tail: moment = at × (F, 0, 0).
362
+ my += tz * tailThrust;
363
+ mz -= ty * tailThrust;
364
+ }
365
+ rotorTelemetry.thrust = rotorThrust;
366
+ rotorTelemetry.torque = torque;
367
+ rotorTelemetry.groundEffect = groundEffect;
368
+ rotorTelemetry.translationalLift = translational;
369
+ }
370
+ toWorld(fx, fy, fz);
371
+ let ax = out[0] / m;
372
+ let ay = out[1] / m;
373
+ let az = out[2] / m;
374
+ if (modifiers?.force !== undefined) {
375
+ ax += modifiers.force[0] / m;
376
+ ay += modifiers.force[1] / m;
377
+ az += modifiers.force[2] / m;
378
+ }
379
+ const gravity = gravityField.sample([state.x, state.y, state.z]);
380
+ const gear = t.gear;
381
+ const floor = gear === undefined ? Number.NEGATIVE_INFINITY : groundAt(state.x, state.z) + gear.height;
382
+ let grounded = gear !== undefined && state.y <= floor + 1e-4;
383
+ let supportY = 0;
384
+ if (grounded && gear !== undefined) {
385
+ const netY = ay + gravity[1];
386
+ if (netY <= 0) {
387
+ supportY = -netY;
388
+ const load = supportY;
389
+ // Heading-aligned tyre frame on flat ground: rolling resistance and brakes along, grip across.
390
+ toWorld(0, 0, 1);
391
+ const hl = Math.hypot(out[0], out[2]) || 1;
392
+ const hx = out[0] / hl;
393
+ const hz = out[2] / hl;
394
+ const along = state.vx * hx + state.vz * hz;
395
+ const across = -state.vx * hz + state.vz * hx;
396
+ const roll = (gear.rollingResistance ?? 0.02) * load + clamp01(input.brake ?? 0) * (gear.brakeDecel ?? 4);
397
+ const alongDv = Math.min(Math.abs(along), roll * h) * Math.sign(along);
398
+ const acrossDv = Math.min(Math.abs(across), (gear.sideGrip ?? 0.8) * load * h) * Math.sign(across);
399
+ ax -= (alongDv * hx - acrossDv * hz) / h;
400
+ az -= (alongDv * hz + acrossDv * hx) / h;
401
+ }
402
+ else {
403
+ grounded = false;
404
+ }
405
+ }
406
+ state.vx += (ax + gravity[0]) * h;
407
+ state.vy += (ay + gravity[1] + supportY) * h;
408
+ state.vz += (az + gravity[2]) * h;
409
+ state.x += state.vx * h;
410
+ state.y += state.vy * h;
411
+ state.z += state.vz * h;
412
+ const inertiaScale = m / t.massKg;
413
+ const ip = t.inertia.pitch * inertiaScale;
414
+ const iy = t.inertia.yaw * inertiaScale;
415
+ const ir = t.inertia.roll * inertiaScale;
416
+ // Euler's rotation equation, body frame: I·ω̇ = M − ω × (I·ω).
417
+ const gx = wy * (ir * wz) - wz * (iy * wy);
418
+ const gy = wz * (ip * wx) - wx * (ir * wz);
419
+ const gz = wx * (iy * wy) - wy * (ip * wx);
420
+ state.wx += ((mx - gx) / ip) * h;
421
+ state.wy += ((my - gy) / iy) * h;
422
+ state.wz += ((mz - gz) / ir) * h;
423
+ integrateOrientation(h);
424
+ if (gear !== undefined) {
425
+ const ground = groundAt(state.x, state.z) + gear.height;
426
+ if (state.y < ground) {
427
+ state.y = ground;
428
+ if (state.vy < 0)
429
+ state.vy = 0;
430
+ grounded = true;
431
+ }
432
+ if (grounded)
433
+ settleOnGear(gear.maxPitch ?? 0.25);
434
+ }
435
+ const g0 = Math.max(1e-3, Math.hypot(gravity[0], gravity[1], gravity[2]));
436
+ // Specific force (everything but gravity) along body up, over g: what the pilot's seat feels.
437
+ toBody(ax, ay + supportY, az);
438
+ telemetry.gLoad = out[1] / g0;
439
+ telemetry.airspeed = airspeed;
440
+ telemetry.alpha = airspeed > 1e-3 ? Math.atan2(-by, bz) : 0;
441
+ telemetry.sideslip = airspeed > 1e-3 ? Math.asin(Math.max(-1, Math.min(1, -bx / airspeed))) : 0;
442
+ telemetry.stallFraction = totalArea > 0 ? stalledArea / totalArea : 0;
443
+ telemetry.stalled = largestStalled;
444
+ telemetry.thrust = thrust;
445
+ telemetry.grounded = grounded;
446
+ state.time += h;
447
+ }
448
+ function pilotCommand(input) {
449
+ limited = false;
450
+ return { pitch: clamp1(input.pitch), roll: clamp1(input.roll), yaw: clamp1(input.yaw) };
451
+ }
452
+ function assist(a, input, h, bx, by, bz) {
453
+ const pilot = pilotCommand(input);
454
+ const attitude = attitudeOf(state);
455
+ const q = -state.wx;
456
+ const r = -state.wy;
457
+ const p = state.wz;
458
+ const airspeed = Math.hypot(bx, by, bz);
459
+ const alpha = airspeed > 1 ? Math.atan2(-by, bz) : 0;
460
+ const g0 = 9.81;
461
+ const gLoad = (airspeed * q) / g0 + Math.cos(attitude.pitch) * Math.cos(attitude.bank);
462
+ const hands = { pitch: 1 - Math.abs(pilot.pitch), roll: 1 - Math.abs(pilot.roll), yaw: 1 - Math.abs(pilot.yaw) };
463
+ const command = { ...pilot };
464
+ const level = a.autoLevel ?? 0;
465
+ const hover = a.hoverHold ?? 0;
466
+ const sas = a.sas;
467
+ if (sas !== undefined) {
468
+ const full = Math.max(1e-3, a.sasRate ?? 0.5);
469
+ const holdsAttitude = level === 0 && hover === 0;
470
+ // Hands-off, SAS is a PID on body angle: rate (D), angle since release (P) and a learned trim (I).
471
+ const axis = (strength, stick, handsFree, rate, hold, trim, holds) => {
472
+ const k = strength ?? 0;
473
+ const active = Math.abs(stick) > 0.05;
474
+ const nextHold = active || !holds ? 0 : hold + rate * h;
475
+ const nextTrim = active || !holds || k === 0 ? trim : trim + nextHold * 0.5 * h;
476
+ return { hold: nextHold, trim: nextTrim, command: -(k * handsFree * (rate + nextHold + nextTrim)) / full };
477
+ };
478
+ const pitchAxis = axis(sas.pitch, pilot.pitch, hands.pitch, q, state.holdPitch, state.trimPitch, holdsAttitude);
479
+ const rollAxis = axis(sas.roll, pilot.roll, hands.roll, p, state.holdRoll, state.trimRoll, holdsAttitude);
480
+ const yawAxis = axis(sas.yaw, pilot.yaw, hands.yaw, r, state.holdYaw, state.trimYaw, true);
481
+ state.holdPitch = pitchAxis.hold;
482
+ state.trimPitch = pitchAxis.trim;
483
+ state.holdRoll = rollAxis.hold;
484
+ state.trimRoll = rollAxis.trim;
485
+ state.holdYaw = yawAxis.hold;
486
+ state.trimYaw = yawAxis.trim;
487
+ command.pitch += pitchAxis.command;
488
+ command.roll += rollAxis.command;
489
+ command.yaw += yawAxis.command;
490
+ }
491
+ if (level > 0) {
492
+ command.roll -= (level * hands.roll * (attitude.bank + 0.5 * p)) / 0.5;
493
+ command.pitch -= (level * hands.pitch * (attitude.pitch + 0.5 * q)) / 0.5;
494
+ }
495
+ const hfx = Math.sin(attitude.heading);
496
+ const hfz = Math.cos(attitude.heading);
497
+ const forward = state.vx * hfx + state.vz * hfz;
498
+ const right = -state.vx * hfz + state.vz * hfx;
499
+ if (hover > 0) {
500
+ const cyclicFree = Math.abs(pilot.pitch) <= 0.05 && Math.abs(pilot.roll) <= 0.05;
501
+ state.driftX = cyclicFree ? state.driftX + state.vx * h : 0;
502
+ state.driftZ = cyclicFree ? state.driftZ + state.vz * h : 0;
503
+ const driftForward = state.driftX * hfx + state.driftZ * hfz;
504
+ const driftRight = -state.driftX * hfz + state.driftZ * hfx;
505
+ // Position → velocity → attitude → rate: tilt against drift plus distance moved, capped at 0.25 rad.
506
+ const pitchTarget = Math.max(-0.25, Math.min(0.25, 0.05 * (forward + 0.3 * driftForward)));
507
+ const bankTarget = Math.max(-0.25, Math.min(0.25, -0.05 * (right + 0.3 * driftRight)));
508
+ command.pitch += hover * hands.pitch * (4 * (pitchTarget - attitude.pitch) - q);
509
+ command.roll += hover * hands.roll * (4 * (bankTarget - attitude.bank) - p);
510
+ }
511
+ limited = false;
512
+ if (a.maxAngleOfAttack !== undefined && airspeed > 1) {
513
+ // Lead the limit by where the pitch rate is taking the angle of attack.
514
+ const predicted = alpha + 0.1 * q;
515
+ const ceiling = (a.maxAngleOfAttack - predicted) / 0.1;
516
+ const floor = (-a.maxAngleOfAttack - predicted) / 0.1;
517
+ if (command.pitch > ceiling || command.pitch < floor)
518
+ limited = true;
519
+ command.pitch = Math.max(floor, Math.min(ceiling, command.pitch));
520
+ }
521
+ if (a.maxG !== undefined) {
522
+ const ceiling = a.maxG - gLoad;
523
+ if (command.pitch > ceiling) {
524
+ command.pitch = ceiling;
525
+ limited = true;
526
+ }
527
+ }
528
+ if (a.policy !== undefined) {
529
+ const out = a.policy({
530
+ dt: h,
531
+ airspeed,
532
+ angleOfAttack: alpha,
533
+ heading: attitude.heading,
534
+ pitch: attitude.pitch,
535
+ bank: attitude.bank,
536
+ pitchRate: q,
537
+ yawRate: r,
538
+ rollRate: p,
539
+ velocity: [state.vx, state.vy, state.vz],
540
+ forward,
541
+ right,
542
+ gLoad,
543
+ heightAboveGround: state.y - groundAt(state.x, state.z),
544
+ pilot: input,
545
+ }, command);
546
+ command.pitch = out.pitch;
547
+ command.roll = out.roll;
548
+ command.yaw = out.yaw;
549
+ }
550
+ command.pitch = clamp1(command.pitch);
551
+ command.roll = clamp1(command.roll);
552
+ command.yaw = clamp1(command.yaw);
553
+ return command;
554
+ }
555
+ function integrateOrientation(h) {
556
+ const { qx, qy, qz, qw, wx, wy, wz } = state;
557
+ // q̇ = ½ q ⊗ (ω, 0) with ω in the body frame.
558
+ const nx = qx + 0.5 * h * (qw * wx + qy * wz - qz * wy);
559
+ const ny = qy + 0.5 * h * (qw * wy + qz * wx - qx * wz);
560
+ const nz = qz + 0.5 * h * (qw * wz + qx * wy - qy * wx);
561
+ const nw = qw - 0.5 * h * (qx * wx + qy * wy + qz * wz);
562
+ const n = Math.hypot(nx, ny, nz, nw) || 1;
563
+ state.qx = nx / n;
564
+ state.qy = ny / n;
565
+ state.qz = nz / n;
566
+ state.qw = nw / n;
567
+ }
568
+ function settleOnGear(maxPitch) {
569
+ const attitude = attitudeOf(state);
570
+ const pitch = Math.max(0, Math.min(maxPitch, attitude.pitch));
571
+ if (pitch === attitude.pitch && attitude.bank === 0)
572
+ return;
573
+ const [qx, qy, qz, qw] = aircraftAttitudeQuaternion(attitude.heading, pitch, 0);
574
+ state.qx = qx;
575
+ state.qy = qy;
576
+ state.qz = qz;
577
+ state.qw = qw;
578
+ state.wz = 0;
579
+ if (pitch !== attitude.pitch)
580
+ state.wx = 0;
581
+ }
582
+ function stepOf() {
583
+ const attitude = attitudeOf(state);
584
+ return {
585
+ position: [state.x, state.y, state.z],
586
+ velocity: [state.vx, state.vy, state.vz],
587
+ orientation: [state.qx, state.qy, state.qz, state.qw],
588
+ heading: attitude.heading,
589
+ pitch: attitude.pitch,
590
+ bank: attitude.bank,
591
+ pitchRate: -state.wx,
592
+ yawRate: -state.wy,
593
+ rollRate: state.wz,
594
+ airspeed: telemetry.airspeed,
595
+ angleOfAttack: telemetry.alpha,
596
+ sideslip: telemetry.sideslip,
597
+ gLoad: telemetry.gLoad,
598
+ stalled: telemetry.stalled,
599
+ stallFraction: telemetry.stallFraction,
600
+ thrust: telemetry.thrust,
601
+ deflection: { pitch: state.pitchDeflection, roll: state.rollDeflection, yaw: state.yawDeflection },
602
+ grounded: telemetry.grounded,
603
+ massKg: tuning.massKg + (tuning.motor === undefined ? 0 : state.propellantKg),
604
+ command: { ...lastCommand },
605
+ limited,
606
+ ...(tuning.motor === undefined
607
+ ? {}
608
+ : {
609
+ motor: {
610
+ thrust: motorTelemetry.thrust,
611
+ burnTime: state.burnTime,
612
+ propellantKg: state.propellantKg,
613
+ burnedOut: state.propellantKg <= 0 || state.burnTime >= (tuning.motor.thrustCurve.at(-1)?.[0] ?? 0),
614
+ },
615
+ }),
616
+ ...(tuning.rotor === undefined
617
+ ? {}
618
+ : {
619
+ rotor: {
620
+ speed: state.rotorSpeed,
621
+ thrust: rotorTelemetry.thrust,
622
+ torque: rotorTelemetry.torque,
623
+ groundEffect: rotorTelemetry.groundEffect,
624
+ translationalLift: rotorTelemetry.translationalLift,
625
+ },
626
+ }),
627
+ };
628
+ }
629
+ return {
630
+ tick(dt, input, modifiers) {
631
+ const maxSub = Math.max(1 / 4000, tuning.maxSubstep ?? DEFAULT_SUBSTEP);
632
+ const steps = dt > 0 ? Math.max(1, Math.ceil(dt / maxSub - 1e-9)) : 0;
633
+ const h = steps > 0 ? dt / steps : 0;
634
+ for (let i = 0; i < steps; i += 1)
635
+ substep(h, input, modifiers);
636
+ return stepOf();
637
+ },
638
+ pose: () => ({ position: [state.x, state.y, state.z], orientation: [state.qx, state.qy, state.qz, state.qw] }),
639
+ velocity: () => [state.vx, state.vy, state.vz],
640
+ applyImpulse(dv) {
641
+ state.vx += dv[0];
642
+ state.vy += dv[1];
643
+ state.vz += dv[2];
644
+ },
645
+ retune(next) {
646
+ if (next.motor !== tuning.motor) {
647
+ state.burnTime = 0;
648
+ state.propellantKg = next.motor?.propellantKg ?? 0;
649
+ }
650
+ tuning = next;
651
+ surfaces = next.surfaces.map(prepareSurface);
652
+ largest = largestSurface(surfaces);
653
+ },
654
+ tuning: () => tuning,
655
+ snapshot: () => ({ ...state }),
656
+ restore(next) {
657
+ Object.assign(state, next);
658
+ },
659
+ resetTo(position, orientation) {
660
+ Object.assign(state, fresh(position, orientation ?? aircraftHeadingQuaternion(attitudeOf(state).heading)));
661
+ },
662
+ };
663
+ }
664
+ function sampleCurve(curve, time) {
665
+ if (curve.length === 0)
666
+ return 0;
667
+ if (time <= curve[0][0])
668
+ return curve[0][1];
669
+ for (let i = 1; i < curve.length; i += 1) {
670
+ const [t1, v1] = curve[i];
671
+ if (time <= t1) {
672
+ const [t0, v0] = curve[i - 1];
673
+ return t1 > t0 ? v0 + ((v1 - v0) * (time - t0)) / (t1 - t0) : v1;
674
+ }
675
+ }
676
+ return 0;
677
+ }
678
+ function peakOf(curve) {
679
+ let peak = 0;
680
+ for (const [, value] of curve)
681
+ peak = Math.max(peak, value);
682
+ return peak;
683
+ }
684
+ function clamp1(value) {
685
+ return Math.max(-1, Math.min(1, value));
686
+ }
687
+ function clamp01(value) {
688
+ return Math.max(0, Math.min(1, value));
689
+ }
690
+ /** Heading, nose-up pitch and right bank of a body orientation (three.js `YXZ` Euler with `x = -pitch`). */
691
+ function attitudeOf(q) {
692
+ const { qx: x, qy: y, qz: z, qw: w } = q;
693
+ const m13 = 2 * (x * z + w * y);
694
+ const m23 = 2 * (y * z - w * x);
695
+ const m33 = 1 - 2 * (x * x + y * y);
696
+ const m21 = 2 * (x * y + w * z);
697
+ const m22 = 1 - 2 * (x * x + z * z);
698
+ const m31 = 2 * (x * z - w * y);
699
+ const m11 = 1 - 2 * (y * y + z * z);
700
+ const ex = Math.asin(-Math.max(-1, Math.min(1, m23)));
701
+ if (Math.abs(m23) < 0.9999999)
702
+ return { heading: Math.atan2(m13, m33), pitch: -ex, bank: Math.atan2(m21, m22) };
703
+ return { heading: Math.atan2(-m31, m11), pitch: -ex, bank: 0 };
704
+ }