Files
cnc_wams/web-rtcp-5axis-sim-plan/app/src/runtime/linuxcnc-interpreter-runtime.js

466 lines
16 KiB
JavaScript

const DEFAULT_SDK_MODULE_URL = "../../../../wasm-port/runtime/sdk/src/linuxcnc-interp.js";
const DEFAULT_TP_SDK_MODULE_URL = "../../../../wasm-port/runtime/sdk/src/linuxcnc-tp.js";
const SOURCE_MODE = "linuxcnc-interpreter-wasm";
const SEMANTIC_BOUNDARY = "linuxcnc_interpreter_wasm_canonical_events";
const PLANNER_TIMING_BOUNDARY = "linuxcnc_tp_queue_runtime_timing_from_canonical_motion";
const SWITCHKINS_REMAP_BOUNDARY = "linuxcnc_switchkins_remap_mcode_preserved_web_runtime_applied";
const FIVE_AXIS_REMAP_FLAGS = [
"fiveaxis_ini_open=1",
"fiveaxis_remaps_ready=1",
"fiveaxis_file_reached_exit=1",
];
const AXES = ["x", "y", "z", "a", "b", "c", "u", "v", "w"];
const SWITCHKINS_M_CODES = new Map([
[428, { switchkinsType: 1, requestedKinsType: "tcp" }],
[429, { switchkinsType: 0, requestedKinsType: "identity" }],
[430, { switchkinsType: 2, requestedKinsType: "userk" }],
]);
const PLANE_AXIS_MAP = {
170: ["x", "y", "z"],
180: ["x", "z", "y"],
190: ["y", "z", "x"],
};
export async function createLinuxCncInterpreterRuntime({
moduleOptions = null,
tpModuleOptions = null,
wasmRoot = null,
sdkModuleUrl = DEFAULT_SDK_MODULE_URL,
tpSdkModuleUrl = DEFAULT_TP_SDK_MODULE_URL,
} = {}) {
const { createLinuxCncInterpSdk } = await import(sdkModuleUrl);
const resolvedModuleOptions = moduleOptions || await createDefaultModuleOptions({ wasmRoot });
const sdk = await createLinuxCncInterpSdk(resolvedModuleOptions);
const tpRuntime = await createOptionalTpRuntime({ tpModuleOptions, wasmRoot, tpSdkModuleUrl });
return {
apiName: "web-rtcp-5axis-linuxcnc-interpreter-runtime",
loaded: true,
sourceMode: SOURCE_MODE,
semanticBoundary: SEMANTIC_BOUNDARY,
executionContext: "direct",
sdk,
tpRuntime,
readiness() {
return {
apiName: "web-rtcp-5axis-linuxcnc-interpreter-runtime-readiness",
loaded: true,
sourceMode: SOURCE_MODE,
semanticBoundary: SEMANTIC_BOUNDARY,
executionContext: "direct",
runProgramReady: typeof sdk.runProgram === "function",
remapRuntimeReady: false,
plannerRuntimeReady: tpRuntime?.loaded === true,
plannerSemanticBoundary: tpRuntime?.semanticBoundary || null,
};
},
runProgram(programText) {
const prepared = prepareLinuxCncProgramForRuntime(programText);
const resultText = sdk.runProgram(prepared.runtimeProgramText);
const motion = parseLinuxCncCanonicalMotion(resultText, programText, prepared.switchkinsEvents);
return createProgramExecutionResult({
programText,
resultText: prependRuntimeEvents(resultText, prepared.switchkinsEvents),
motion,
switchkinsEvents: prepared.switchkinsEvents,
runtimeProgramText: prepared.runtimeProgramText,
plannerTiming: runPlannerTiming(tpRuntime, motion),
});
},
runMachineFileProgram({ plan, files = null, executionMode = "fiveAxisRemap" } = {}) {
if (!plan?.wasmIniPath || !plan?.wasmProgramPath) {
throw new Error("runMachineFileProgram requires a machine-file staging plan with INI and program paths");
}
if (typeof sdk.runSimConfigProgram !== "function") {
throw new Error("LinuxCNC interpreter SDK missing runSimConfigProgram");
}
const stagedFiles = files || plan.files;
const resultText = sdk.runSimConfigProgram({
files: stagedFiles.map((file) => ({
path: file.wasmPath || file.path,
text: file.text,
executable: file.executable,
})),
programPath: plan.wasmProgramPath,
iniPath: plan.wasmIniPath,
executionMode,
});
const programFile = stagedFiles.find((file) => (
(file.wasmPath || file.path) === plan.wasmProgramPath
));
const programText = programFile?.text || "";
const motion = parseLinuxCncCanonicalMotion(resultText, programText);
return createProgramExecutionResult({
programText,
resultText,
motion,
machineFilePlan: plan,
sourceMode: "linuxcnc-machine-file-remap-wasm",
semanticBoundary: "linuxcnc_fiveaxis_remap_wasm_machine_file_execution",
plannerTiming: runPlannerTiming(tpRuntime, motion),
});
},
};
}
export function createLinuxCncInterpreterRuntimeDescriptor(runtime) {
if (!runtime?.loaded) return null;
return {
apiName: runtime.apiName,
loaded: runtime.loaded,
sourceMode: runtime.sourceMode,
semanticBoundary: runtime.semanticBoundary,
executionContext: runtime.executionContext || "direct",
};
}
function createProgramExecutionResult({
programText,
resultText,
motion,
switchkinsEvents = [],
runtimeProgramText = programText,
machineFilePlan = null,
sourceMode = SOURCE_MODE,
semanticBoundary = SEMANTIC_BOUNDARY,
plannerTiming = null,
}) {
const canonicalEventCount = String(resultText).split("\n").filter((line) => line.startsWith("canon_event=")).length;
const machineFileExecutionReady = Boolean(
machineFilePlan && FIVE_AXIS_REMAP_FLAGS.every((flag) => String(resultText).includes(flag)),
);
const plannerRuntimeReady = plannerTiming?.plannerRuntimeReady === true
&& plannerTiming.motionCount === motion.length;
return {
apiName: "web-rtcp-5axis-linuxcnc-interpreter-program-execution",
sourceMode,
semanticBoundary,
resultText,
runtimeProgramText,
motion,
plannerTiming,
switchkinsEvents,
switchkinsRemapBoundary: switchkinsEvents.length > 0 ? SWITCHKINS_REMAP_BOUNDARY : null,
machineFilePlan: machineFilePlan
? {
apiName: machineFilePlan.apiName,
profileId: machineFilePlan.profileId,
wasmIniPath: machineFilePlan.wasmIniPath,
wasmProgramPath: machineFilePlan.wasmProgramPath,
selectedProgramSourceRel: machineFilePlan.selectedProgramSourceRel || null,
selectedProgramFilename: machineFilePlan.selectedProgramFilename || null,
fileCount: machineFilePlan.files?.length ?? 0,
semanticBoundary: machineFilePlan.semanticBoundary,
}
: null,
summary: {
ready: motion.length > 0,
programLineCount: programText.split(/\r?\n/).filter((line) => line.trim()).length,
canonicalEventCount,
motionEventCount: motion.length,
motionTypes: [...new Set(motion.map((event) => event.type))],
finalAxes: motion.at(-1)?.axes || Object.fromEntries(AXES.map((axis) => [axis, 0])),
switchkinsEventCount: switchkinsEvents.length,
switchkinsCodes: [...new Set(switchkinsEvents.map((event) => event.code))],
switchkinsRemapBoundary: switchkinsEvents.length > 0 ? SWITCHKINS_REMAP_BOUNDARY : null,
remapRuntimeReady: machineFileExecutionReady,
plannerRuntimeReady,
plannerSemanticBoundary: plannerRuntimeReady ? PLANNER_TIMING_BOUNDARY : null,
machineFileExecutionReady,
fullLinuxCncProgramExecutionReady: false,
},
};
}
async function createOptionalTpRuntime({ tpModuleOptions, wasmRoot, tpSdkModuleUrl }) {
try {
const { createLinuxCncTpSdk } = await import(tpSdkModuleUrl);
const resolvedOptions = tpModuleOptions || await createDefaultTpModuleOptions({ wasmRoot });
const sdk = await createLinuxCncTpSdk(resolvedOptions);
return {
loaded: true,
semanticBoundary: PLANNER_TIMING_BOUNDARY,
sdk,
};
} catch (error) {
return {
loaded: false,
semanticBoundary: null,
error: error instanceof Error ? error.message : String(error),
};
}
}
function runPlannerTiming(tpRuntime, motion) {
if (!tpRuntime?.loaded || typeof tpRuntime.sdk?.runCanonicalMotionTiming !== "function") {
return null;
}
try {
return tpRuntime.sdk.runCanonicalMotionTiming({
motion,
options: {
cycleTime: 0.001,
queueSize: 32,
maxCycles: 2000000,
sampleStride: 10,
maxVelocity: 35,
maxAcceleration: 500,
maxJerk: 1000,
tolerance: 0,
},
});
} catch (error) {
return {
apiName: "web-rtcp-5axis-linuxcnc-tp-program-timing",
semanticBoundary: PLANNER_TIMING_BOUNDARY,
plannerRuntimeReady: false,
error: error instanceof Error ? error.message : String(error),
motionCount: motion.length,
totalSeconds: 0,
totalMinutes: 0,
feedSeconds: 0,
rapidSeconds: 0,
segments: [],
};
}
}
export function parseLinuxCncCanonicalMotion(resultText, programText = "", switchkinsEvents = []) {
const axes = Object.fromEntries(AXES.map((axis) => [axis, 0]));
const sourceLines = programLineMap(programText);
const feedRatesByLine = feedRatesBySourceLine(programText);
const switchkinsByLine = switchkinsEventsByLine(switchkinsEvents);
const motion = [];
let activePlane = 170;
let activeSwitchkinsEvent = null;
let activeFeedRate = null;
for (const line of String(resultText).split("\n")) {
const feedRate = readCanonicalNumber(line, "feed_rate");
if (Number.isFinite(feedRate) && feedRate > 0) {
activeFeedRate = feedRate;
}
const plane = readCanonicalNumber(line, "plane");
if (plane && PLANE_AXIS_MAP[plane]) {
activePlane = plane;
}
const event = line.match(/^canon_event=(STRAIGHT_TRAVERSE|STRAIGHT_FEED|ARC_FEED)\b/);
if (!event) continue;
if (event[1] === "ARC_FEED") {
const [firstAxis, secondAxis, thirdAxis] = PLANE_AXIS_MAP[activePlane] ?? PLANE_AXIS_MAP[170];
const firstEnd = readCanonicalNumber(line, "first_end");
const secondEnd = readCanonicalNumber(line, "second_end");
const axisEndPoint = readCanonicalNumber(line, "axis_end_point");
if (Number.isFinite(firstEnd)) axes[firstAxis] = firstEnd;
if (Number.isFinite(secondEnd)) axes[secondAxis] = secondEnd;
if (Number.isFinite(axisEndPoint)) axes[thirdAxis] = axisEndPoint;
axes.arc = {
plane: activePlane,
firstAxis,
secondAxis,
thirdAxis,
firstEnd,
secondEnd,
centerFirst: readCanonicalNumber(line, "first_axis"),
centerSecond: readCanonicalNumber(line, "second_axis"),
rotation: readCanonicalNumber(line, "rotation"),
axisEndPoint,
};
} else {
for (const axis of AXES) {
const value = readCanonicalNumber(line, axis);
if (value !== null && Number.isFinite(value)) axes[axis] = value;
}
}
const sourceLine = readCanonicalNumber(line, "line");
if (Number.isFinite(sourceLine)) {
const event = latestSwitchkinsEventAtOrBeforeLine(switchkinsByLine, sourceLine);
if (event) activeSwitchkinsEvent = event;
const sourceFeedRate = latestFeedRateAtOrBeforeLine(feedRatesByLine, sourceLine);
if (Number.isFinite(sourceFeedRate) && sourceFeedRate > 0) {
activeFeedRate = sourceFeedRate;
}
}
motion.push({
type: event[1],
line: Number.isFinite(sourceLine) ? sourceLine : null,
statement: Number.isFinite(sourceLine) ? (sourceLines.get(sourceLine) ?? "-") : "-",
axes: { ...axes },
kinsType: activeSwitchkinsEvent?.requestedKinsType || null,
switchkinsType: activeSwitchkinsEvent?.switchkinsType ?? null,
switchkinsCode: activeSwitchkinsEvent?.code || null,
switchkinsRemapBoundary: activeSwitchkinsEvent ? SWITCHKINS_REMAP_BOUNDARY : null,
feedRate: activeFeedRate,
raw: line,
});
}
return motion;
}
function feedRatesBySourceLine(programText) {
const rates = [];
String(programText).split(/\r?\n/).forEach((line, index) => {
const codeOnly = stripComments(line);
let feedRate = null;
for (const match of codeOnly.matchAll(/\bF\s*([-+]?\d+(?:\.\d+)?)/gi)) {
const value = Number(match[1]);
if (Number.isFinite(value) && value > 0) feedRate = value;
}
if (feedRate !== null) {
rates.push({ line: index + 1, feedRate });
}
});
return rates;
}
function latestFeedRateAtOrBeforeLine(rates, sourceLine) {
let feedRate = null;
for (const entry of rates) {
if (entry.line <= sourceLine) feedRate = entry.feedRate;
}
return feedRate;
}
export function prepareLinuxCncProgramForRuntime(programText) {
const switchkinsEvents = [];
const runtimeLines = String(programText).split(/\r?\n/).map((line, index) => {
const lineNumber = index + 1;
const events = readSwitchkinsEventsFromLine(line, lineNumber);
if (events.length === 0) return line;
switchkinsEvents.push(...events);
return stripSwitchkinsMcodesFromLine(line, events);
});
return {
runtimeProgramText: runtimeLines.join("\n"),
switchkinsEvents,
};
}
function readSwitchkinsEventsFromLine(line, lineNumber) {
const codeOnly = stripComments(String(line));
const events = [];
for (const match of codeOnly.matchAll(/\bM\s*([0-9]+(?:\.[0-9]+)?)\b/gi)) {
const value = Number(match[1]);
const mCode = Number.isInteger(value) ? value : null;
const switchkins = SWITCHKINS_M_CODES.get(mCode);
if (!switchkins) continue;
events.push({
line: lineNumber,
code: `M${mCode}`,
switchkinsType: switchkins.switchkinsType,
requestedKinsType: switchkins.requestedKinsType,
semanticBoundary: SWITCHKINS_REMAP_BOUNDARY,
});
}
return events;
}
function stripSwitchkinsMcodesFromLine(line, events) {
const eventCodes = new Set(events.map((event) => event.code.slice(1)));
let nextLine = String(line).replace(/\bM\s*([0-9]+(?:\.[0-9]+)?)\b/gi, (token, value) => {
const numericValue = Number(value);
if (Number.isInteger(numericValue) && eventCodes.has(String(numericValue))) {
return " ";
}
return token;
});
const codeOnly = stripComments(nextLine).replace(/\bN\s*[0-9]+\b/gi, "").trim();
if (!codeOnly) {
nextLine = `(web runtime switchkins ${events.map((event) => event.code).join(" ")})`;
}
return nextLine;
}
function stripComments(line) {
return String(line)
.replace(/\([^)]*\)/g, " ")
.replace(/;.*$/g, " ");
}
function switchkinsEventsByLine(events) {
return [...events]
.filter((event) => Number.isFinite(event.line))
.sort((left, right) => left.line - right.line);
}
function latestSwitchkinsEventAtOrBeforeLine(events, lineNumber) {
let latest = null;
for (const event of events) {
if (event.line > lineNumber) break;
latest = event;
}
return latest;
}
function prependRuntimeEvents(resultText, switchkinsEvents) {
if (switchkinsEvents.length === 0) return resultText;
const eventLines = switchkinsEvents.map((event) => (
`web_runtime_event=SWITCHKINS line=${event.line} code=${event.code} switchkins_type=${event.switchkinsType} requested_kins=${event.requestedKinsType}`
));
return `${eventLines.join("\n")}\n${resultText}`;
}
function programLineMap(programText) {
const lines = new Map();
programText.split(/\r?\n/).forEach((line, index) => {
lines.set(index + 1, line.trim() || "(blank)");
});
return lines;
}
function readCanonicalNumber(line, field) {
const match = String(line).match(new RegExp(`\\b${field}=([-+0-9.eE]+)`));
return match ? Number(match[1]) : null;
}
async function createDefaultModuleOptions({ wasmRoot }) {
const quietOptions = { print() {}, printErr() {} };
if (!isNodeRuntime()) return quietOptions;
const [{ readFileSync }, { dirname, resolve }, { fileURLToPath }] = await Promise.all([
import("node:fs"),
import("node:path"),
import("node:url"),
]);
const moduleDir = dirname(fileURLToPath(import.meta.url));
const resolvedWasmRoot = wasmRoot || resolve(moduleDir, "../../../../wasm-port/build/wasm/core");
return {
...quietOptions,
wasmBinary: readFileSync(resolve(resolvedWasmRoot, "linuxcnc_interp.wasm")),
};
}
async function createDefaultTpModuleOptions({ wasmRoot }) {
const quietOptions = { print() {}, printErr() {} };
if (!isNodeRuntime()) return quietOptions;
const [{ readFileSync }, { dirname, resolve }, { fileURLToPath }] = await Promise.all([
import("node:fs"),
import("node:path"),
import("node:url"),
]);
const moduleDir = dirname(fileURLToPath(import.meta.url));
const resolvedWasmRoot = wasmRoot || resolve(moduleDir, "../../../../wasm-port/build/wasm/tp");
return {
...quietOptions,
wasmBinary: readFileSync(resolve(resolvedWasmRoot, "linuxcnc_tp.wasm")),
};
}
function isNodeRuntime() {
return typeof process === "object"
&& typeof process.versions === "object"
&& typeof process.versions.node === "string"
&& process.type !== "renderer";
}