466 lines
16 KiB
JavaScript
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";
|
|
}
|