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:
Quintine committed 2026-08-24 17:58:58 +10:00
commit 8c19cdaae6
18 files changed
+4499

No files matched your search

+74
View File
@@ -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);
+451
View File
@@ -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}`);
});