From 79793faed23cd8c81def77771b1f09bc9e59005d Mon Sep 17 00:00:00 2001 From: Kartios Date: Wed, 19 Aug 2026 00:52:35 -0700 Subject: [PATCH] feat: upgrade electric aircraft fidelity and flight model --- src/aircraft/asset.ts | 293 ++++++++++++++++++++++++++++--------- src/aircraft/controller.ts | 151 ++++++++++++++++++- src/realtime/scenePeers.ts | 11 +- src/test/aircraft.test.ts | 76 +++++++++- 4 files changed, 443 insertions(+), 88 deletions(-) diff --git a/src/aircraft/asset.ts b/src/aircraft/asset.ts index 29aa3f2..d38ec32 100644 --- a/src/aircraft/asset.ts +++ b/src/aircraft/asset.ts @@ -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"; export const ELECTRIC_AIRCRAFT_METRICS = Object.freeze({ - length: 8.4, - wingspan: 11.8, - height: 2.45, + length: 8.8, + wingspan: 12.4, + height: 2.55, }); export interface ElectricAircraftMaterials { @@ -13,6 +13,10 @@ export interface ElectricAircraftMaterials { accent: THREE.Material; glass: THREE.Material; dark: THREE.Material; + rotor: THREE.Material; + light: THREE.Material; + redLight: THREE.Material; + greenLight: THREE.Material; } export interface ElectricAircraftBuildOptions { @@ -37,35 +41,51 @@ export interface AircraftSurfacePose { } export function createElectricAircraftMaterials( - bodyColor: THREE.ColorRepresentation = 0xe9edf0, + bodyColor: THREE.ColorRepresentation = 0xdfe5e8, ): ElectricAircraftMaterials { return { body: new THREE.MeshPhysicalMaterial({ name: "electric-aircraft.body", color: bodyColor, - metalness: 0.35, - roughness: 0.3, - clearcoat: 0.8, + metalness: 0.22, + roughness: 0.28, + clearcoat: 0.86, + clearcoatRoughness: 0.22, }), - accent: new THREE.MeshStandardMaterial({ + accent: new THREE.MeshPhysicalMaterial({ name: "electric-aircraft.accent", - color: 0x178f83, - metalness: 0.45, - roughness: 0.32, + color: 0xc58b22, + metalness: 0.42, + roughness: 0.3, + clearcoat: 0.65, }), glass: new THREE.MeshPhysicalMaterial({ name: "electric-aircraft.glass", - color: 0x19323c, - roughness: 0.12, + color: 0x10232d, + roughness: 0.08, + metalness: 0.08, + transmission: 0.08, transparent: true, - opacity: 0.82, + opacity: 0.86, + clearcoat: 1, }), dark: new THREE.MeshStandardMaterial({ name: "electric-aircraft.dark", - color: 0x1a2022, - metalness: 0.7, - roughness: 0.34, + color: 0x151b20, + metalness: 0.68, + 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; } -function wingGeometry(span: number, rootChord: number, tipChord: number): THREE.BufferGeometry { - const half = span / 2; - const vertices = new Float32Array([ - 0, 0, -rootChord / 2, half, 0, -tipChord / 2, half, 0, tipChord / 2, - 0, 0, rootChord / 2, -half, 0, tipChord / 2, -half, 0, -tipChord / 2, - ]); +/** Smooth, tapered fuselage without the toy-like capsule/cone seam of the first asset. */ +function fuselageGeometry(): THREE.BufferGeometry { + const rings = [ + { z: -4.4, y: 0.38, rx: 0.08, ry: 0.08 }, + { 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(); - geometry.setAttribute("position", new THREE.BufferAttribute(vertices, 3)); - // Counter-clockwise from above. The reverse winding points the generated - // 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.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3)); + geometry.setIndex(indices); geometry.computeVertexNormals(); 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(); - fan.name = x < 0 ? "electric-aircraft.fan-left" : "electric-aircraft.fan-right"; - fan.position.set(x, 0.02, -0.48); - const nacelle = mesh(new THREE.CapsuleGeometry(0.25, 0.72, 5, 10), materials.accent, `${fan.name}:nacelle`); + const side = x < 0 ? "left" : "right"; + const sideOrdinal = ordinal < 3 ? ordinal : 5 - ordinal; + 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.position.set(x, 0.02, -0.12); - root.add(nacelle); - const hub = mesh(new THREE.CylinderGeometry(0.11, 0.11, 0.14, 12), materials.dark, `${fan.name}:hub`); + nacelle.position.z = 0.16; + fan.add(nacelle); + const hub = mesh(new THREE.CylinderGeometry(0.1, 0.1, 0.16, 12), materials.dark, `${fan.name}:hub`); hub.rotation.x = Math.PI / 2; fan.add(hub); for (let index = 0; index < 5; index += 1) { const angle = index * TWO_PI / 5; - const blade = mesh(new THREE.BoxGeometry(0.07, 0.68, 0.025), materials.dark, `${fan.name}:blade-${index}`); - // Place every blade on its own radial spoke. Rotating five differently - // 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); + const blade = mesh(new THREE.BoxGeometry(0.055, 0.58, 0.018), materials.dark, `${fan.name}:blade-${index}`); + blade.position.set(-Math.sin(angle) * 0.25, Math.cos(angle) * 0.25, 0); blade.rotation.z = angle; 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); 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 { const ownsMaterials = options.materials === undefined; @@ -134,32 +245,45 @@ export function buildElectricAircraft(options: ElectricAircraftBuildOptions = {} root.userData.kind = "aircraft"; root.userData.aircraftModel = "electric-vtail"; 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"); - fuselage.rotation.x = Math.PI / 2; - fuselage.position.y = 0.42; - root.add(fuselage); - const nose = mesh(new THREE.ConeGeometry(0.64, 1.7, 18), materials.accent, "electric-aircraft:nose"); - nose.rotation.x = -Math.PI / 2; - nose.position.set(0, 0.42, -3.8); - root.add(nose); - const canopy = mesh(new THREE.SphereGeometry(0.68, 16, 8), materials.glass, "electric-aircraft:canopy"); - canopy.scale.set(0.8, 0.52, 1.55); - canopy.position.set(0, 1.02, -0.9); + root.add(mesh(fuselageGeometry(), materials.body, "electric-aircraft:fuselage")); + + const lowerNose = mesh(new THREE.ConeGeometry(0.34, 1.25, 16), materials.accent, "electric-aircraft:nose-keel"); + lowerNose.rotation.x = -Math.PI / 2; + lowerNose.position.set(0, 0.18, -3.9); + lowerNose.scale.set(1, 0.48, 1); + root.add(lowerNose); + + const canopy = mesh(new THREE.SphereGeometry(0.72, 20, 10), materials.glass, "electric-aircraft:canopy"); + canopy.scale.set(0.82, 0.55, 1.68); + canopy.position.set(0, 1.05, -1.0); root.add(canopy); - const wing = mesh(wingGeometry(11.8, 2.25, 0.72), materials.body, "electric-aircraft:wing"); - wing.position.set(0, 0.46, 0.05); + const canopySpine = mesh(new THREE.BoxGeometry(0.055, 0.07, 2.0), materials.dark, "electric-aircraft:canopy-spine"); + 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); + 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 rightAileron = new THREE.Group(); leftAileron.name = "electric-aircraft.aileron-left"; rightAileron.name = "electric-aircraft.aileron-right"; - for (const [joint, x] of [[leftAileron, -4.2], [rightAileron, 4.2]] as const) { - // On the tapered wing's trailing edge. At 0.72 the outer leading corner - // sat behind the tip chord and the bright surface read as a floating bar. - joint.position.set(x, 0.48, 0.58); - joint.add(mesh(new THREE.BoxGeometry(2.15, 0.08, 0.45), materials.accent, `${joint.name}:surface`)); + for (const [joint, x, rise] of [ + [leftAileron, -4.45, 0.29], + [rightAileron, 4.45, 0.29], + ] as const) { + 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); } @@ -168,18 +292,38 @@ export function buildElectricAircraft(options: ElectricAircraftBuildOptions = {} leftVTail.name = "electric-aircraft.v-tail-left"; rightVTail.name = "electric-aircraft.v-tail-right"; for (const [joint, x, tilt] of [ - [leftVTail, -0.32, -0.72], - [rightVTail, 0.32, 0.72], + [leftVTail, -0.28, -0.68], + [rightVTail, 0.28, 0.68], ] as const) { - joint.position.set(x, 0.63, 3.2); + joint.position.set(x, 0.68, 3.0); joint.rotation.z = tilt; - const surface = mesh(new THREE.BoxGeometry(0.1, 1.85, 1.15), materials.body, `${joint.name}:surface`); - surface.position.y = 0.72; + const surface = mesh(new THREE.BoxGeometry(0.16, 1.88, 1.45), materials.body, `${joint.name}:surface`); + surface.position.set(0, 0.72, 0.16); 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); } - 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) => { if (item instanceof THREE.Mesh) { item.geometry.computeBoundingBox(); @@ -216,10 +360,15 @@ export function disposeElectricAircraft(rig: ElectricAircraftRig): void { const geometries = new Set(); const materials = new Set(); rig.root.traverse((item) => { - if (!(item instanceof THREE.Mesh)) return; - geometries.add(item.geometry); - const values = Array.isArray(item.material) ? item.material : [item.material]; - for (const material of values) materials.add(material); + if (item instanceof THREE.Mesh) { + geometries.add(item.geometry); + const values = Array.isArray(item.material) ? item.material : [item.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(); if (rig.ownsMaterials) for (const material of materials) material.dispose(); diff --git a/src/aircraft/controller.ts b/src/aircraft/controller.ts index 14115ac..f4572a2 100644 --- a/src/aircraft/controller.ts +++ b/src/aircraft/controller.ts @@ -65,6 +65,15 @@ export interface AircraftControllerOptions { maximumSpeedMps?: number; assistedCruiseMps?: 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; maxFrameDeltaSeconds?: number; } @@ -84,6 +93,17 @@ export interface AircraftControllerState extends AircraftGeographicPoint { pitchInput: number; rollInput: 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; elapsedSteps: number; } @@ -118,6 +138,12 @@ interface ResolvedOptions { maximumSpeedMps: number; assistedCruiseMps: number; assistedAltitudeM: number; + windNorthMps: number; + windEastMps: number; + turbulenceMps: number; + stallSpeedMps: number; + batteryCapacityWh: number; + terrainElevationM: (lat: number, lng: number) => number; fixedStepSeconds: number; maxFrameDeltaSeconds: number; } @@ -199,6 +225,15 @@ function resolveOptions(value: AircraftControllerOptions): ResolvedOptions { envelope.minAltitudeM, 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 { initialPosition: { lat: clamp(finiteOr(value.initialPosition?.lat, 34.0522), envelope.minLat, envelope.maxLat), @@ -214,6 +249,12 @@ function resolveOptions(value: AircraftControllerOptions): ResolvedOptions { maximumSpeedMps, assistedCruiseMps: clamp(finiteOr(value.assistedCruiseMps, 62), minimumSpeedMps, maximumSpeedMps), 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), maxFrameDeltaSeconds: clamp(finiteOr(value.maxFrameDeltaSeconds, 0.25), 0.05, 1), }; @@ -264,6 +305,17 @@ export class AircraftController { pitchInput: 0, rollInput: 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, elapsedSteps: 0, }; @@ -299,11 +351,33 @@ export class AircraftController { pitchInput: 0, rollInput: 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, 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 = NEUTRAL_AIRCRAFT_ACTIONS): number { if (!Number.isFinite(deltaSeconds) || deltaSeconds <= 0) return 0; const normalized = normalizeAircraftActions(actions); @@ -358,12 +432,28 @@ export class AircraftController { this.current.pitchInput = moveToward(this.current.pitchInput, pitch, 2.2 * dt); this.current.rollInput = moveToward(this.current.rollInput, roll, 2.8 * dt); - const targetRoll = this.current.rollInput * 58; - const targetPitch = this.current.pitchInput * 22; + const gustPhase = this.current.elapsedSteps * dt * 0.73; + 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.pitchDeg = moveToward(this.current.pitchDeg, targetPitch, 28 * dt); - const thrust = this.current.throttle * 8.5; - const drag = 1.2 + this.current.speedMps * this.current.speedMps * 0.00075; + const flightPathDeg = Math.atan2( + 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 + (thrust - drag) * dt, this.options.minimumSpeedMps, @@ -373,16 +463,55 @@ export class AircraftController { this.current.headingDeg = wrapDegrees( 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 horizontalSpeed = Math.cos(this.current.pitchDeg * Math.PI / 180) * this.current.speedMps; - const northM = Math.cos(heading) * horizontalSpeed * dt; - const eastM = Math.sin(heading) * horizontalSpeed * dt; + const northM = (Math.cos(heading) * horizontalSpeed + this.current.windNorthMps) * dt; + const eastM = (Math.sin(heading) * horizontalSpeed + this.current.windEastMps) * dt; const nextLat = this.current.lat + northM / EARTH_RADIUS_M * 180 / Math.PI; const nextLng = this.current.lng + eastM / (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; this.current.envelopeContact = nextLat < envelope.minLat || nextLat > envelope.maxLat || @@ -391,12 +520,18 @@ export class AircraftController { this.current.lat = clamp(nextLat, envelope.minLat, envelope.maxLat); this.current.lng = clamp(nextLng, envelope.minLng, envelope.maxLng); this.current.altitudeM = clamp(nextAltitude, envelope.minAltitudeM, envelope.maxAltitudeM); + this.current.groundClearanceM = Math.max(0, this.current.altitudeM - groundElevationM); if (this.current.envelopeContact) { 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.rollDeg = moveToward(this.current.rollDeg, 0, 90 * dt); } 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; } } diff --git a/src/realtime/scenePeers.ts b/src/realtime/scenePeers.ts index 276e733..39d3d4b 100644 --- a/src/realtime/scenePeers.ts +++ b/src/realtime/scenePeers.ts @@ -153,16 +153,19 @@ function childGroup(root: THREE.Group, name: string): THREE.Group { function cloneAircraft(prototype: ElectricAircraftRig): ElectricAircraftRig { 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 { root, leftAileron: childGroup(root, "electric-aircraft.aileron-left"), rightAileron: childGroup(root, "electric-aircraft.aileron-right"), leftVTail: childGroup(root, "electric-aircraft.v-tail-left"), rightVTail: childGroup(root, "electric-aircraft.v-tail-right"), - fans: [ - childGroup(root, "electric-aircraft.fan-left"), - childGroup(root, "electric-aircraft.fan-right"), - ], + fans, ownsMaterials: false, }; } diff --git a/src/test/aircraft.test.ts b/src/test/aircraft.test.ts index 77cf62a..0192844 100644 --- a/src/test/aircraft.test.ts +++ b/src/test/aircraft.test.ts @@ -26,7 +26,7 @@ describe("procedural electric aircraft", () => { assert.equal(rig.root.name, "electric-aircraft"); assert.equal(rig.root.userData.forwardAxis, "-Z"); 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.ok(ELECTRIC_AIRCRAFT_METRICS.wingspan > ELECTRIC_AIRCRAFT_METRICS.length); const bounds = new THREE.Box3().setFromObject(rig.root); @@ -39,8 +39,13 @@ describe("procedural electric aircraft", () => { const wing = rig.root.getObjectByName("electric-aircraft:wing"); assert.ok(wing instanceof THREE.Mesh); const normals = wing.geometry.getAttribute("normal"); - assert.ok(normals && Array.from({ length: normals.count }, (_, index) => normals.getY(index)).every((y) => y > 0), - "wing front faces point toward the chase/flyover camera"); + assert.ok(normals && Array.from({ length: normals.count }, (_, index) => normals.getY(index)).some((y) => y > 0.5), + "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); assert.equal(rig.root.children.length, 0); }); @@ -56,7 +61,7 @@ describe("procedural electric aircraft", () => { ).multiplyScalar(1 / blades.length); assert.ok(centroid.length() < 1e-12, `fan centroid ${centroid.toArray()}`); 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); assert.ok(radial.angleTo(blade.position.clone().normalize()) < 1e-7); } @@ -168,6 +173,66 @@ describe("aircraft controller", () => { 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", () => { const options = { route: ROUTE, initialAltitudeM: 1_200, initialSpeedMps: 48 } as const; const controller = new AircraftController(options); @@ -218,5 +283,8 @@ describe("aircraft controller", () => { maxAltitudeM: 100, }, }), /envelope/); + assert.throws(() => new AircraftController({ + terrainElevationM: 3 as unknown as (lat: number, lng: number) => number, + }), /terrain/); }); });