Add JSBSim service backend
This commit is contained in:
171
service/src/backends/jsbsimBackend.js
Normal file
171
service/src/backends/jsbsimBackend.js
Normal file
@@ -0,0 +1,171 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
const serviceRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
const DEFAULT_RUNNER = resolve(serviceRoot, "scripts", "jsbsim_runner.py");
|
||||
const runnerPath = process.env.JSBSIM_RUNNER ?? DEFAULT_RUNNER;
|
||||
const pythonCommand = process.env.JSBSIM_PYTHON ?? "python";
|
||||
|
||||
export const backendInfo = {
|
||||
id: "jsbsim",
|
||||
name: "JSBSim",
|
||||
ready: true,
|
||||
runner: runnerPath,
|
||||
};
|
||||
|
||||
export const AIRCRAFT = [
|
||||
{
|
||||
id: "f22cobra-jsbsim",
|
||||
name: "F-22 Cobra Research JSBSim",
|
||||
source: "JSBSim",
|
||||
capabilities: {
|
||||
basic6dof: true,
|
||||
highAlpha: true,
|
||||
postStall: "experimental",
|
||||
thrustVectoring: true,
|
||||
requiresNoTrimForManeuverResearch: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "f22-jsbsim",
|
||||
name: "F-22 JSBSim",
|
||||
source: "JSBSim",
|
||||
capabilities: {
|
||||
basic6dof: true,
|
||||
highAlpha: true,
|
||||
postStall: "experimental",
|
||||
thrustVectoring: true,
|
||||
requiresNoTrimForManeuverResearch: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "f16-jsbsim",
|
||||
name: "F-16 JSBSim",
|
||||
source: "JSBSim",
|
||||
capabilities: {
|
||||
basic6dof: true,
|
||||
highAlpha: "limited",
|
||||
postStall: false,
|
||||
thrustVectoring: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "c172p-jsbsim",
|
||||
name: "Cessna 172P JSBSim",
|
||||
source: "JSBSim",
|
||||
capabilities: {
|
||||
basic6dof: true,
|
||||
highAlpha: false,
|
||||
postStall: false,
|
||||
thrustVectoring: false,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function trimInitialState(request) {
|
||||
validateTrimRequest(request);
|
||||
|
||||
return runJsbsimRunner({
|
||||
operation: "trim",
|
||||
aircraftId: request.aircraftId,
|
||||
sampleRateHz: 30,
|
||||
integrationRateHz: numberOrDefault(request.integrationRateHz, 120),
|
||||
trim: request,
|
||||
});
|
||||
}
|
||||
|
||||
export function simulateManeuver(request) {
|
||||
validateSimulateRequest(request);
|
||||
|
||||
return runJsbsimRunner({
|
||||
operation: "simulate",
|
||||
aircraftId: request.aircraftId.replace("-jsbsim", ""),
|
||||
sampleRateHz: numberOrDefault(request.sampleRateHz, 30),
|
||||
integrationRateHz: numberOrDefault(request.integrationRateHz, 120),
|
||||
durationSec: numberOrDefault(request.durationSec, 10),
|
||||
trim: request.trim,
|
||||
initialState: request.initialState,
|
||||
maneuver: request.maneuver,
|
||||
});
|
||||
}
|
||||
|
||||
function runJsbsimRunner(payload) {
|
||||
const result = spawnSync(pythonCommand, [runnerPath], {
|
||||
input: JSON.stringify(payload),
|
||||
encoding: "utf8",
|
||||
maxBuffer: 20 * 1024 * 1024,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(
|
||||
`Unable to run JSBSim runner with "${pythonCommand}". Install Python and jsbsim, or set JSBSIM_PYTHON/JSBSIM_RUNNER. ${result.error.message}`
|
||||
);
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
if (result.status === 9009) {
|
||||
throw new Error(
|
||||
`Unable to run JSBSim runner with "${pythonCommand}". Python is not available on PATH; install Python and jsbsim, or set JSBSIM_PYTHON to a Python executable.`
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`JSBSim runner failed with exit code ${result.status}: ${result.stderr.trim()}`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = JSON.parse(extractJson(result.stdout));
|
||||
return {
|
||||
...response,
|
||||
backend: backendInfo.id,
|
||||
};
|
||||
} catch {
|
||||
throw new Error(`JSBSim runner did not return valid JSON: ${result.stdout}`);
|
||||
}
|
||||
}
|
||||
|
||||
function extractJson(stdout) {
|
||||
const firstObject = stdout.indexOf("{");
|
||||
if (firstObject < 0) {
|
||||
throw new Error("No JSON payload found.");
|
||||
}
|
||||
return stdout.slice(firstObject).trim();
|
||||
}
|
||||
|
||||
function validateTrimRequest(request) {
|
||||
if (!request || typeof request !== "object") {
|
||||
throw new Error("Request body must be a JSON object.");
|
||||
}
|
||||
|
||||
if (!AIRCRAFT.some((aircraft) => aircraft.id === request.aircraftId)) {
|
||||
throw new Error(`Unknown aircraftId: ${request.aircraftId}`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateSimulateRequest(request) {
|
||||
if (!request || typeof request !== "object") {
|
||||
throw new Error("Request body must be a JSON object.");
|
||||
}
|
||||
|
||||
if (!AIRCRAFT.some((aircraft) => aircraft.id === request.aircraftId)) {
|
||||
throw new Error(`Unknown aircraftId: ${request.aircraftId}`);
|
||||
}
|
||||
|
||||
if (!request.initialState || typeof request.initialState !== "object") {
|
||||
throw new Error("initialState is required.");
|
||||
}
|
||||
|
||||
const maneuverType = request.maneuver?.type;
|
||||
const supported = new Set(["straight", "climb", "descent", "control-script", "cobra"]);
|
||||
if (!supported.has(maneuverType)) {
|
||||
throw new Error(
|
||||
`JSBSim minimal backend supports straight/climb/descent/control-script/cobra only. Unsupported maneuver.type: ${maneuverType}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function numberOrDefault(value, fallback) {
|
||||
return Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
472
service/src/backends/mockBackend.js
Normal file
472
service/src/backends/mockBackend.js
Normal file
@@ -0,0 +1,472 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
const EARTH_GRAVITY_MPS2 = 9.80665;
|
||||
const SPEED_OF_SOUND_SEA_LEVEL_MPS = 340.29;
|
||||
|
||||
export const backendInfo = {
|
||||
id: "mock",
|
||||
name: "Mock Dynamics",
|
||||
ready: true,
|
||||
};
|
||||
|
||||
export const AIRCRAFT = [
|
||||
{
|
||||
id: "f16-mock",
|
||||
name: "F-16 Mock Dynamics",
|
||||
source: "Mock",
|
||||
capabilities: {
|
||||
basic6dof: false,
|
||||
highAlpha: "visual-only",
|
||||
postStall: "visual-only",
|
||||
thrustVectoring: false,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function trimInitialState(request) {
|
||||
const altitudeM = numberOrDefault(request.altitudeM, 1000);
|
||||
const speedMps = numberOrDefault(request.speedMps, 120);
|
||||
const headingDeg = normalizeHeading(numberOrDefault(request.headingDeg, 0));
|
||||
const flightPathAngleDeg = numberOrDefault(request.flightPathAngleDeg, 0);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
backend: backendInfo.id,
|
||||
state: {
|
||||
position: [0, altitudeM, 0],
|
||||
rotation: eulerToQuat(0, flightPathAngleDeg, -headingDeg),
|
||||
velocityMps: speedMps,
|
||||
headingDeg,
|
||||
alphaDeg: 3.2,
|
||||
betaDeg: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function simulateManeuver(request) {
|
||||
validateSimulateRequest(request);
|
||||
|
||||
const sampleRateHz = clamp(numberOrDefault(request.sampleRateHz, 30), 1, 240);
|
||||
const integrationRateHz = clamp(numberOrDefault(request.integrationRateHz, 120), 1, 1000);
|
||||
const durationSec = clamp(numberOrDefault(request.durationSec, 10), 0.1, 3600);
|
||||
const totalSamples = Math.floor(durationSec * sampleRateHz) + 1;
|
||||
const samples = [];
|
||||
|
||||
const initial = request.initialState;
|
||||
const startPosition = vec3OrDefault(initial.position, [0, 1000, 0]);
|
||||
const startVelocity = numberOrDefault(initial.velocityMps, 120);
|
||||
const startHeading = normalizeHeading(numberOrDefault(initial.headingDeg, 0));
|
||||
const maneuver = request.maneuver ?? { type: "straight", parameters: {} };
|
||||
const parameters = maneuver.parameters ?? {};
|
||||
|
||||
for (let index = 0; index < totalSamples; index += 1) {
|
||||
const t = Math.min(index / sampleRateHz, durationSec);
|
||||
samples.push(
|
||||
sampleAtTime({
|
||||
t,
|
||||
maneuverType: maneuver.type,
|
||||
parameters,
|
||||
startPosition,
|
||||
startVelocity,
|
||||
startHeading,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
id: `sim_${randomUUID()}`,
|
||||
backend: backendInfo.id,
|
||||
aircraftId: request.aircraftId,
|
||||
sampleRateHz,
|
||||
integrationRateHz,
|
||||
durationSec,
|
||||
coordinateFrame: "local-eun",
|
||||
headingConvention:
|
||||
"true heading: 0 deg north, clockwise positive, counter-clockwise negative",
|
||||
samples,
|
||||
};
|
||||
}
|
||||
|
||||
function sampleAtTime({
|
||||
t,
|
||||
maneuverType,
|
||||
parameters,
|
||||
startPosition,
|
||||
startVelocity,
|
||||
startHeading,
|
||||
}) {
|
||||
const speedMps = Math.max(1, numberOrDefault(parameters.speedMps, startVelocity));
|
||||
let headingDeg = startHeading;
|
||||
let east = startPosition[0];
|
||||
let up = startPosition[1];
|
||||
let north = startPosition[2];
|
||||
let pitchDeg = 0;
|
||||
let rollDeg = 0;
|
||||
let loadFactor = 1;
|
||||
let alphaDeg = 3.2;
|
||||
let verticalSpeedMps = 0;
|
||||
|
||||
if (maneuverType === "level-turn") {
|
||||
const turnRateDegPerSec = numberOrDefault(
|
||||
parameters.turnRateDegPerSec,
|
||||
inferTurnRate(parameters.bankDeg, speedMps)
|
||||
);
|
||||
const turnRateRadPerSec = degToRad(turnRateDegPerSec);
|
||||
|
||||
headingDeg = normalizeHeading(startHeading + turnRateDegPerSec * t);
|
||||
rollDeg = numberOrDefault(
|
||||
parameters.bankDeg,
|
||||
inferBankDeg(turnRateDegPerSec, speedMps)
|
||||
);
|
||||
loadFactor = 1 / Math.max(0.2, Math.cos(degToRad(Math.abs(rollDeg))));
|
||||
alphaDeg = 3.2 + Math.max(0, loadFactor - 1) * 1.4;
|
||||
|
||||
if (Math.abs(turnRateRadPerSec) < 0.000001) {
|
||||
const straight = headingDistance(startHeading, speedMps * t);
|
||||
east += straight[0];
|
||||
north += straight[1];
|
||||
} else {
|
||||
const radiusM = speedMps / turnRateRadPerSec;
|
||||
const initialHeadingRad = degToRad(startHeading);
|
||||
const currentHeadingRad = initialHeadingRad + turnRateRadPerSec * t;
|
||||
east += radiusM * (Math.cos(initialHeadingRad) - Math.cos(currentHeadingRad));
|
||||
north += radiusM * (Math.sin(currentHeadingRad) - Math.sin(initialHeadingRad));
|
||||
}
|
||||
} else if (maneuverType === "cobra" || maneuverType === "control-script") {
|
||||
const controls = controlInputsAtTime(maneuverType, parameters, t);
|
||||
const profile = postStallProfile(maneuverType, parameters, t, startVelocity);
|
||||
const distance = profile.forwardDistanceM;
|
||||
const horizontal = headingDistance(startHeading, distance);
|
||||
|
||||
east += horizontal[0];
|
||||
north += horizontal[1];
|
||||
up += profile.altitudeDeltaM;
|
||||
pitchDeg = profile.pitchDeg;
|
||||
alphaDeg = profile.alphaDeg;
|
||||
loadFactor = profile.loadFactor;
|
||||
|
||||
return {
|
||||
t,
|
||||
position: [round(east), round(up), round(north)],
|
||||
rotation: eulerToQuat(rollDeg, pitchDeg, -headingDeg),
|
||||
velocityMps: round(profile.speedMps),
|
||||
headingDeg: round(headingDeg),
|
||||
pitchDeg: round(pitchDeg),
|
||||
rollDeg: round(rollDeg),
|
||||
alphaDeg: round(alphaDeg),
|
||||
betaDeg: 0,
|
||||
loadFactor: round(loadFactor),
|
||||
mach: round(profile.speedMps / SPEED_OF_SOUND_SEA_LEVEL_MPS),
|
||||
qbar: round(0.5 * 1.225 * profile.speedMps * profile.speedMps),
|
||||
verticalSpeedMps: round(profile.verticalSpeedMps),
|
||||
pitchRateDegS: round(profile.pitchRateDegS),
|
||||
rollRateDegS: 0,
|
||||
yawRateDegS: 0,
|
||||
throttle: controls.throttle,
|
||||
controlInputs: controls,
|
||||
controlSurfaces: {
|
||||
elevatorDeg: round(controls.elevator * 25),
|
||||
aileronDeg: round(controls.aileron * 21),
|
||||
rudderDeg: round(controls.rudder * 30),
|
||||
},
|
||||
};
|
||||
} else {
|
||||
const flightPathAngleDeg =
|
||||
maneuverType === "climb"
|
||||
? Math.abs(numberOrDefault(parameters.flightPathAngleDeg, 8))
|
||||
: maneuverType === "descent"
|
||||
? -Math.abs(numberOrDefault(parameters.flightPathAngleDeg, 8))
|
||||
: numberOrDefault(parameters.flightPathAngleDeg, 0);
|
||||
const horizontalSpeed = speedMps * Math.cos(degToRad(flightPathAngleDeg));
|
||||
const verticalSpeed = speedMps * Math.sin(degToRad(flightPathAngleDeg));
|
||||
const distance = horizontalSpeed * t;
|
||||
const horizontal = headingDistance(startHeading, distance);
|
||||
|
||||
east += horizontal[0];
|
||||
north += horizontal[1];
|
||||
up += verticalSpeed * t;
|
||||
pitchDeg = flightPathAngleDeg;
|
||||
alphaDeg = 3.2 + Math.max(0, flightPathAngleDeg) * 0.08;
|
||||
verticalSpeedMps = verticalSpeed;
|
||||
}
|
||||
|
||||
return {
|
||||
t,
|
||||
position: [round(east), round(up), round(north)],
|
||||
rotation: eulerToQuat(rollDeg, pitchDeg, -headingDeg),
|
||||
velocityMps: round(speedMps),
|
||||
headingDeg: round(headingDeg),
|
||||
pitchDeg: round(pitchDeg),
|
||||
rollDeg: round(rollDeg),
|
||||
alphaDeg: round(alphaDeg),
|
||||
betaDeg: 0,
|
||||
loadFactor: round(loadFactor),
|
||||
mach: round(speedMps / SPEED_OF_SOUND_SEA_LEVEL_MPS),
|
||||
qbar: round(0.5 * 1.225 * speedMps * speedMps),
|
||||
verticalSpeedMps: round(verticalSpeedMps),
|
||||
pitchRateDegS: 0,
|
||||
rollRateDegS: 0,
|
||||
yawRateDegS: 0,
|
||||
throttle: 0.82,
|
||||
controlInputs: {
|
||||
elevator: 0,
|
||||
aileron: 0,
|
||||
rudder: 0,
|
||||
throttle: 0.82,
|
||||
},
|
||||
controlSurfaces: {
|
||||
elevatorDeg: round(pitchDeg * 0.15),
|
||||
aileronDeg: round(rollDeg * 0.1),
|
||||
rudderDeg: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function validateSimulateRequest(request) {
|
||||
if (!request || typeof request !== "object") {
|
||||
throw new Error("Request body must be a JSON object.");
|
||||
}
|
||||
|
||||
if (!AIRCRAFT.some((aircraft) => aircraft.id === request.aircraftId)) {
|
||||
throw new Error(`Unknown aircraftId: ${request.aircraftId}`);
|
||||
}
|
||||
|
||||
if (!request.initialState || typeof request.initialState !== "object") {
|
||||
throw new Error("initialState is required.");
|
||||
}
|
||||
|
||||
if (!request.maneuver || typeof request.maneuver !== "object") {
|
||||
throw new Error("maneuver is required.");
|
||||
}
|
||||
|
||||
const allowedManeuvers = new Set([
|
||||
"straight",
|
||||
"climb",
|
||||
"descent",
|
||||
"level-turn",
|
||||
"control-script",
|
||||
"cobra",
|
||||
]);
|
||||
if (!allowedManeuvers.has(request.maneuver.type)) {
|
||||
throw new Error(`Unsupported maneuver.type: ${request.maneuver.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
function controlInputsAtTime(maneuverType, parameters, t) {
|
||||
const timeline =
|
||||
maneuverType === "cobra" ? createCobraTimeline(parameters) : parameters.timeline;
|
||||
if (!Array.isArray(timeline) || timeline.length === 0) {
|
||||
return { elevator: 0, aileron: 0, rudder: 0, throttle: 0.82 };
|
||||
}
|
||||
|
||||
const points = timeline
|
||||
.filter((point) => point && typeof point === "object")
|
||||
.toSorted((a, b) => numberOrDefault(a.t, 0) - numberOrDefault(b.t, 0));
|
||||
|
||||
if (points.length === 0 || t <= numberOrDefault(points[0].t, 0)) {
|
||||
return normalizeControlPoint(points[0]);
|
||||
}
|
||||
|
||||
for (let index = 1; index < points.length; index += 1) {
|
||||
const previous = points[index - 1];
|
||||
const current = points[index];
|
||||
const previousT = numberOrDefault(previous.t, 0);
|
||||
const currentT = numberOrDefault(current.t, previousT);
|
||||
if (t <= currentT) {
|
||||
const amount = currentT <= previousT ? 0 : (t - previousT) / (currentT - previousT);
|
||||
return interpolateControls(
|
||||
normalizeControlPoint(previous),
|
||||
normalizeControlPoint(current),
|
||||
amount
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return normalizeControlPoint(points.at(-1));
|
||||
}
|
||||
|
||||
function createCobraTimeline(parameters) {
|
||||
const pullDuration = Math.max(0.1, numberOrDefault(parameters.pullDurationSec, 0.9));
|
||||
const unloadDuration = Math.max(0.1, numberOrDefault(parameters.unloadDurationSec, 0.7));
|
||||
const recoveryDuration = Math.max(0.1, numberOrDefault(parameters.recoveryDurationSec, 2.0));
|
||||
const maxElevatorCmd = clamp(numberOrDefault(parameters.maxElevatorCmd, -1), -1, 1);
|
||||
const recoveryElevatorCmd = clamp(numberOrDefault(parameters.recoveryElevatorCmd, 0.35), -1, 1);
|
||||
const throttleCmd = clamp(numberOrDefault(parameters.throttleCmd, 1), 0, 1);
|
||||
|
||||
return [
|
||||
{ t: 0, elevator: 0, aileron: 0, rudder: 0, throttle: throttleCmd },
|
||||
{ t: 0.2, elevator: maxElevatorCmd, aileron: 0, rudder: 0, throttle: throttleCmd },
|
||||
{
|
||||
t: 0.2 + pullDuration,
|
||||
elevator: 0,
|
||||
aileron: 0,
|
||||
rudder: 0,
|
||||
throttle: throttleCmd,
|
||||
},
|
||||
{
|
||||
t: 0.2 + pullDuration + unloadDuration,
|
||||
elevator: recoveryElevatorCmd,
|
||||
aileron: 0,
|
||||
rudder: 0,
|
||||
throttle: throttleCmd,
|
||||
},
|
||||
{
|
||||
t: 0.2 + pullDuration + unloadDuration + recoveryDuration,
|
||||
elevator: 0,
|
||||
aileron: 0,
|
||||
rudder: 0,
|
||||
throttle: throttleCmd,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function postStallProfile(maneuverType, parameters, t, startVelocity) {
|
||||
if (maneuverType === "control-script") {
|
||||
const controls = controlInputsAtTime(maneuverType, parameters, t);
|
||||
const pitchDeg = controls.elevator * 35;
|
||||
const speedMps = Math.max(30, startVelocity - Math.abs(pitchDeg) * 0.4);
|
||||
return {
|
||||
pitchDeg,
|
||||
alphaDeg: 3.2 + Math.max(0, pitchDeg) * 0.8,
|
||||
speedMps,
|
||||
forwardDistanceM: speedMps * t,
|
||||
altitudeDeltaM: Math.sin(degToRad(pitchDeg)) * speedMps * t * 0.15,
|
||||
verticalSpeedMps: Math.sin(degToRad(pitchDeg)) * speedMps,
|
||||
pitchRateDegS: controls.elevator * 45,
|
||||
loadFactor: 1 + Math.max(0, controls.elevator) * 1.8,
|
||||
};
|
||||
}
|
||||
|
||||
const pullDuration = Math.max(0.1, numberOrDefault(parameters.pullDurationSec, 0.9));
|
||||
const unloadDuration = Math.max(0.1, numberOrDefault(parameters.unloadDurationSec, 0.7));
|
||||
const recoveryDuration = Math.max(0.1, numberOrDefault(parameters.recoveryDurationSec, 2.0));
|
||||
const peakPitchDeg = numberOrDefault(parameters.peakPitchDeg, 105);
|
||||
const tPullEnd = 0.2 + pullDuration;
|
||||
const tUnloadEnd = tPullEnd + unloadDuration;
|
||||
const tRecoveryEnd = tUnloadEnd + recoveryDuration;
|
||||
|
||||
let pitchDeg;
|
||||
if (t <= 0.2) {
|
||||
pitchDeg = 0;
|
||||
} else if (t <= tPullEnd) {
|
||||
pitchDeg = smoothstep((t - 0.2) / pullDuration) * peakPitchDeg;
|
||||
} else if (t <= tUnloadEnd) {
|
||||
pitchDeg = peakPitchDeg - smoothstep((t - tPullEnd) / unloadDuration) * 35;
|
||||
} else if (t <= tRecoveryEnd) {
|
||||
pitchDeg = (peakPitchDeg - 35) * (1 - smoothstep((t - tUnloadEnd) / recoveryDuration));
|
||||
} else {
|
||||
pitchDeg = 0;
|
||||
}
|
||||
|
||||
const speedDrop = Math.min(startVelocity * 0.62, Math.max(0, pitchDeg) * 0.9);
|
||||
const speedMps = Math.max(35, startVelocity - speedDrop);
|
||||
const forwardFactor = Math.max(0.18, Math.cos(degToRad(Math.min(Math.abs(pitchDeg), 82))));
|
||||
const forwardDistanceM = speedMps * t * forwardFactor;
|
||||
const altitudeDeltaM = Math.sin(degToRad(Math.min(pitchDeg, 75))) * speedMps * Math.min(t, 3.5) * 0.18;
|
||||
|
||||
return {
|
||||
pitchDeg,
|
||||
alphaDeg: Math.max(3.2, pitchDeg * 0.95),
|
||||
speedMps,
|
||||
forwardDistanceM,
|
||||
altitudeDeltaM,
|
||||
verticalSpeedMps: Math.sin(degToRad(Math.min(pitchDeg, 75))) * speedMps,
|
||||
pitchRateDegS: t <= tPullEnd ? peakPitchDeg / pullDuration : -peakPitchDeg / recoveryDuration,
|
||||
loadFactor: 1 + Math.max(0, pitchDeg / peakPitchDeg) * 2.5,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeControlPoint(point = {}) {
|
||||
return {
|
||||
elevator: clamp(numberOrDefault(point.elevator, 0), -1, 1),
|
||||
aileron: clamp(numberOrDefault(point.aileron, 0), -1, 1),
|
||||
rudder: clamp(numberOrDefault(point.rudder, 0), -1, 1),
|
||||
throttle: clamp(numberOrDefault(point.throttle, 0.82), 0, 1),
|
||||
};
|
||||
}
|
||||
|
||||
function interpolateControls(start, end, amount) {
|
||||
return {
|
||||
elevator: round(start.elevator + (end.elevator - start.elevator) * amount),
|
||||
aileron: round(start.aileron + (end.aileron - start.aileron) * amount),
|
||||
rudder: round(start.rudder + (end.rudder - start.rudder) * amount),
|
||||
throttle: round(start.throttle + (end.throttle - start.throttle) * amount),
|
||||
};
|
||||
}
|
||||
|
||||
function smoothstep(value) {
|
||||
const x = clamp(value, 0, 1);
|
||||
return x * x * (3 - 2 * x);
|
||||
}
|
||||
|
||||
function headingDistance(headingDeg, distanceM) {
|
||||
const headingRad = degToRad(headingDeg);
|
||||
return [Math.sin(headingRad) * distanceM, Math.cos(headingRad) * distanceM];
|
||||
}
|
||||
|
||||
function inferTurnRate(bankDeg, speedMps) {
|
||||
const bankRad = degToRad(numberOrDefault(bankDeg, 30));
|
||||
return radToDeg((EARTH_GRAVITY_MPS2 * Math.tan(bankRad)) / Math.max(1, speedMps));
|
||||
}
|
||||
|
||||
function inferBankDeg(turnRateDegPerSec, speedMps) {
|
||||
const turnRateRadPerSec = degToRad(turnRateDegPerSec);
|
||||
return radToDeg(Math.atan((turnRateRadPerSec * speedMps) / EARTH_GRAVITY_MPS2));
|
||||
}
|
||||
|
||||
function eulerToQuat(rollDeg, pitchDeg, yawDeg) {
|
||||
const roll = degToRad(rollDeg);
|
||||
const pitch = degToRad(pitchDeg);
|
||||
const yaw = degToRad(yawDeg);
|
||||
const cy = Math.cos(yaw * 0.5);
|
||||
const sy = Math.sin(yaw * 0.5);
|
||||
const cp = Math.cos(pitch * 0.5);
|
||||
const sp = Math.sin(pitch * 0.5);
|
||||
const cr = Math.cos(roll * 0.5);
|
||||
const sr = Math.sin(roll * 0.5);
|
||||
|
||||
return [
|
||||
round(sr * cp * cy - cr * sp * sy),
|
||||
round(cr * sp * cy + sr * cp * sy),
|
||||
round(cr * cp * sy - sr * sp * cy),
|
||||
round(cr * cp * cy + sr * sp * sy),
|
||||
];
|
||||
}
|
||||
|
||||
function normalizeHeading(value) {
|
||||
let heading = numberOrDefault(value, 0) % 360;
|
||||
if (heading > 180) {
|
||||
heading -= 360;
|
||||
}
|
||||
if (heading <= -180) {
|
||||
heading += 360;
|
||||
}
|
||||
return heading;
|
||||
}
|
||||
|
||||
function vec3OrDefault(value, fallback) {
|
||||
if (!Array.isArray(value) || value.length !== 3) {
|
||||
return fallback;
|
||||
}
|
||||
return value.map((item, index) => numberOrDefault(item, fallback[index]));
|
||||
}
|
||||
|
||||
function numberOrDefault(value, fallback) {
|
||||
return Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function degToRad(value) {
|
||||
return (value * Math.PI) / 180;
|
||||
}
|
||||
|
||||
function radToDeg(value) {
|
||||
return (value * 180) / Math.PI;
|
||||
}
|
||||
|
||||
function round(value) {
|
||||
return Math.round(value * 1000000) / 1000000;
|
||||
}
|
||||
Reference in New Issue
Block a user