feat: upgrade electric aircraft fidelity and flight model
This commit is contained in:
+221
-72
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user