接入 LinuxCNC TP 运行反馈
This commit is contained in:
@@ -0,0 +1,465 @@
|
||||
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";
|
||||
}
|
||||
Reference in New Issue
Block a user