3D flight simulator: 6-DOF physics, three.js r160, browser HUD
- Detailed flight dynamics: aero lift/drag/side-force with smooth stall, control-surface moments, stability derivatives, engine spool with prop torque and gyro precession, inertial coupling, ISA atmosphere, ground effect, 3-point gear handling, landing vs crash classification. - Pure ESM, zero build step; three.js vendored locally. Deterministic simulation; physics core is Node-testable (14 node:test cases). - Procedural 8 km world with runway, water, sky dome, cloud wrap; DOM instrument panel (attitude indicator, tapes, VSI, lamps, stall warning). - Controls: WASD/arrows, Q/E rudder, Shift/Ctrl throttle, F flaps, G gear, B brakes, C camera (chase/cockpit/orbit), R reset, P pause, D debug.
This commit is contained in:
commit
8c19cdaae6
18 files changed
+4499
No files matched your search
@@ -0,0 +1,74 @@
|
||||
// Geometry sanity: the wing/stab chords must run AFT (−X), and flaps/
|
||||
// ailerons/elevator must hinge at the actual trailing edges. Runs the vendored
|
||||
// three.js in Node — no DOM needed.
|
||||
import * as THREE from '../vendor/three.module.min.js';
|
||||
import { buildAircraftModel } from '../src/aircraft-model.js';
|
||||
|
||||
const { group, animate } = buildAircraftModel();
|
||||
const bbox = (m) => new THREE.Box3().setFromObject(m); // fresh Box3 per call
|
||||
|
||||
let fail = 0;
|
||||
const check = (name, cond, detail) => {
|
||||
console.log((cond ? 'PASS' : 'FAIL') + ' ' + name + (detail ? ' (' + detail + ')' : ''));
|
||||
if (!cond) fail++;
|
||||
};
|
||||
|
||||
// Identify meshes by geometry parameters.
|
||||
const meshes = [];
|
||||
group.traverse((o) => { if (o.isMesh) meshes.push(o); });
|
||||
const byBox = (w, h, d) => meshes.filter((m) => {
|
||||
const p = m.geometry.parameters;
|
||||
return p && p.width === w && p.height === h && p.depth === d;
|
||||
});
|
||||
const wing = meshes.find((m) => m.geometry.type === 'ExtrudeGeometry' && Math.abs(m.position.y) < 0.001 && m.position.x > 0 && m.position.z > 0.5);
|
||||
const stab = meshes.find((m) => m.geometry.type === 'ExtrudeGeometry' && m.position.x < -2 && m.position.z > -0.2);
|
||||
const flaps = byBox(0.45, 1.15, 0.045);
|
||||
const ails = byBox(0.42, 2.0, 0.045);
|
||||
const elevs = byBox(0.32, 4.0, 0.06);
|
||||
|
||||
if (!wing || !stab) { console.log('FAIL could not locate wing/stab meshes'); process.exit(1); }
|
||||
const wb = bbox(wing), sb = bbox(stab);
|
||||
|
||||
console.log('wing bbox: x [' + wb.min.x.toFixed(2) + ', ' + wb.max.x.toFixed(2) + '] z [' + wb.min.z.toFixed(2) + ', ' + wb.max.z.toFixed(2) + ']');
|
||||
console.log('stab bbox: x [' + sb.min.x.toFixed(2) + ', ' + sb.max.x.toFixed(2) + ']');
|
||||
|
||||
check('wing chord runs AFT (LE x=0.65 > TE x≈−1.30)', Math.abs(wb.max.x - 0.65) < 0.02 && wb.min.x < -1.25, 'LE=' + wb.max.x.toFixed(2) + ' TE=' + wb.min.x.toFixed(2));
|
||||
const wingTE = wb.min.x;
|
||||
|
||||
for (const f of flaps) {
|
||||
const fb = bbox(f);
|
||||
check('flap hinges at wing TE and sweeps AFT', Math.abs(fb.max.x - wingTE) < 0.03 && fb.min.x < fb.max.x - 0.4,
|
||||
'hinge=' + fb.max.x.toFixed(2) + ' aftEnd=' + fb.min.x.toFixed(2));
|
||||
check('flap sits behind the wing LEADING edge (not at the front!)', fb.max.x < wb.max.x - 1.5);
|
||||
}
|
||||
for (const a of ails) {
|
||||
const ab = bbox(a);
|
||||
check('aileron hinges at wing TE and sweeps AFT', ab.max.x > -1.35 && ab.max.x < -1.1 && ab.min.x < ab.max.x - 0.35,
|
||||
'hinge=' + ab.max.x.toFixed(2));
|
||||
check('aileron behind the wing LE', ab.max.x < wb.max.x - 1.5);
|
||||
}
|
||||
check('two flaps + two ailerons built', flaps.length === 2 && ails.length === 2, flaps.length + '/' + ails.length);
|
||||
|
||||
const elev = elevs[0];
|
||||
if (elev) {
|
||||
const eb = bbox(elev);
|
||||
check('elevator hinges at stab TE (−3.08) and sweeps AFT',
|
||||
Math.abs(eb.max.x - sb.min.x) < 0.03 && eb.min.x < eb.max.x - 0.25,
|
||||
'hinge=' + eb.max.x.toFixed(2) + ' stabTE=' + sb.min.x.toFixed(2));
|
||||
}
|
||||
|
||||
// Deflection directions: drive animate() and read the sign of each surface.
|
||||
const inputs = { elevator: 1, aileron: 1, rudder: 0.5, flaps: 1, gear: true, brakes: false };
|
||||
animate({ rpm: 0.5, flaps: 1, gearDown: true }, inputs, 0.1);
|
||||
const find = (w, h, d) => byBox(w, h, d)[0];
|
||||
const elevMesh = find(0.32, 4.0, 0.06);
|
||||
const flapMesh = find(0.45, 1.15, 0.045);
|
||||
const ailS = ails.find((m) => m.position.y > 0);
|
||||
const ailP = ails.find((m) => m.position.y < 0);
|
||||
check('elevator TE UP on nose-up command (rotation.y < 0)', elevMesh.rotation.y < 0, elevMesh.rotation.y.toFixed(3));
|
||||
check('flaps TE DOWN when deployed (rotation.y > 0)', flapMesh.rotation.y > 0, flapMesh.rotation.y.toFixed(3));
|
||||
check('starboard aileron TE UP on positive input (rotation.y < 0)', ailS.rotation.y < 0, ailS.rotation.y.toFixed(3));
|
||||
check('port aileron TE DOWN on positive input (rotation.y > 0)', ailP.rotation.y > 0, ailP.rotation.y.toFixed(3));
|
||||
|
||||
console.log(fail === 0 ? '\nALL GEOMETRY CHECKS PASSED' : '\n' + fail + ' CHECK(S) FAILED');
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,451 @@
|
||||
// tests/physics.test.mjs — deterministic unit tests for src/physics.js.
|
||||
// Run: node --test tests/physics.test.mjs
|
||||
//
|
||||
// Covers the 7 required SPEC tests plus brutallly-honest extras:
|
||||
// instrument completeness/ranges, 3000 m / 80% cruise envelope, rudder turn
|
||||
// direction consistency, gear-up vs gear-down drag, monotonic fuel, and
|
||||
// exact determinism across substep splits.
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
createAircraft, step, resetAircraft,
|
||||
getInstrumentData, getAeroSummary,
|
||||
} from '../src/physics.js';
|
||||
import { cfg, atmoDensity } from '../src/config.js';
|
||||
import { getGroundHeight, WORLD } from '../src/world.js';
|
||||
|
||||
const DT = 1 / 120; // caller-guaranteed fixed step (main.js FIXED_DT)
|
||||
const KTS = 1.94384;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Quaternion [w,x,y,z]: level-north (heading 0, wings level) then pitch up. */
|
||||
export function levelNoseUpQuat(pitchRad) {
|
||||
const qp = Math.cos(pitchRad / 2), sp = Math.sin(pitchRad / 2);
|
||||
const LQ = [0.5, 0.5, 0.5, -0.5]; // level-north (validated vs three.js r160)
|
||||
return [
|
||||
qp * LQ[0] - sp * LQ[1],
|
||||
qp * LQ[1] + sp * LQ[0],
|
||||
qp * LQ[2] + sp * LQ[3],
|
||||
qp * LQ[3] - sp * LQ[2],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a steady level-cruise state at `altM`: lift = weight, thrust = drag,
|
||||
* zero pitching moment, zero rates (analytic trim). Returns the aircraft, the
|
||||
* inputs snapshot that holds the trim, and the design cruise speed Vt.
|
||||
*/
|
||||
export function cruiseState(altM, throttle = 0.7) {
|
||||
const rho = atmoDensity(altM);
|
||||
const W = cfg.mass * cfg.g;
|
||||
const alphaT = 0.010; // rad trim alpha (small positive AOA)
|
||||
const CLt = cfg.CL0 + cfg.CLalpha * alphaT;
|
||||
const Vt = Math.sqrt((2 * W) / (rho * cfg.S * CLt)); // L = W
|
||||
const CDt = cfg.CD0 + cfg.kInd * CLt * CLt;
|
||||
const Dt = 0.5 * rho * Vt * Vt * cfg.S * CDt;
|
||||
const rpmNeeded = Dt / (cfg.Tmax * Math.pow(rho / 1.225, 0.7));
|
||||
const rpm = Math.max(throttle, rpmNeeded); // keep the engine spooled at set point
|
||||
const deT = -(cfg.Cm0 + cfg.CmAlpha * alphaT) / cfg.Cmδe; // Cm = 0 at q̇=0
|
||||
const elevT = -deT / cfg.dE_max; // δe = −elev·dE_max (input is the stick)
|
||||
assert.ok(rpm <= 1, `cruise rpm ${rpm} must not exceed 1`);
|
||||
|
||||
const a = createAircraft();
|
||||
a.pos = [0, altM, 0];
|
||||
a.quat = levelNoseUpQuat(alphaT);
|
||||
// Level flight at pitch αt: body velocity has downward-body component +V·sinαt.
|
||||
a.vel = [Vt * Math.cos(alphaT), 0, Vt * Math.sin(alphaT)];
|
||||
a.rpm = rpm;
|
||||
a.throttle = rpm;
|
||||
a.flaps = 0;
|
||||
a.gearDown = false;
|
||||
a.brakes = false;
|
||||
const inputs = {
|
||||
elevator: elevT, aileron: 0, rudder: 0, throttle: rpm,
|
||||
flaps: 0, gear: false, brakes: false,
|
||||
};
|
||||
return { a, inputs, Vt };
|
||||
}
|
||||
|
||||
function run(a, inputs, seconds, dt = DT) {
|
||||
const n = Math.round(seconds / dt);
|
||||
for (let i = 0; i < n; i++) step(a, inputs, dt);
|
||||
}
|
||||
|
||||
/** Unwrapped heading change (deg) over the run, monitoring each step. */
|
||||
function headingDrift(a, inputs, seconds) {
|
||||
let unwrap = 0;
|
||||
let prev = getInstrumentData(a).headingDeg;
|
||||
for (let s = 0; s < Math.round(seconds); s++) {
|
||||
run(a, inputs, 1);
|
||||
const h = getInstrumentData(a).headingDeg;
|
||||
unwrap += (h - prev + 540) % 360 - 180;
|
||||
prev = h;
|
||||
}
|
||||
return unwrap;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Trim / cruise: 1000 m, throttle 0.7, 20 s → airspeed within ±5% of the
|
||||
// analytic design-cruise speed, and inside 120–180 kts.
|
||||
// ---------------------------------------------------------------------------
|
||||
test('trim/cruise holds design airspeed at 1000 m (70% throttle)', () => {
|
||||
const { a, inputs, Vt } = cruiseState(1000, 0.7);
|
||||
assert.ok(Vt * KTS >= 120 && Vt * KTS <= 180,
|
||||
`design cruise ${(Vt * KTS).toFixed(0)} kts must be in 120-180 kts`);
|
||||
run(a, inputs, 20);
|
||||
const d = getInstrumentData(a);
|
||||
assert.ok(Math.abs(d.airspeedMps - Vt) / Vt <= 0.05,
|
||||
`airspeed ${d.airspeedMps.toFixed(1)} m/s vs cruise ${Vt.toFixed(1)} ` +
|
||||
`(${(Math.abs(d.airspeedMps - Vt) / Vt * 100).toFixed(1)}%)`);
|
||||
assert.ok(d.airspeedKts >= 120 && d.airspeedKts <= 180,
|
||||
`airspeed ${d.airspeedKts.toFixed(0)} kts must be within 120-180 kts`);
|
||||
// altitude should stay near the cruise level (no uncontrolled dive/climb)
|
||||
assert.ok(Math.abs(d.altM - 1000) < 150, `altitude ${d.altM.toFixed(0)} m drifted`);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Stall: full up elevator, 30% throttle → α > α_crit within 25 s, stall
|
||||
// flag true.
|
||||
// ---------------------------------------------------------------------------
|
||||
test('full up elevator at 30% throttle stalls within 25 s', () => {
|
||||
const { a, inputs } = cruiseState(1000, 0.7);
|
||||
const stall = { elevator: 1, aileron: 0, rudder: 0, throttle: 0.3,
|
||||
flaps: 0, gear: false, brakes: false };
|
||||
let sawStall = false;
|
||||
let maxAlpha = 0;
|
||||
for (let s = 0; s < 25; s++) {
|
||||
run(a, stall, 1);
|
||||
const d = getInstrumentData(a);
|
||||
maxAlpha = Math.max(maxAlpha, Math.abs(d.alphaDeg));
|
||||
if (d.stall) sawStall = true;
|
||||
}
|
||||
assert.ok(maxAlpha > cfg.alphaCrit * (180 / Math.PI),
|
||||
`alpha never exceeded alpha_crit (max |alpha| ${maxAlpha.toFixed(1)}°)`);
|
||||
assert.ok(sawStall, 'stall flag never became true within 25 s');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Inverted sign: at α = −10° the lift coefficient (and lift force) is
|
||||
// negative — lift points down.
|
||||
// ---------------------------------------------------------------------------
|
||||
test('inverted sign: CL < 0 at alpha = -10 deg (lift points down)', () => {
|
||||
const a = createAircraft();
|
||||
a.pos = [0, 1000, 0];
|
||||
a.flaps = 0;
|
||||
a.gearDown = false;
|
||||
const V = 60;
|
||||
const aN = -10 * Math.PI / 180;
|
||||
a.vel = [V * Math.cos(aN), 0, V * Math.sin(aN)]; // vz > 0 → alpha = −10°
|
||||
const s = getAeroSummary(a);
|
||||
assert.ok(Math.abs(s.alpha - aN) < 1e-9, `alpha ${s.alpha} != -10 deg`);
|
||||
assert.ok(s.cl < 0, `cl ${s.cl.toFixed(3)} must be negative at alpha=-10`);
|
||||
assert.ok(s.liftN < 0, `liftN ${s.liftN.toFixed(0)} must point down`);
|
||||
// mirror check: at +10° the sign flips
|
||||
a.vel = [V * Math.cos(-aN), 0, V * Math.sin(-aN)];
|
||||
const s2 = getAeroSummary(a);
|
||||
assert.ok(s2.cl > 0 && s2.liftN > 0, 'lift must be positive at alpha=+10');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Ground stop: spawn on runway, gear down, brakes → rest in < 400 m and
|
||||
// never sink below ground − 0.1.
|
||||
// ---------------------------------------------------------------------------
|
||||
test('braked ground stop from 30 m/s < 400 m, never below ground', () => {
|
||||
const a = createAircraft(); // spawns on the runway, gear down, brakes on
|
||||
a.rpm = 0; // dead-stick: brakes alone must stop the airframe
|
||||
a.engineOn = false;
|
||||
a.vel = [30, 0, 0]; // north-bound (body +X at heading 0 = world −Z)
|
||||
const inputs = { elevator: 0, aileron: 0, rudder: 0, throttle: 0,
|
||||
flaps: 0.25, gear: true, brakes: true };
|
||||
const groundAt = getGroundHeight(a.pos[0], a.pos[2]);
|
||||
const start = [a.pos[0], a.pos[2]];
|
||||
let minY = Infinity;
|
||||
for (let s = 0; s < 60; s++) {
|
||||
run(a, inputs, 1);
|
||||
minY = Math.min(minY, a.pos[1]);
|
||||
}
|
||||
const dist = Math.hypot(a.pos[0] - start[0], a.pos[2] - start[1]);
|
||||
assert.ok(Math.hypot(...a.vel) < 0.05, `still moving: V=${Math.hypot(...a.vel)}`);
|
||||
assert.ok(dist < 400, `stopping distance ${dist.toFixed(0)} m >= 400`);
|
||||
assert.ok(minY >= groundAt - 0.1, `CG sank to ${minY.toFixed(3)} m`);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Heavy impact: vertical sink 8 m/s with gear up → crashed.
|
||||
// ---------------------------------------------------------------------------
|
||||
test('vertical impact at 8 m/s with gear up crashes', () => {
|
||||
const a = createAircraft();
|
||||
a.pos = [0, cfg.wheelH + 2, WORLD.spawn.z];
|
||||
a.groundContact = false;
|
||||
a.airTime = 1;
|
||||
a.vel = [0, 0, 8]; // body +Z = world down at level attitude
|
||||
a.gearDown = false;
|
||||
const inputs = { elevator: 0, aileron: 0, rudder: 0, throttle: 0,
|
||||
flaps: 0.25, gear: false, brakes: true };
|
||||
run(a, inputs, 1);
|
||||
assert.ok(a.crashed, 'aircraft must crash on a hard gear-up impact');
|
||||
assert.ok(a.impactSpeed >= 7,
|
||||
`impactSpeed ${a.impactSpeed.toFixed(2)} should be ~ the 8 m/s sink`);
|
||||
// sanity: gear-down version of the same descent is survivable
|
||||
const b = createAircraft();
|
||||
b.pos = [0, cfg.wheelH + 2, WORLD.spawn.z];
|
||||
b.groundContact = false;
|
||||
b.airTime = 1;
|
||||
b.vel = [0, 0, 8];
|
||||
b.gearDown = true;
|
||||
const insB = { elevator: 0, aileron: 0, rudder: 0, throttle: 0,
|
||||
flaps: 0.25, gear: true, brakes: true };
|
||||
run(b, insB, 1);
|
||||
// 8 m/s > maxTouchVz (3) with gear down is also a crash by the SPEC rule —
|
||||
// this is just a hard landing, allowed only ≤ 3 m/s.
|
||||
assert.ok(b.crashed, '8 m/s still exceeds the gear-down 3 m/s limit');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Quaternion unit norm after 2000 substeps.
|
||||
// ---------------------------------------------------------------------------
|
||||
test('quaternion stays unit-norm after 2000 substeps', () => {
|
||||
const a = createAircraft();
|
||||
a.pos = [0, 1200, 0];
|
||||
a.flaps = 0;
|
||||
a.gearDown = false;
|
||||
a.angVel = [1.2, -0.8, 1.5];
|
||||
a.vel = [40, 5, -10];
|
||||
const inputs = { elevator: 0.7, aileron: -0.9, rudder: 0.4, throttle: 0.5,
|
||||
flaps: 0, gear: false, brakes: false };
|
||||
for (let i = 0; i < 2000; i++) step(a, inputs, 1 / 240);
|
||||
const n = Math.hypot(a.quat[0], a.quat[1], a.quat[2], a.quat[3]);
|
||||
assert.ok(Math.abs(n - 1) <= 1e-6, `|q| = ${n} drifted by ${(n - 1).toExponential(2)}`);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. Lateral symmetry: 3 s full roll right vs roll left → |bank| within 2%.
|
||||
// (Engine off so prop torque/gyro asymmetry cannot bias the comparison.)
|
||||
// ---------------------------------------------------------------------------
|
||||
test('roll-right vs roll-left for 3 s give symmetric |bank| (≤2%)', () => {
|
||||
function roll(sign) {
|
||||
const a = createAircraft();
|
||||
a.pos = [0, 1500, 0];
|
||||
a.flaps = 0;
|
||||
a.gearDown = false;
|
||||
a.rpm = 0;
|
||||
a.throttle = 0;
|
||||
a.engineOn = false;
|
||||
a.vel = [60, 0, 0];
|
||||
const inputs = { elevator: 0, aileron: sign, rudder: 0, throttle: 0,
|
||||
flaps: 0, gear: false, brakes: false };
|
||||
run(a, inputs, 3);
|
||||
return Math.abs(getInstrumentData(a).bankDeg);
|
||||
}
|
||||
const bR = roll(1);
|
||||
const bL = roll(-1);
|
||||
const base = Math.min(bR, bL);
|
||||
assert.ok(base > 10, `roll was too weak (|bank| ${base.toFixed(1)}°)`);
|
||||
assert.ok(Math.abs(bR - bL) / base <= 0.02,
|
||||
`|bank| right ${bR.toFixed(1)}° vs left ${bL.toFixed(1)}° differ > 2%`);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 8. getInstrumentData completeness + sane ranges (every SPEC field).
|
||||
// ---------------------------------------------------------------------------
|
||||
test('getInstrumentData returns every SPEC field with sane values', () => {
|
||||
const required = [
|
||||
'airspeedMps', 'airspeedKts', 'altM', 'altFt', 'vsMps', 'vsFpm',
|
||||
'headingDeg', 'pitchDeg', 'bankDeg', 'rollRateDps', 'pitchRateDps', 'yawRateDps',
|
||||
'alphaDeg', 'betaDeg', 'gLoad', 'stall', 'engineOn', 'rpm', 'throttle',
|
||||
'flapsPos', 'gearDown', 'brakes', 'fuelKg', 'groundContact', 'landed',
|
||||
'crashed', 'onGround', 'rolling', 'airTimeS',
|
||||
];
|
||||
// parked state
|
||||
const parked = getInstrumentData(createAircraft());
|
||||
for (const k of required) {
|
||||
assert.ok(k in parked, `missing field ${k}`);
|
||||
assert.ok(Number.isFinite(parked[k]) || typeof parked[k] === 'boolean',
|
||||
`non-finite ${k}`);
|
||||
}
|
||||
// airborne cruise state
|
||||
const { a, inputs } = cruiseState(1000, 0.7);
|
||||
run(a, inputs, 5);
|
||||
const d = getInstrumentData(a);
|
||||
for (const k of required) {
|
||||
assert.ok(Number.isFinite(d[k]) || typeof d[k] === 'boolean', `${k} bad`);
|
||||
}
|
||||
assert.ok(d.airspeedKts > 100 && d.airspeedKts < 200, `airspeedKts ${d.airspeedKts}`);
|
||||
assert.ok(d.altM > 900 && d.altM < 1100, `altM ${d.altM}`);
|
||||
assert.ok(d.rpm >= 0 && d.rpm <= 1, `rpm ${d.rpm}`);
|
||||
assert.ok(d.throttle >= 0 && d.throttle <= 1, `throttle ${d.throttle}`);
|
||||
assert.ok(d.flapsPos >= 0 && d.flapsPos <= 1, `flapsPos ${d.flapsPos}`);
|
||||
assert.ok(d.fuelKg > 0 && d.fuelKg <= cfg.fuelKg, `fuelKg ${d.fuelKg}`);
|
||||
assert.ok(Math.abs(d.alphaDeg) < 45, `alphaDeg ${d.alphaDeg}`);
|
||||
assert.ok(Math.abs(d.betaDeg) < 45, `betaDeg ${d.betaDeg}`);
|
||||
assert.ok(d.gLoad > 0.5 && d.gLoad < 3, `gLoad ${d.gLoad}`);
|
||||
assert.ok(d.engineOn === true, 'engine must be running in cruise');
|
||||
assert.ok(d.crashed === false && d.groundContact === false, 'must be airborne');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 9. Envelope: cruise at 3000 m @ 80% throttle for 30 s stays on-trim,
|
||||
// never stalls, fuel depletes monotonically.
|
||||
// ---------------------------------------------------------------------------
|
||||
test('3000 m / 80% cruise stays within envelope (V, alt, no stall, fuel)', () => {
|
||||
const { a, inputs, Vt } = cruiseState(3000, 0.8);
|
||||
assert.ok(Vt * KTS >= 120 && Vt * KTS <= 180, `3000 m cruise ${Vt * KTS} kts`);
|
||||
let minAlt = Infinity, maxAlt = -Infinity, stallSeen = false;
|
||||
const fuel0 = a.fuelKg;
|
||||
let prevFuel = fuel0;
|
||||
for (let s = 0; s < 60; s++) { // 60 × 0.5 s = 30 s
|
||||
run(a, inputs, 0.5);
|
||||
const d = getInstrumentData(a);
|
||||
minAlt = Math.min(minAlt, d.altM);
|
||||
maxAlt = Math.max(maxAlt, d.altM);
|
||||
if (d.stall) stallSeen = true;
|
||||
assert.ok(d.fuelKg <= prevFuel + 1e-12, 'fuel must never increase');
|
||||
prevFuel = d.fuelKg;
|
||||
}
|
||||
const d = getInstrumentData(a);
|
||||
assert.ok(Math.abs(d.airspeedMps - Vt) / Vt <= 0.05,
|
||||
`airspeed ${d.airspeedMps.toFixed(1)} m/s vs ${Vt.toFixed(1)} m/s`);
|
||||
assert.ok(Math.abs(maxAlt - 3000) <= 200 && Math.abs(3000 - minAlt) <= 200,
|
||||
`altitude drifted to [${minAlt.toFixed(0)}, ${maxAlt.toFixed(0)}] m`);
|
||||
assert.ok(!stallSeen, 'cruise must not stall');
|
||||
const consumed = fuel0 - a.fuelKg;
|
||||
assert.ok(consumed > 0.8,
|
||||
`fuel barely burned (${consumed.toFixed(2)} kg in 30 s @ 80%)`);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 10. Rudder: full-ish right rudder for 5 s → consistent right turn
|
||||
// (unwrapped heading advance, every second segment still right).
|
||||
// ---------------------------------------------------------------------------
|
||||
test('right rudder 5 s produces a consistent clockwise turn', () => {
|
||||
const a = createAircraft();
|
||||
a.pos = [0, 1500, 0];
|
||||
a.flaps = 0;
|
||||
a.gearDown = false;
|
||||
a.rpm = 0;
|
||||
a.throttle = 0;
|
||||
a.engineOn = false;
|
||||
a.vel = [60, 0, 0];
|
||||
const inputs = { elevator: 0, aileron: 0, rudder: 0.5, throttle: 0,
|
||||
flaps: 0, gear: false, brakes: false };
|
||||
let unwrap = 0, prev = getInstrumentData(a).headingDeg;
|
||||
const segments = [];
|
||||
for (let s = 0; s < 5; s++) {
|
||||
run(a, inputs, 1);
|
||||
const h = getInstrumentData(a).headingDeg;
|
||||
const dh = (h - prev + 540) % 360 - 180;
|
||||
segments.push(dh);
|
||||
unwrap += dh;
|
||||
prev = h;
|
||||
}
|
||||
assert.ok(unwrap > 20, `right rudder must turn right (>20°), got ${unwrap.toFixed(1)}°`);
|
||||
assert.ok(segments.every((d) => d >= -1),
|
||||
`heading reversed mid-turn: ${segments.map((x) => x.toFixed(1)).join(', ')}`);
|
||||
// mirror: left rudder must turn left
|
||||
const b = createAircraft();
|
||||
b.pos = [0, 1500, 0];
|
||||
b.flaps = 0; b.gearDown = false; b.rpm = 0; b.throttle = 0; b.engineOn = false;
|
||||
b.vel = [60, 0, 0];
|
||||
const insL = { elevator: 0, aileron: 0, rudder: -0.5, throttle: 0,
|
||||
flaps: 0, gear: false, brakes: false };
|
||||
const dLeft = headingDrift(b, insL, 5);
|
||||
assert.ok(dLeft < -20, `left rudder must turn left, got ${dLeft.toFixed(1)}°`);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 11. Gear drag: at the same state, drag with gear down is measurably larger.
|
||||
// ---------------------------------------------------------------------------
|
||||
test('gear-down drag exceeds gear-up drag (>30%) at cruise speed', () => {
|
||||
const mk = (gear) => {
|
||||
const a = createAircraft();
|
||||
a.pos = [0, 1500, 0];
|
||||
a.flaps = 0;
|
||||
a.gearDown = gear;
|
||||
a.rpm = 0.3; a.throttle = 0.3; a.engineOn = true;
|
||||
a.vel = [70, 0, 0];
|
||||
return a;
|
||||
};
|
||||
const up = getAeroSummary(mk(false));
|
||||
const down = getAeroSummary(mk(true));
|
||||
assert.ok(down.dragN > up.dragN * 1.3,
|
||||
`gear drag too small: up ${up.dragN.toFixed(0)} N vs down ${down.dragN.toFixed(0)} N`);
|
||||
// gear drag also raises total CD measurably
|
||||
assert.ok(down.cd > up.cd + cfg.dCD_gear * 0.5,
|
||||
`CD delta too small: ${up.cd.toFixed(4)} -> ${down.cd.toFixed(4)}`);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 12. Fuel depletes monotonically (and monotonically over a long cruise).
|
||||
// ---------------------------------------------------------------------------
|
||||
test('fuel depletes monotonically; enough burned in 120 s cruise', () => {
|
||||
const { a, inputs } = cruiseState(1000, 0.7);
|
||||
const fuel0 = a.fuelKg;
|
||||
let prev = fuel0;
|
||||
for (let i = 0; i < 240; i++) { // 2 s at 1/120
|
||||
step(a, inputs, 1 / 120);
|
||||
assert.ok(a.fuelKg <= prev + 1e-12, `fuel increased at step ${i}`);
|
||||
prev = a.fuelKg;
|
||||
}
|
||||
assert.ok(fuel0 - a.fuelKg > 0.05,
|
||||
`only ${(fuel0 - a.fuelKg).toFixed(3)} kg burned in 2 s of cruise ` +
|
||||
`(burn rate ${(cfg.fuelBurn * 0.7 * 0.7).toFixed(4)} kg/s)`);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 13. Determinism: two runs split differently (1/120 vs 1/240 steps) produce
|
||||
// bit-identical state; NaN/Infinity inputs cannot poison the sim.
|
||||
// ---------------------------------------------------------------------------
|
||||
test('deterministic across substep splits; NaN inputs are harmless', () => {
|
||||
const mk = () => {
|
||||
const a = createAircraft();
|
||||
a.pos = [0, 1200, 0];
|
||||
a.flaps = 0; a.gearDown = false; a.rpm = 0.5; a.throttle = 0.5;
|
||||
a.vel = [50, 2, -3]; a.angVel = [0.1, -0.2, 0.3];
|
||||
return a;
|
||||
};
|
||||
const inputs = { elevator: 0.2, aileron: 0.3, rudder: -0.1, throttle: 0.5,
|
||||
flaps: 0, gear: false, brakes: false };
|
||||
const a1 = mk(), a2 = mk();
|
||||
for (let i = 0; i < 480; i++) step(a1, inputs, 1 / 240);
|
||||
for (let i = 0; i < 240; i++) step(a2, inputs, 1 / 120);
|
||||
for (const k of ['pos', 'vel', 'angVel', 'quat']) {
|
||||
for (let j = 0; j < a1[k].length; j++) {
|
||||
assert.ok(a1[k][j] === a2[k][j],
|
||||
`${k}[${j}] differs: ${a1[k][j]} vs ${a2[k][j]}`);
|
||||
}
|
||||
}
|
||||
// NaN inputs must not poison the state
|
||||
const a3 = createAircraft();
|
||||
step(a3, { elevator: NaN, aileron: Infinity, rudder: NaN, throttle: NaN }, 1 / 120);
|
||||
const d = getInstrumentData(a3);
|
||||
assert.ok(Number.isFinite(d.pitchDeg) && Number.isFinite(d.bankDeg) &&
|
||||
Number.isFinite(d.airspeedMps), 'sim state must stay finite after bad inputs');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 14. resetAircraft: returns the aircraft to the spawn envelope (parked,
|
||||
// level, on the runway, fuel restored).
|
||||
// ---------------------------------------------------------------------------
|
||||
test('resetAircraft restores spawn state exactly', () => {
|
||||
const a = createAircraft();
|
||||
a.pos = [100, 2000, -500];
|
||||
a.vel = [40, 5, -3];
|
||||
a.angVel = [0.5, 0.2, -0.4];
|
||||
a.fuelKg = 3;
|
||||
a.crashed = true;
|
||||
a.airTime = 99;
|
||||
resetAircraft(a);
|
||||
const d = getInstrumentData(a);
|
||||
assert.deepEqual(a.pos, a.spawnPos);
|
||||
assert.deepEqual(a.quat, a.spawnQuat);
|
||||
assert.ok(Math.hypot(...a.vel) === 0 && Math.hypot(...a.angVel) === 0);
|
||||
assert.ok(a.fuelKg === cfg.fuelKg && a.crashed === false && a.airTime === 0);
|
||||
assert.ok(d.groundContact === true);
|
||||
assert.ok(Math.abs(d.bankDeg) < 1 && Math.abs(d.pitchDeg) < 1,
|
||||
`reset attitude off: pitch ${d.pitchDeg} bank ${d.bankDeg}`);
|
||||
});
|
||||
Reference in new issue
Block a user