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"; }