1
0

feat: upgrade electric aircraft fidelity and flight model

This commit is contained in:
2026-08-19 00:52:35 -07:00
parent dd18c6775d
commit 79793faed2
4 changed files with 443 additions and 88 deletions
+218 -69
View File
@@ -1,11 +1,11 @@
/** Original procedural electric V-tail aircraft, authored in metres and facing -Z. */ /** Original procedural distributed-electric V-tail aircraft, authored in metres and facing -Z. */
import * as THREE from "three"; import * as THREE from "three";
export const ELECTRIC_AIRCRAFT_METRICS = Object.freeze({ export const ELECTRIC_AIRCRAFT_METRICS = Object.freeze({
length: 8.4, length: 8.8,
wingspan: 11.8, wingspan: 12.4,
height: 2.45, height: 2.55,
}); });
export interface ElectricAircraftMaterials { export interface ElectricAircraftMaterials {
@@ -13,6 +13,10 @@ export interface ElectricAircraftMaterials {
accent: THREE.Material; accent: THREE.Material;
glass: THREE.Material; glass: THREE.Material;
dark: THREE.Material; dark: THREE.Material;
rotor: THREE.Material;
light: THREE.Material;
redLight: THREE.Material;
greenLight: THREE.Material;
} }
export interface ElectricAircraftBuildOptions { export interface ElectricAircraftBuildOptions {
@@ -37,35 +41,51 @@ export interface AircraftSurfacePose {
} }
export function createElectricAircraftMaterials( export function createElectricAircraftMaterials(
bodyColor: THREE.ColorRepresentation = 0xe9edf0, bodyColor: THREE.ColorRepresentation = 0xdfe5e8,
): ElectricAircraftMaterials { ): ElectricAircraftMaterials {
return { return {
body: new THREE.MeshPhysicalMaterial({ body: new THREE.MeshPhysicalMaterial({
name: "electric-aircraft.body", name: "electric-aircraft.body",
color: bodyColor, color: bodyColor,
metalness: 0.35, metalness: 0.22,
roughness: 0.3, roughness: 0.28,
clearcoat: 0.8, clearcoat: 0.86,
clearcoatRoughness: 0.22,
}), }),
accent: new THREE.MeshStandardMaterial({ accent: new THREE.MeshPhysicalMaterial({
name: "electric-aircraft.accent", name: "electric-aircraft.accent",
color: 0x178f83, color: 0xc58b22,
metalness: 0.45, metalness: 0.42,
roughness: 0.32, roughness: 0.3,
clearcoat: 0.65,
}), }),
glass: new THREE.MeshPhysicalMaterial({ glass: new THREE.MeshPhysicalMaterial({
name: "electric-aircraft.glass", name: "electric-aircraft.glass",
color: 0x19323c, color: 0x10232d,
roughness: 0.12, roughness: 0.08,
metalness: 0.08,
transmission: 0.08,
transparent: true, transparent: true,
opacity: 0.82, opacity: 0.86,
clearcoat: 1,
}), }),
dark: new THREE.MeshStandardMaterial({ dark: new THREE.MeshStandardMaterial({
name: "electric-aircraft.dark", name: "electric-aircraft.dark",
color: 0x1a2022, color: 0x151b20,
metalness: 0.7, metalness: 0.68,
roughness: 0.34, roughness: 0.3,
}), }),
rotor: new THREE.MeshBasicMaterial({
name: "electric-aircraft.rotor-disc",
color: 0x60717a,
transparent: true,
opacity: 0.15,
depthWrite: false,
side: THREE.DoubleSide,
}),
light: new THREE.MeshBasicMaterial({ name: "electric-aircraft.strobe", color: 0xf1fbff }),
redLight: new THREE.MeshBasicMaterial({ name: "electric-aircraft.nav-red", color: 0xff3c33 }),
greenLight: new THREE.MeshBasicMaterial({ name: "electric-aircraft.nav-green", color: 0x44ff9a }),
}; };
} }
@@ -81,50 +101,141 @@ function mesh(
return value; return value;
} }
function wingGeometry(span: number, rootChord: number, tipChord: number): THREE.BufferGeometry { /** Smooth, tapered fuselage without the toy-like capsule/cone seam of the first asset. */
const half = span / 2; function fuselageGeometry(): THREE.BufferGeometry {
const vertices = new Float32Array([ const rings = [
0, 0, -rootChord / 2, half, 0, -tipChord / 2, half, 0, tipChord / 2, { z: -4.4, y: 0.38, rx: 0.08, ry: 0.08 },
0, 0, rootChord / 2, -half, 0, tipChord / 2, -half, 0, -tipChord / 2, { z: -4.05, y: 0.4, rx: 0.48, ry: 0.42 },
]); { z: -3.35, y: 0.44, rx: 0.7, ry: 0.62 },
{ z: -1.5, y: 0.48, rx: 0.78, ry: 0.73 },
{ z: 0.8, y: 0.48, rx: 0.68, ry: 0.64 },
{ z: 2.7, y: 0.55, rx: 0.4, ry: 0.43 },
{ z: 4.15, y: 0.65, rx: 0.11, ry: 0.12 },
] as const;
const radialSegments = 18;
const positions: number[] = [];
const indices: number[] = [];
for (const ring of rings) {
for (let segment = 0; segment < radialSegments; segment += 1) {
const angle = segment / radialSegments * Math.PI * 2;
positions.push(
Math.cos(angle) * ring.rx,
ring.y + Math.sin(angle) * ring.ry,
ring.z,
);
}
}
for (let ring = 0; ring < rings.length - 1; ring += 1) {
for (let segment = 0; segment < radialSegments; segment += 1) {
const next = (segment + 1) % radialSegments;
const a = ring * radialSegments + segment;
const b = ring * radialSegments + next;
const c = (ring + 1) * radialSegments + segment;
const d = (ring + 1) * radialSegments + next;
indices.push(a, c, b, b, c, d);
}
}
const geometry = new THREE.BufferGeometry(); const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(vertices, 3)); geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
// Counter-clockwise from above. The reverse winding points the generated geometry.setIndex(indices);
// normals down, so Three.js culls the whole wing from the chase/flyover view
// while still drawing its shadow — leaving only two apparently floating
// ailerons around the fuselage.
geometry.setIndex([0, 2, 1, 0, 3, 2, 0, 4, 3, 0, 5, 4]);
geometry.computeVertexNormals(); geometry.computeVertexNormals();
return geometry; return geometry;
} }
function addFan(root: THREE.Group, x: number, materials: ElectricAircraftMaterials): THREE.Group { /** Closed, dihedral wing prism: visible from above, below, and during a steep bank. */
function wingGeometry(span: number, rootChord: number, tipChord: number): THREE.BufferGeometry {
const half = span / 2;
const thickness = 0.18;
const tipRise = 0.38;
const planform = [
[0, -rootChord / 2], [half, -tipChord / 2], [half, tipChord / 2],
[0, rootChord / 2], [-half, tipChord / 2], [-half, -tipChord / 2],
] as const;
const positions: number[] = [];
for (const y of [thickness / 2, -thickness / 2]) {
for (const [x, z] of planform) positions.push(x, y + Math.abs(x / half) * tipRise, z);
}
const indices: number[] = [];
indices.push(0, 2, 1, 0, 3, 2, 0, 4, 3, 0, 5, 4);
indices.push(6, 7, 8, 6, 8, 9, 6, 9, 10, 6, 10, 11);
for (let edge = 0; edge < 6; edge += 1) {
const next = (edge + 1) % 6;
indices.push(edge, next + 6, edge + 6, edge, next, next + 6);
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
geometry.setIndex(indices);
geometry.computeVertexNormals();
return geometry;
}
function addPanelLine(
root: THREE.Group,
points: readonly THREE.Vector3[],
name: string,
): void {
const geometry = new THREE.BufferGeometry().setFromPoints([...points]);
const line = new THREE.Line(
geometry,
new THREE.LineBasicMaterial({ color: 0x59656b, transparent: true, opacity: 0.7 }),
);
line.name = name;
root.add(line);
}
const TWO_PI = Math.PI * 2;
function addFan(
root: THREE.Group,
x: number,
ordinal: number,
materials: ElectricAircraftMaterials,
): THREE.Group {
const fan = new THREE.Group(); const fan = new THREE.Group();
fan.name = x < 0 ? "electric-aircraft.fan-left" : "electric-aircraft.fan-right"; const side = x < 0 ? "left" : "right";
fan.position.set(x, 0.02, -0.48); const sideOrdinal = ordinal < 3 ? ordinal : 5 - ordinal;
const nacelle = mesh(new THREE.CapsuleGeometry(0.25, 0.72, 5, 10), materials.accent, `${fan.name}:nacelle`); fan.name = sideOrdinal === 0
? `electric-aircraft.fan-${side}`
: `electric-aircraft.fan-${side}-${sideOrdinal === 1 ? "mid" : "outer"}`;
fan.position.set(x, 0.5 + Math.abs(x) * 0.035, -0.52 + Math.abs(x) * 0.015);
const nacelle = mesh(
new THREE.CapsuleGeometry(0.18, 0.56, 4, 10),
materials.accent,
`${fan.name}:nacelle`,
);
nacelle.rotation.x = Math.PI / 2; nacelle.rotation.x = Math.PI / 2;
nacelle.position.set(x, 0.02, -0.12); nacelle.position.z = 0.16;
root.add(nacelle); fan.add(nacelle);
const hub = mesh(new THREE.CylinderGeometry(0.11, 0.11, 0.14, 12), materials.dark, `${fan.name}:hub`); const hub = mesh(new THREE.CylinderGeometry(0.1, 0.1, 0.16, 12), materials.dark, `${fan.name}:hub`);
hub.rotation.x = Math.PI / 2; hub.rotation.x = Math.PI / 2;
fan.add(hub); fan.add(hub);
for (let index = 0; index < 5; index += 1) { for (let index = 0; index < 5; index += 1) {
const angle = index * TWO_PI / 5; const angle = index * TWO_PI / 5;
const blade = mesh(new THREE.BoxGeometry(0.07, 0.68, 0.025), materials.dark, `${fan.name}:blade-${index}`); const blade = mesh(new THREE.BoxGeometry(0.055, 0.58, 0.018), materials.dark, `${fan.name}:blade-${index}`);
// Place every blade on its own radial spoke. Rotating five differently blade.position.set(-Math.sin(angle) * 0.25, Math.cos(angle) * 0.25, 0);
// oriented rectangles around the same off-centre point makes a lopsided
// paddle; matching centre and local +Y to this angle produces a balanced
// rotor whose group can spin continuously around the hub.
blade.position.set(-Math.sin(angle) * 0.3, Math.cos(angle) * 0.3, 0);
blade.rotation.z = angle; blade.rotation.z = angle;
fan.add(blade); fan.add(blade);
} }
const disc = mesh(new THREE.CircleGeometry(0.61, 24), materials.rotor, `${fan.name}:motion-disc`);
disc.position.z = -0.018;
fan.add(disc);
root.add(fan); root.add(fan);
return fan; return fan;
} }
const TWO_PI = Math.PI * 2; function addLight(
root: THREE.Group,
name: string,
position: THREE.Vector3Tuple,
material: THREE.Material,
radius = 0.075,
): void {
const value = mesh(new THREE.SphereGeometry(radius, 10, 6), material, name);
value.position.set(...position);
value.castShadow = false;
root.add(value);
}
export function buildElectricAircraft(options: ElectricAircraftBuildOptions = {}): ElectricAircraftRig { export function buildElectricAircraft(options: ElectricAircraftBuildOptions = {}): ElectricAircraftRig {
const ownsMaterials = options.materials === undefined; const ownsMaterials = options.materials === undefined;
@@ -134,32 +245,45 @@ export function buildElectricAircraft(options: ElectricAircraftBuildOptions = {}
root.userData.kind = "aircraft"; root.userData.kind = "aircraft";
root.userData.aircraftModel = "electric-vtail"; root.userData.aircraftModel = "electric-vtail";
root.userData.forwardAxis = "-Z"; root.userData.forwardAxis = "-Z";
root.userData.design = "lumbridge-de6";
const fuselage = mesh(new THREE.CapsuleGeometry(0.66, 5.9, 8, 16), materials.body, "electric-aircraft:fuselage"); root.add(mesh(fuselageGeometry(), materials.body, "electric-aircraft:fuselage"));
fuselage.rotation.x = Math.PI / 2;
fuselage.position.y = 0.42; const lowerNose = mesh(new THREE.ConeGeometry(0.34, 1.25, 16), materials.accent, "electric-aircraft:nose-keel");
root.add(fuselage); lowerNose.rotation.x = -Math.PI / 2;
const nose = mesh(new THREE.ConeGeometry(0.64, 1.7, 18), materials.accent, "electric-aircraft:nose"); lowerNose.position.set(0, 0.18, -3.9);
nose.rotation.x = -Math.PI / 2; lowerNose.scale.set(1, 0.48, 1);
nose.position.set(0, 0.42, -3.8); root.add(lowerNose);
root.add(nose);
const canopy = mesh(new THREE.SphereGeometry(0.68, 16, 8), materials.glass, "electric-aircraft:canopy"); const canopy = mesh(new THREE.SphereGeometry(0.72, 20, 10), materials.glass, "electric-aircraft:canopy");
canopy.scale.set(0.8, 0.52, 1.55); canopy.scale.set(0.82, 0.55, 1.68);
canopy.position.set(0, 1.02, -0.9); canopy.position.set(0, 1.05, -1.0);
root.add(canopy); root.add(canopy);
const wing = mesh(wingGeometry(11.8, 2.25, 0.72), materials.body, "electric-aircraft:wing"); const canopySpine = mesh(new THREE.BoxGeometry(0.055, 0.07, 2.0), materials.dark, "electric-aircraft:canopy-spine");
wing.position.set(0, 0.46, 0.05); canopySpine.position.set(0, 1.43, -0.98);
root.add(canopySpine);
const wing = mesh(wingGeometry(ELECTRIC_AIRCRAFT_METRICS.wingspan, 2.45, 0.82), materials.body, "electric-aircraft:wing");
wing.position.set(0, 0.5, 0.05);
root.add(wing); root.add(wing);
addPanelLine(root, [
new THREE.Vector3(-5.7, 0.98, 0.12),
new THREE.Vector3(0, 0.6, 0.26),
new THREE.Vector3(5.7, 0.98, 0.12),
], "electric-aircraft:wing-panel-line");
const leftAileron = new THREE.Group(); const leftAileron = new THREE.Group();
const rightAileron = new THREE.Group(); const rightAileron = new THREE.Group();
leftAileron.name = "electric-aircraft.aileron-left"; leftAileron.name = "electric-aircraft.aileron-left";
rightAileron.name = "electric-aircraft.aileron-right"; rightAileron.name = "electric-aircraft.aileron-right";
for (const [joint, x] of [[leftAileron, -4.2], [rightAileron, 4.2]] as const) { for (const [joint, x, rise] of [
// On the tapered wing's trailing edge. At 0.72 the outer leading corner [leftAileron, -4.45, 0.29],
// sat behind the tip chord and the bright surface read as a floating bar. [rightAileron, 4.45, 0.29],
joint.position.set(x, 0.48, 0.58); ] as const) {
joint.add(mesh(new THREE.BoxGeometry(2.15, 0.08, 0.45), materials.accent, `${joint.name}:surface`)); joint.position.set(x, 0.62 + rise, 0.56);
const surface = mesh(new THREE.BoxGeometry(2.15, 0.09, 0.43), materials.accent, `${joint.name}:surface`);
surface.rotation.z = x < 0 ? -0.055 : 0.055;
joint.add(surface);
root.add(joint); root.add(joint);
} }
@@ -168,18 +292,38 @@ export function buildElectricAircraft(options: ElectricAircraftBuildOptions = {}
leftVTail.name = "electric-aircraft.v-tail-left"; leftVTail.name = "electric-aircraft.v-tail-left";
rightVTail.name = "electric-aircraft.v-tail-right"; rightVTail.name = "electric-aircraft.v-tail-right";
for (const [joint, x, tilt] of [ for (const [joint, x, tilt] of [
[leftVTail, -0.32, -0.72], [leftVTail, -0.28, -0.68],
[rightVTail, 0.32, 0.72], [rightVTail, 0.28, 0.68],
] as const) { ] as const) {
joint.position.set(x, 0.63, 3.2); joint.position.set(x, 0.68, 3.0);
joint.rotation.z = tilt; joint.rotation.z = tilt;
const surface = mesh(new THREE.BoxGeometry(0.1, 1.85, 1.15), materials.body, `${joint.name}:surface`); const surface = mesh(new THREE.BoxGeometry(0.16, 1.88, 1.45), materials.body, `${joint.name}:surface`);
surface.position.y = 0.72; surface.position.set(0, 0.72, 0.16);
joint.add(surface); joint.add(surface);
const ruddervator = mesh(new THREE.BoxGeometry(0.18, 1.5, 0.34), materials.accent, `${joint.name}:ruddervator`);
ruddervator.position.set(0, 0.72, 0.88);
joint.add(ruddervator);
root.add(joint); root.add(joint);
} }
const fans = [addFan(root, -2.1, materials), addFan(root, 2.1, materials)]; const belly = mesh(new THREE.BoxGeometry(0.92, 0.22, 2.65), materials.dark, "electric-aircraft:battery-belly");
belly.position.set(0, -0.16, 0.15);
root.add(belly);
for (const x of [-0.56, 0.56]) {
const door = mesh(new THREE.BoxGeometry(0.38, 0.045, 0.86), materials.accent, `electric-aircraft:gear-door-${x < 0 ? "left" : "right"}`);
door.position.set(x, -0.29, 0.42);
root.add(door);
}
const fanPositions = [-4.45, -3.05, -1.68, 1.68, 3.05, 4.45] as const;
const fans = fanPositions.map((x, index) => addFan(root, x, index, materials));
addLight(root, "electric-aircraft:nav-left", [-6.05, 0.96, 0.08], materials.redLight, 0.09);
addLight(root, "electric-aircraft:nav-right", [6.05, 0.96, 0.08], materials.greenLight, 0.09);
addLight(root, "electric-aircraft:strobe-left", [-4.9, 0.98, 0.18], materials.light);
addLight(root, "electric-aircraft:strobe-right", [4.9, 0.98, 0.18], materials.light);
addLight(root, "electric-aircraft:tail-light", [0, 1.35, 4.0], materials.light, 0.065);
root.traverse((item) => { root.traverse((item) => {
if (item instanceof THREE.Mesh) { if (item instanceof THREE.Mesh) {
item.geometry.computeBoundingBox(); item.geometry.computeBoundingBox();
@@ -216,10 +360,15 @@ export function disposeElectricAircraft(rig: ElectricAircraftRig): void {
const geometries = new Set<THREE.BufferGeometry>(); const geometries = new Set<THREE.BufferGeometry>();
const materials = new Set<THREE.Material>(); const materials = new Set<THREE.Material>();
rig.root.traverse((item) => { rig.root.traverse((item) => {
if (!(item instanceof THREE.Mesh)) return; if (item instanceof THREE.Mesh) {
geometries.add(item.geometry); geometries.add(item.geometry);
const values = Array.isArray(item.material) ? item.material : [item.material]; const values = Array.isArray(item.material) ? item.material : [item.material];
for (const material of values) materials.add(material); for (const material of values) materials.add(material);
} else if (item instanceof THREE.Line) {
geometries.add(item.geometry);
const values = Array.isArray(item.material) ? item.material : [item.material];
for (const material of values) materials.add(material);
}
}); });
for (const geometry of geometries) geometry.dispose(); for (const geometry of geometries) geometry.dispose();
if (rig.ownsMaterials) for (const material of materials) material.dispose(); if (rig.ownsMaterials) for (const material of materials) material.dispose();
+143 -8
View File
@@ -65,6 +65,15 @@ export interface AircraftControllerOptions {
maximumSpeedMps?: number; maximumSpeedMps?: number;
assistedCruiseMps?: number; assistedCruiseMps?: number;
assistedAltitudeM?: number; assistedAltitudeM?: number;
/** Deterministic scenario wind, positive north/east in metres per second. */
windNorthMps?: number;
windEastMps?: number;
/** Deterministic sinusoidal gust amplitude. Zero disables turbulence. */
turbulenceMps?: number;
stallSpeedMps?: number;
batteryCapacityWh?: number;
/** Terrain/runway elevation callback used for ground contact and landing. */
terrainElevationM?: (lat: number, lng: number) => number;
fixedStepSeconds?: number; fixedStepSeconds?: number;
maxFrameDeltaSeconds?: number; maxFrameDeltaSeconds?: number;
} }
@@ -84,6 +93,17 @@ export interface AircraftControllerState extends AircraftGeographicPoint {
pitchInput: number; pitchInput: number;
rollInput: number; rollInput: number;
fanRadians: number; fanRadians: number;
angleOfAttackDeg: number;
liftCoefficient: number;
loadFactorG: number;
stalled: boolean;
windNorthMps: number;
windEastMps: number;
batteryWh: number;
energyUsedWh: number;
groundClearanceM: number;
onGround: boolean;
hardLanding: boolean;
envelopeContact: boolean; envelopeContact: boolean;
elapsedSteps: number; elapsedSteps: number;
} }
@@ -118,6 +138,12 @@ interface ResolvedOptions {
maximumSpeedMps: number; maximumSpeedMps: number;
assistedCruiseMps: number; assistedCruiseMps: number;
assistedAltitudeM: number; assistedAltitudeM: number;
windNorthMps: number;
windEastMps: number;
turbulenceMps: number;
stallSpeedMps: number;
batteryCapacityWh: number;
terrainElevationM: (lat: number, lng: number) => number;
fixedStepSeconds: number; fixedStepSeconds: number;
maxFrameDeltaSeconds: number; maxFrameDeltaSeconds: number;
} }
@@ -199,6 +225,15 @@ function resolveOptions(value: AircraftControllerOptions): ResolvedOptions {
envelope.minAltitudeM, envelope.minAltitudeM,
envelope.maxAltitudeM, envelope.maxAltitudeM,
); );
const stallSpeedMps = clamp(
finiteOr(value.stallSpeedMps, Math.max(18, minimumSpeedMps + 3)),
minimumSpeedMps,
maximumSpeedMps * 0.8,
);
const terrainElevationM = value.terrainElevationM ?? (() => envelope.minAltitudeM);
if (typeof terrainElevationM !== "function") {
throw new RangeError("aircraft terrainElevationM must be a function");
}
return { return {
initialPosition: { initialPosition: {
lat: clamp(finiteOr(value.initialPosition?.lat, 34.0522), envelope.minLat, envelope.maxLat), lat: clamp(finiteOr(value.initialPosition?.lat, 34.0522), envelope.minLat, envelope.maxLat),
@@ -214,6 +249,12 @@ function resolveOptions(value: AircraftControllerOptions): ResolvedOptions {
maximumSpeedMps, maximumSpeedMps,
assistedCruiseMps: clamp(finiteOr(value.assistedCruiseMps, 62), minimumSpeedMps, maximumSpeedMps), assistedCruiseMps: clamp(finiteOr(value.assistedCruiseMps, 62), minimumSpeedMps, maximumSpeedMps),
assistedAltitudeM, assistedAltitudeM,
windNorthMps: clamp(finiteOr(value.windNorthMps, 0), -80, 80),
windEastMps: clamp(finiteOr(value.windEastMps, 0), -80, 80),
turbulenceMps: clamp(finiteOr(value.turbulenceMps, 0), 0, 30),
stallSpeedMps,
batteryCapacityWh: clamp(finiteOr(value.batteryCapacityWh, 54_000), 1_000, 500_000),
terrainElevationM,
fixedStepSeconds: clamp(finiteOr(value.fixedStepSeconds, 1 / 60), 1 / 240, 0.1), fixedStepSeconds: clamp(finiteOr(value.fixedStepSeconds, 1 / 60), 1 / 240, 0.1),
maxFrameDeltaSeconds: clamp(finiteOr(value.maxFrameDeltaSeconds, 0.25), 0.05, 1), maxFrameDeltaSeconds: clamp(finiteOr(value.maxFrameDeltaSeconds, 0.25), 0.05, 1),
}; };
@@ -264,6 +305,17 @@ export class AircraftController {
pitchInput: 0, pitchInput: 0,
rollInput: 0, rollInput: 0,
fanRadians: 0, fanRadians: 0,
angleOfAttackDeg: 0,
liftCoefficient: 1,
loadFactorG: 1,
stalled: false,
windNorthMps: this.options.windNorthMps,
windEastMps: this.options.windEastMps,
batteryWh: this.options.batteryCapacityWh,
energyUsedWh: 0,
groundClearanceM: 0,
onGround: false,
hardLanding: false,
envelopeContact: false, envelopeContact: false,
elapsedSteps: 0, elapsedSteps: 0,
}; };
@@ -299,11 +351,33 @@ export class AircraftController {
pitchInput: 0, pitchInput: 0,
rollInput: 0, rollInput: 0,
fanRadians: 0, fanRadians: 0,
angleOfAttackDeg: 0,
liftCoefficient: 1,
loadFactorG: 1,
stalled: false,
windNorthMps: this.options.windNorthMps,
windEastMps: this.options.windEastMps,
batteryWh: this.options.batteryCapacityWh,
energyUsedWh: 0,
groundClearanceM: Math.max(0, this.options.initialAltitudeM - this.groundElevationM(
this.options.initialPosition.lat,
this.options.initialPosition.lng,
)),
onGround: false,
hardLanding: false,
envelopeContact: false, envelopeContact: false,
elapsedSteps: 0, elapsedSteps: 0,
}); });
} }
private groundElevationM(lat: number, lng: number): number {
const value = this.options.terrainElevationM(lat, lng);
if (!Number.isFinite(value)) {
throw new RangeError("aircraft terrainElevationM must return a finite elevation");
}
return clamp(value, this.options.envelope.minAltitudeM, this.options.envelope.maxAltitudeM);
}
tick(deltaSeconds: number, actions: Partial<AircraftActionSnapshot> = NEUTRAL_AIRCRAFT_ACTIONS): number { tick(deltaSeconds: number, actions: Partial<AircraftActionSnapshot> = NEUTRAL_AIRCRAFT_ACTIONS): number {
if (!Number.isFinite(deltaSeconds) || deltaSeconds <= 0) return 0; if (!Number.isFinite(deltaSeconds) || deltaSeconds <= 0) return 0;
const normalized = normalizeAircraftActions(actions); const normalized = normalizeAircraftActions(actions);
@@ -358,12 +432,28 @@ export class AircraftController {
this.current.pitchInput = moveToward(this.current.pitchInput, pitch, 2.2 * dt); this.current.pitchInput = moveToward(this.current.pitchInput, pitch, 2.2 * dt);
this.current.rollInput = moveToward(this.current.rollInput, roll, 2.8 * dt); this.current.rollInput = moveToward(this.current.rollInput, roll, 2.8 * dt);
const targetRoll = this.current.rollInput * 58; const gustPhase = this.current.elapsedSteps * dt * 0.73;
const targetPitch = this.current.pitchInput * 22; const gustNorth = Math.sin(gustPhase) * this.options.turbulenceMps;
const gustEast = Math.sin(gustPhase * 0.61 + 1.7) * this.options.turbulenceMps * 0.72;
this.current.windNorthMps = this.options.windNorthMps + gustNorth;
this.current.windEastMps = this.options.windEastMps + gustEast;
const targetRoll = this.current.rollInput * 58 + gustEast * 0.22;
const targetPitch = this.current.pitchInput * 22 + gustNorth * 0.08;
this.current.rollDeg = moveToward(this.current.rollDeg, targetRoll, 55 * dt); this.current.rollDeg = moveToward(this.current.rollDeg, targetRoll, 55 * dt);
this.current.pitchDeg = moveToward(this.current.pitchDeg, targetPitch, 28 * dt); this.current.pitchDeg = moveToward(this.current.pitchDeg, targetPitch, 28 * dt);
const thrust = this.current.throttle * 8.5; const flightPathDeg = Math.atan2(
const drag = 1.2 + this.current.speedMps * this.current.speedMps * 0.00075; this.current.verticalSpeedMps,
Math.max(1, this.current.speedMps),
) * 180 / Math.PI;
this.current.angleOfAttackDeg = this.current.pitchDeg - flightPathDeg;
this.current.liftCoefficient = clamp(1 + this.current.angleOfAttackDeg * 0.055, 0.05, 1.6);
this.current.stalled = this.current.speedMps < this.options.stallSpeedMps ||
Math.abs(this.current.angleOfAttackDeg) > 19;
const stallDrag = this.current.stalled ? 3.8 : 0;
const batteryFactor = clamp(this.current.batteryWh / Math.max(1, this.options.batteryCapacityWh * 0.08), 0, 1);
const thrust = this.current.throttle * 8.5 * batteryFactor;
const drag = 1.2 + this.current.speedMps * this.current.speedMps * 0.00075 + stallDrag;
this.current.speedMps = clamp( this.current.speedMps = clamp(
this.current.speedMps + (thrust - drag) * dt, this.current.speedMps + (thrust - drag) * dt,
this.options.minimumSpeedMps, this.options.minimumSpeedMps,
@@ -373,16 +463,55 @@ export class AircraftController {
this.current.headingDeg = wrapDegrees( this.current.headingDeg = wrapDegrees(
this.current.headingDeg + (bankTurn + this.current.yawInput * 20) * dt, this.current.headingDeg + (bankTurn + this.current.yawInput * 20) * dt,
); );
this.current.verticalSpeedMps = Math.sin(this.current.pitchDeg * Math.PI / 180) * this.current.speedMps; const liftAuthority = clamp(
this.current.liftCoefficient * (this.current.speedMps / Math.max(1, this.options.assistedCruiseMps)),
0.08,
1.35,
);
const commandedVerticalSpeed = Math.sin(this.current.pitchDeg * Math.PI / 180) *
this.current.speedMps * liftAuthority;
const stallSinkMps = this.current.stalled
? clamp((this.options.stallSpeedMps - this.current.speedMps) * 0.7 + 2.5, 2.5, 14)
: 0;
this.current.verticalSpeedMps = moveToward(
this.current.verticalSpeedMps,
commandedVerticalSpeed - stallSinkMps,
(this.current.stalled ? 8 : 5) * dt,
);
this.current.loadFactorG = clamp(
liftAuthority / Math.max(0.25, Math.cos(this.current.rollDeg * Math.PI / 180)),
0,
3.5,
);
const heading = this.current.headingDeg * Math.PI / 180; const heading = this.current.headingDeg * Math.PI / 180;
const horizontalSpeed = Math.cos(this.current.pitchDeg * Math.PI / 180) * this.current.speedMps; const horizontalSpeed = Math.cos(this.current.pitchDeg * Math.PI / 180) * this.current.speedMps;
const northM = Math.cos(heading) * horizontalSpeed * dt; const northM = (Math.cos(heading) * horizontalSpeed + this.current.windNorthMps) * dt;
const eastM = Math.sin(heading) * horizontalSpeed * dt; const eastM = (Math.sin(heading) * horizontalSpeed + this.current.windEastMps) * dt;
const nextLat = this.current.lat + northM / EARTH_RADIUS_M * 180 / Math.PI; const nextLat = this.current.lat + northM / EARTH_RADIUS_M * 180 / Math.PI;
const nextLng = this.current.lng + eastM / const nextLng = this.current.lng + eastM /
(EARTH_RADIUS_M * Math.max(0.01, Math.cos(this.current.lat * Math.PI / 180))) * 180 / Math.PI; (EARTH_RADIUS_M * Math.max(0.01, Math.cos(this.current.lat * Math.PI / 180))) * 180 / Math.PI;
const nextAltitude = this.current.altitudeM + this.current.verticalSpeedMps * dt; let nextAltitude = this.current.altitudeM + this.current.verticalSpeedMps * dt;
const groundElevationM = this.groundElevationM(nextLat, nextLng);
const wasVerticalSpeedMps = this.current.verticalSpeedMps;
this.current.onGround = nextAltitude <= groundElevationM + 0.75;
this.current.hardLanding = this.current.onGround && wasVerticalSpeedMps < -4.5;
if (this.current.onGround) {
nextAltitude = groundElevationM;
this.current.verticalSpeedMps = 0;
this.current.rollDeg = moveToward(this.current.rollDeg, 0, 70 * dt);
this.current.pitchDeg = moveToward(this.current.pitchDeg, 0, 45 * dt);
if (this.current.throttle < 0.55) {
this.current.speedMps = Math.max(this.options.minimumSpeedMps, this.current.speedMps - 4 * dt);
} else if (
this.current.speedMps > this.options.stallSpeedMps * 1.08 &&
this.current.pitchInput > 0.2
) {
this.current.onGround = false;
nextAltitude = groundElevationM + 0.8;
this.current.verticalSpeedMps = 0.8;
}
}
const envelope = this.options.envelope; const envelope = this.options.envelope;
this.current.envelopeContact = this.current.envelopeContact =
nextLat < envelope.minLat || nextLat > envelope.maxLat || nextLat < envelope.minLat || nextLat > envelope.maxLat ||
@@ -391,12 +520,18 @@ export class AircraftController {
this.current.lat = clamp(nextLat, envelope.minLat, envelope.maxLat); this.current.lat = clamp(nextLat, envelope.minLat, envelope.maxLat);
this.current.lng = clamp(nextLng, envelope.minLng, envelope.maxLng); this.current.lng = clamp(nextLng, envelope.minLng, envelope.maxLng);
this.current.altitudeM = clamp(nextAltitude, envelope.minAltitudeM, envelope.maxAltitudeM); this.current.altitudeM = clamp(nextAltitude, envelope.minAltitudeM, envelope.maxAltitudeM);
this.current.groundClearanceM = Math.max(0, this.current.altitudeM - groundElevationM);
if (this.current.envelopeContact) { if (this.current.envelopeContact) {
this.current.speedMps = Math.max(this.options.minimumSpeedMps, this.current.speedMps * 0.92); this.current.speedMps = Math.max(this.options.minimumSpeedMps, this.current.speedMps * 0.92);
this.current.pitchDeg = moveToward(this.current.pitchDeg, 0, 90 * dt); this.current.pitchDeg = moveToward(this.current.pitchDeg, 0, 90 * dt);
this.current.rollDeg = moveToward(this.current.rollDeg, 0, 90 * dt); this.current.rollDeg = moveToward(this.current.rollDeg, 0, 90 * dt);
} }
this.current.fanRadians = (this.current.fanRadians + (30 + this.current.throttle * 180) * dt) % TWO_PI; this.current.fanRadians = (this.current.fanRadians + (30 + this.current.throttle * 180) * dt) % TWO_PI;
const electricalPowerW = 8_000 + this.current.throttle * 122_000 +
Math.abs(this.current.verticalSpeedMps) * 420;
const usedWh = Math.min(this.current.batteryWh, electricalPowerW * dt / 3_600);
this.current.batteryWh -= usedWh;
this.current.energyUsedWh += usedWh;
this.current.elapsedSteps += 1; this.current.elapsedSteps += 1;
} }
} }
+7 -4
View File
@@ -153,16 +153,19 @@ function childGroup(root: THREE.Group, name: string): THREE.Group {
function cloneAircraft(prototype: ElectricAircraftRig): ElectricAircraftRig { function cloneAircraft(prototype: ElectricAircraftRig): ElectricAircraftRig {
const root = prototype.root.clone(true); const root = prototype.root.clone(true);
const fans: THREE.Group[] = [];
root.traverse((value) => {
if (value instanceof THREE.Group && value.name.startsWith("electric-aircraft.fan-")) {
fans.push(value);
}
});
return { return {
root, root,
leftAileron: childGroup(root, "electric-aircraft.aileron-left"), leftAileron: childGroup(root, "electric-aircraft.aileron-left"),
rightAileron: childGroup(root, "electric-aircraft.aileron-right"), rightAileron: childGroup(root, "electric-aircraft.aileron-right"),
leftVTail: childGroup(root, "electric-aircraft.v-tail-left"), leftVTail: childGroup(root, "electric-aircraft.v-tail-left"),
rightVTail: childGroup(root, "electric-aircraft.v-tail-right"), rightVTail: childGroup(root, "electric-aircraft.v-tail-right"),
fans: [ fans,
childGroup(root, "electric-aircraft.fan-left"),
childGroup(root, "electric-aircraft.fan-right"),
],
ownsMaterials: false, ownsMaterials: false,
}; };
} }
+72 -4
View File
@@ -26,7 +26,7 @@ describe("procedural electric aircraft", () => {
assert.equal(rig.root.name, "electric-aircraft"); assert.equal(rig.root.name, "electric-aircraft");
assert.equal(rig.root.userData.forwardAxis, "-Z"); assert.equal(rig.root.userData.forwardAxis, "-Z");
assert.equal(rig.root.userData.aircraftModel, "electric-vtail"); assert.equal(rig.root.userData.aircraftModel, "electric-vtail");
assert.equal(rig.fans.length, 2); assert.equal(rig.fans.length, 6);
assert.equal(rig.ownsMaterials, true); assert.equal(rig.ownsMaterials, true);
assert.ok(ELECTRIC_AIRCRAFT_METRICS.wingspan > ELECTRIC_AIRCRAFT_METRICS.length); assert.ok(ELECTRIC_AIRCRAFT_METRICS.wingspan > ELECTRIC_AIRCRAFT_METRICS.length);
const bounds = new THREE.Box3().setFromObject(rig.root); const bounds = new THREE.Box3().setFromObject(rig.root);
@@ -39,8 +39,13 @@ describe("procedural electric aircraft", () => {
const wing = rig.root.getObjectByName("electric-aircraft:wing"); const wing = rig.root.getObjectByName("electric-aircraft:wing");
assert.ok(wing instanceof THREE.Mesh); assert.ok(wing instanceof THREE.Mesh);
const normals = wing.geometry.getAttribute("normal"); const normals = wing.geometry.getAttribute("normal");
assert.ok(normals && Array.from({ length: normals.count }, (_, index) => normals.getY(index)).every((y) => y > 0), assert.ok(normals && Array.from({ length: normals.count }, (_, index) => normals.getY(index)).some((y) => y > 0.5),
"wing front faces point toward the chase/flyover camera"); "wing has a readable upper airfoil surface");
assert.ok(normals && Array.from({ length: normals.count }, (_, index) => normals.getY(index)).some((y) => y < -0.5),
"wing is a closed prism that remains visible from below");
assert.ok(rig.root.getObjectByName("electric-aircraft:canopy-spine"));
assert.ok(rig.root.getObjectByName("electric-aircraft:nav-left"));
assert.ok(rig.root.getObjectByName("electric-aircraft:gear-door-left"));
disposeElectricAircraft(rig); disposeElectricAircraft(rig);
assert.equal(rig.root.children.length, 0); assert.equal(rig.root.children.length, 0);
}); });
@@ -56,7 +61,7 @@ describe("procedural electric aircraft", () => {
).multiplyScalar(1 / blades.length); ).multiplyScalar(1 / blades.length);
assert.ok(centroid.length() < 1e-12, `fan centroid ${centroid.toArray()}`); assert.ok(centroid.length() < 1e-12, `fan centroid ${centroid.toArray()}`);
for (const blade of blades) { for (const blade of blades) {
assert.ok(Math.abs(blade.position.length() - 0.3) < 1e-12); assert.ok(Math.abs(blade.position.length() - 0.25) < 1e-12);
const radial = new THREE.Vector3(0, 1, 0).applyQuaternion(blade.quaternion); const radial = new THREE.Vector3(0, 1, 0).applyQuaternion(blade.quaternion);
assert.ok(radial.angleTo(blade.position.clone().normalize()) < 1e-7); assert.ok(radial.angleTo(blade.position.clone().normalize()) < 1e-7);
} }
@@ -168,6 +173,66 @@ describe("aircraft controller", () => {
assert.equal(controller.state().envelopeContact, true); assert.equal(controller.state().envelopeContact, true);
}); });
it("models deterministic wind, energy use, lift telemetry, and stalls", () => {
const options = {
mode: "manual" as const,
initialSpeedMps: 20,
minimumSpeedMps: 18,
stallSpeedMps: 25,
windNorthMps: 7,
windEastMps: -4,
turbulenceMps: 2,
batteryCapacityWh: 2_000,
fixedStepSeconds: 0.05,
};
const a = new AircraftController(options);
const b = new AircraftController(options);
for (let index = 0; index < 200; index += 1) {
a.stepFixed({ throttle: 0.15, pitch: 0.7, roll: 0.2 });
b.stepFixed({ throttle: 0.15, pitch: 0.7, roll: 0.2 });
}
assert.deepEqual(a.snapshot(), b.snapshot());
assert.equal(a.state().stalled, true);
assert.ok(a.state().angleOfAttackDeg > 0);
assert.ok(a.state().verticalSpeedMps < 0, "a deep low-speed stall loses altitude");
assert.ok(a.state().energyUsedWh > 0);
assert.ok(a.state().batteryWh < 2_000);
assert.notEqual(a.state().windNorthMps, 7, "deterministic turbulence perturbs scenario wind");
assert.ok(Number.isFinite(a.state().loadFactorG));
});
it("uses deterministic terrain contact and reports a hard landing", () => {
const controller = new AircraftController({
mode: "manual",
envelope: {
minLat: 30,
maxLat: 45,
minLng: -130,
maxLng: -110,
minAltitudeM: 0,
maxAltitudeM: 6_000,
},
initialAltitudeM: 104,
initialSpeedMps: 22,
minimumSpeedMps: 18,
stallSpeedMps: 26,
terrainElevationM: () => 100,
fixedStepSeconds: 0.1,
});
let touched = false;
let hard = false;
for (let index = 0; index < 120; index += 1) {
controller.stepFixed({ throttle: 0, pitch: -1 });
touched ||= controller.state().onGround;
hard ||= controller.state().hardLanding;
}
assert.equal(touched, true);
assert.equal(hard, true);
assert.equal(controller.state().groundClearanceM, 0);
assert.equal(controller.state().altitudeM, 100);
assert.equal(controller.state().verticalSpeedMps, 0);
});
it("resets exactly, caps sleeping-tab time, and replays bit-for-bit", () => { it("resets exactly, caps sleeping-tab time, and replays bit-for-bit", () => {
const options = { route: ROUTE, initialAltitudeM: 1_200, initialSpeedMps: 48 } as const; const options = { route: ROUTE, initialAltitudeM: 1_200, initialSpeedMps: 48 } as const;
const controller = new AircraftController(options); const controller = new AircraftController(options);
@@ -218,5 +283,8 @@ describe("aircraft controller", () => {
maxAltitudeM: 100, maxAltitudeM: 100,
}, },
}), /envelope/); }), /envelope/);
assert.throws(() => new AircraftController({
terrainElevationM: 3 as unknown as (lat: number, lng: number) => number,
}), /terrain/);
}); });
}); });